mcp-log-query-server 1.0.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.
package/server-sse.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Log Query MCP Server - SSE/HTTP 模式
3
+ *
4
+ * 支持通过 HTTP 和 Server-Sent Events 与 MCP 客户端通信
5
+ * 适用于 Docker 部署
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
10
+ import express from 'express';
11
+ import cors from 'cors';
12
+ import { findService, getAllServices, DEFAULTS } from './config.js';
13
+ import { queryLog, testConnection } from './ssh-client.js';
14
+
15
+ const app = express();
16
+ const PORT = process.env.PORT || 3100;
17
+
18
+ // 启用 CORS
19
+ app.use(cors());
20
+ app.use(express.json());
21
+
22
+ // 存储活跃的 SSE 连接
23
+ const transports = {};
24
+
25
+ // 健康检查端点
26
+ app.get('/health', (req, res) => {
27
+ res.json({ status: 'ok', service: 'mcp-log-query' });
28
+ });
29
+
30
+ // SSE 端点 - 客户端连接
31
+ app.get('/sse', async (req, res) => {
32
+ console.log('[SSE] 新客户端连接');
33
+
34
+ // 设置 SSE 响应头
35
+ res.setHeader('Content-Type', 'text/event-stream');
36
+ res.setHeader('Cache-Control', 'no-cache');
37
+ res.setHeader('Connection', 'keep-alive');
38
+ res.setHeader('Access-Control-Allow-Origin', '*');
39
+ res.flushHeaders();
40
+
41
+ // 生成 sessionId
42
+ const sessionId = Date.now().toString();
43
+
44
+ // 创建 SSE transport,传入 sessionId
45
+ const transport = new SSEServerTransport(`/message?sessionId=${sessionId}`, res);
46
+ transports[sessionId] = transport;
47
+
48
+ // 创建 MCP Server
49
+ const server = new McpServer({
50
+ name: 'mcp-log-query',
51
+ version: '1.0.0'
52
+ });
53
+
54
+ // 注册工具
55
+ registerTools(server);
56
+
57
+ // 连接 transport
58
+ try {
59
+ await server.connect(transport);
60
+ console.log(`[SSE] 客户端已连接, sessionId: ${sessionId}`);
61
+ } catch (err) {
62
+ console.error('[SSE] 连接错误:', err);
63
+ }
64
+
65
+ // 保持连接活跃 - 每 30 秒发送心跳
66
+ const heartbeat = setInterval(() => {
67
+ if (!res.writableEnded) {
68
+ res.write(': heartbeat\n\n');
69
+ }
70
+ }, 30000);
71
+
72
+ // 清理断开的连接
73
+ res.on('close', () => {
74
+ console.log(`[SSE] 客户端断开, sessionId: ${sessionId}`);
75
+ clearInterval(heartbeat);
76
+ delete transports[sessionId];
77
+ });
78
+ });
79
+
80
+ // 消息端点 - 接收客户端请求
81
+ app.post('/message', async (req, res) => {
82
+ const sessionId = req.query.sessionId;
83
+ const transport = transports[sessionId];
84
+
85
+ if (!transport) {
86
+ res.status(404).json({ error: 'Session not found' });
87
+ return;
88
+ }
89
+
90
+ await transport.handlePostMessage(req, res);
91
+ });
92
+
93
+ /**
94
+ * 注册 MCP 工具
95
+ */
96
+ function registerTools(server) {
97
+ // query_log 工具
98
+ server.tool('query_log', {
99
+ description: '查询服务容器的日志文件。返回最近的日志内容。',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ service: {
104
+ type: 'string',
105
+ description: '服务名称,如 clife-senior-health、clife-senior-archive,或别名如 health、archive'
106
+ },
107
+ lines: {
108
+ type: 'number',
109
+ description: '返回的日志行数,默认 100',
110
+ default: 100
111
+ }
112
+ },
113
+ required: ['service']
114
+ }
115
+ }, async (args) => {
116
+ const service = findService(args.service);
117
+ if (!service) {
118
+ return { content: [{ type: 'text', text: `错误: 未找到服务 "${args.service}"` }] };
119
+ }
120
+
121
+ const lines = args.lines || DEFAULTS.lines;
122
+ const command = `tail -${lines}`;
123
+
124
+ console.log(`[MCP] 查询日志: ${service.name}`);
125
+ const result = await queryLog(service, command);
126
+
127
+ return {
128
+ content: [{
129
+ type: 'text',
130
+ text: `## ${service.name} 日志 (最近 ${lines} 行)\n\n\`\`\`\n${result}\n\`\`\``
131
+ }]
132
+ };
133
+ });
134
+
135
+ // search_log 工具
136
+ server.tool('search_log', {
137
+ description: '在服务日志中搜索关键词',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ service: { type: 'string', description: '服务名称或别名' },
142
+ keyword: { type: 'string', description: '搜索关键词' },
143
+ context_lines: { type: 'number', description: '上下文行数', default: 5 },
144
+ case_sensitive: { type: 'boolean', description: '区分大小写', default: false }
145
+ },
146
+ required: ['service', 'keyword']
147
+ }
148
+ }, async (args) => {
149
+ const service = findService(args.service);
150
+ if (!service) {
151
+ return { content: [{ type: 'text', text: `错误: 未找到服务 "${args.service}"` }] };
152
+ }
153
+
154
+ const grepFlags = args.case_sensitive ? '' : '-i';
155
+ const contextLines = args.context_lines || 5;
156
+ const command = `grep ${grepFlags} -C ${contextLines} "${args.keyword}"`;
157
+
158
+ console.log(`[MCP] 搜索日志: ${service.name}, 关键词: ${args.keyword}`);
159
+ const result = await queryLog(service, command);
160
+
161
+ return {
162
+ content: [{
163
+ type: 'text',
164
+ text: `## ${service.name} 日志搜索结果\n\n**关键词**: ${args.keyword}\n\n\`\`\`\n${result || '未找到匹配内容'}\n\`\`\``
165
+ }]
166
+ };
167
+ });
168
+
169
+ // list_services 工具
170
+ server.tool('list_services', {
171
+ description: '列出所有可查询日志的服务',
172
+ inputSchema: { type: 'object', properties: {} }
173
+ }, async () => {
174
+ const services = getAllServices();
175
+ const text = services.map(s =>
176
+ `- **${s.name}**: ${s.description}\n 别名: ${s.aliases.join(', ')}`
177
+ ).join('\n');
178
+
179
+ return { content: [{ type: 'text', text: `## 可用服务\n\n${text}` }] };
180
+ });
181
+
182
+ // test_connection 工具
183
+ server.tool('test_connection', {
184
+ description: '测试 SSH 连接',
185
+ inputSchema: { type: 'object', properties: {} }
186
+ }, async () => {
187
+ const result = await testConnection();
188
+ return {
189
+ content: [{
190
+ type: 'text',
191
+ text: result.success ? '✅ SSH 连接正常' : `❌ 连接失败: ${result.error}`
192
+ }]
193
+ };
194
+ });
195
+ }
196
+
197
+ // 启动服务器
198
+ app.listen(PORT, () => {
199
+ console.log(`[MCP] Log Query Server (SSE) 已启动,端口: ${PORT}`);
200
+ console.log(`[MCP] SSE 端点: http://localhost:${PORT}/sse`);
201
+ console.log(`[MCP] 健康检查: http://localhost:${PORT}/health`);
202
+ });
203
+
package/ssh-client.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * SSH Client - 通过 JumpServer 堡垒机连接 K8s 服务器并执行命令
3
+ *
4
+ * JumpServer 交互流程:
5
+ * 1. SSH 连接堡垒机,等待 Opt> 提示符
6
+ * 2. 输入目标服务器 IP,等待 [Host]> 提示符
7
+ * 3. 输入服务器 ID (如 1),等待进入服务器 shell
8
+ * 4. 执行 kubectl 命令查询日志
9
+ * 5. 退出
10
+ *
11
+ * 注意:
12
+ * - 堡垒机只有 30 秒等待时间,必须快速响应!
13
+ * - 使用 \r 而不是 \n 发送命令
14
+ */
15
+
16
+ import { Client } from 'ssh2';
17
+ import { JUMP_HOST, K8S_SERVER, DEFAULTS } from './config.js';
18
+
19
+ /**
20
+ * 执行日志查询
21
+ * @param {Object} service - 服务配置
22
+ * @param {string} command - 日志查询命令(如 tail -100 *.log)
23
+ * @param {Object} options - 选项
24
+ * @returns {Promise<string>} 日志内容
25
+ */
26
+ export async function queryLog(service, command, options = {}) {
27
+ const timeout = options.timeout || DEFAULTS.timeout;
28
+
29
+ return new Promise((resolve, reject) => {
30
+ const conn = new Client();
31
+ let buffer = ''; // 累积所有输出
32
+ let timeoutId;
33
+ let stage = 'init'; // init -> opt -> host -> server -> kubectl -> done
34
+ let kubectlOutput = '';
35
+ let collectingOutput = false;
36
+
37
+ // 设置超时
38
+ timeoutId = setTimeout(() => {
39
+ conn.end();
40
+ reject(new Error(`命令执行超时 (${timeout}ms)`));
41
+ }, timeout);
42
+
43
+ conn.on('ready', () => {
44
+ console.error('[SSH] 已连接到堡垒机');
45
+
46
+ conn.shell({ term: 'xterm', rows: 24, cols: 500 }, (err, stream) => {
47
+ if (err) {
48
+ clearTimeout(timeoutId);
49
+ reject(err);
50
+ return;
51
+ }
52
+
53
+ stream.on('close', () => {
54
+ clearTimeout(timeoutId);
55
+ conn.end();
56
+
57
+ // 清理输出
58
+ const cleanOutput = cleanTerminalOutput(kubectlOutput || buffer);
59
+ resolve(cleanOutput);
60
+ });
61
+
62
+ stream.on('data', (data) => {
63
+ const text = data.toString();
64
+ buffer += text;
65
+
66
+ if (collectingOutput) {
67
+ kubectlOutput += text;
68
+ }
69
+
70
+ // JumpServer 状态机 - 检查累积的 buffer
71
+
72
+ // 阶段1: 等待 Opt> 提示符
73
+ if (stage === 'init' && buffer.includes('Opt>')) {
74
+ stage = 'opt';
75
+ console.error('[SSH] 输入目标服务器 IP');
76
+ stream.write(K8S_SERVER.host + '\r');
77
+ }
78
+ // 阶段2: 等待 [Host]> 提示符(搜索结果列表后)
79
+ else if (stage === 'opt' && buffer.includes('[Host]>')) {
80
+ stage = 'host';
81
+ console.error('[SSH] 选择服务器 ID: ' + K8S_SERVER.selectOption);
82
+ stream.write(K8S_SERVER.selectOption + '\r');
83
+ }
84
+ // 阶段3: 等待进入服务器 shell(检测 ~]$ 或 ~]# 提示符)
85
+ else if (stage === 'host' && (buffer.includes('~]$') || buffer.includes('~]#'))) {
86
+ stage = 'server';
87
+ console.error('[SSH] 已进入服务器,执行 kubectl 命令');
88
+
89
+ // 构建并执行 kubectl 命令
90
+ const kubectlCmd = buildKubectlCommand(service, command);
91
+ console.error(`[SSH] 命令: ${kubectlCmd.substring(0, 80)}...`);
92
+
93
+ // 重置 buffer,开始收集 kubectl 输出
94
+ kubectlOutput = '';
95
+ collectingOutput = true;
96
+
97
+ stream.write(kubectlCmd + '\r');
98
+ stage = 'kubectl';
99
+ }
100
+ // 阶段4: kubectl 执行完成
101
+ else if (stage === 'kubectl' && collectingOutput && kubectlOutput.length > 50) {
102
+ // 检测命令执行完成(返回到 shell 提示符)
103
+ if (kubectlOutput.includes('~]$') || kubectlOutput.includes('~]#')) {
104
+ stage = 'done';
105
+ collectingOutput = false;
106
+ console.error('[SSH] 命令执行完成,退出');
107
+
108
+ // 立即退出
109
+ stream.write('exit\r');
110
+ stream.write('exit\r');
111
+ setTimeout(() => stream.end(), 500);
112
+ }
113
+ }
114
+ });
115
+
116
+ stream.stderr.on('data', (data) => {
117
+ console.error('[SSH stderr]', data.toString());
118
+ });
119
+ });
120
+ });
121
+
122
+ conn.on('error', (err) => {
123
+ clearTimeout(timeoutId);
124
+ reject(new Error(`SSH 连接错误: ${err.message}`));
125
+ });
126
+
127
+ // 连接堡垒机
128
+ conn.connect({
129
+ host: JUMP_HOST.host,
130
+ port: JUMP_HOST.port,
131
+ username: JUMP_HOST.username,
132
+ password: JUMP_HOST.password,
133
+ readyTimeout: DEFAULTS.connectTimeout
134
+ });
135
+ });
136
+ }
137
+
138
+ /**
139
+ * 构建 kubectl exec 命令
140
+ * @param {Object} service - 服务配置
141
+ * @param {string} logCommand - 日志命令,如 "tail -100" 或 "grep error"
142
+ */
143
+ function buildKubectlCommand(service, logCommand) {
144
+ const { namespace, podPattern, logPath, logFile } = service;
145
+ const file = logFile || 'normal.log';
146
+ const fullPath = `${logPath}/${file}`;
147
+
148
+ // 构建完整命令
149
+ // 例如: kubectl exec pod/xxx -n namespace -- tail -100 /path/to/normal.log
150
+ return `kubectl exec $(kubectl get pod -n ${namespace} -o name | grep ${podPattern} | head -1) -n ${namespace} -- ${logCommand} ${fullPath}`;
151
+ }
152
+
153
+ /**
154
+ * 清理终端输出中的控制字符
155
+ */
156
+ function cleanTerminalOutput(output) {
157
+ return output
158
+ // 移除 ANSI 转义序列
159
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
160
+ // 移除其他控制字符
161
+ .replace(/\x1B\][^\x07]*\x07/g, '')
162
+ // 移除回车符
163
+ .replace(/\r/g, '')
164
+ // 移除堡垒机提示信息(根据实际情况调整)
165
+ .split('\n')
166
+ .filter(line => {
167
+ // 过滤掉提示行和空行
168
+ const trimmed = line.trim();
169
+ if (!trimmed) return false;
170
+ if (trimmed.startsWith('Last login:')) return false;
171
+ if (trimmed.includes('Welcome')) return false;
172
+ if (trimmed.match(/^\[.*@.*\][$#]/)) return false;
173
+ return true;
174
+ })
175
+ .join('\n')
176
+ .trim();
177
+ }
178
+
179
+ /**
180
+ * 测试 SSH 连接
181
+ */
182
+ export async function testConnection() {
183
+ return new Promise((resolve, reject) => {
184
+ const conn = new Client();
185
+
186
+ const timeoutId = setTimeout(() => {
187
+ conn.end();
188
+ reject(new Error('连接超时'));
189
+ }, DEFAULTS.connectTimeout);
190
+
191
+ conn.on('ready', () => {
192
+ clearTimeout(timeoutId);
193
+ conn.end();
194
+ resolve({ success: true, message: '堡垒机连接成功' });
195
+ });
196
+
197
+ conn.on('error', (err) => {
198
+ clearTimeout(timeoutId);
199
+ reject(new Error(`连接失败: ${err.message}`));
200
+ });
201
+
202
+ conn.connect({
203
+ host: JUMP_HOST.host,
204
+ port: JUMP_HOST.port,
205
+ username: JUMP_HOST.username,
206
+ password: JUMP_HOST.password,
207
+ readyTimeout: DEFAULTS.connectTimeout
208
+ });
209
+ });
210
+ }
211
+
212
+
213
+ /**
214
+ * 执行通用 kubectl 命令
215
+ * @param {string} kubectlCommand - 完整的 kubectl 命令
216
+ * @param {Object} options - 选项
217
+ * @returns {Promise<string>} 命令输出
218
+ */
219
+ export async function executeKubectl(kubectlCommand, options = {}) {
220
+ const timeout = options.timeout || DEFAULTS.timeout;
221
+
222
+ return new Promise((resolve, reject) => {
223
+ const conn = new Client();
224
+ let buffer = '';
225
+ let timeoutId;
226
+ let stage = 'init';
227
+ let kubectlOutput = '';
228
+ let collectingOutput = false;
229
+
230
+ // 设置超时
231
+ timeoutId = setTimeout(() => {
232
+ conn.end();
233
+ reject(new Error(`命令执行超时 (${timeout}ms)`));
234
+ }, timeout);
235
+
236
+ conn.on('ready', () => {
237
+ console.error('[SSH] 已连接到堡垒机');
238
+
239
+ conn.shell({ term: 'xterm', rows: 24, cols: 500 }, (err, stream) => {
240
+ if (err) {
241
+ clearTimeout(timeoutId);
242
+ reject(err);
243
+ return;
244
+ }
245
+
246
+ stream.on('close', () => {
247
+ clearTimeout(timeoutId);
248
+ conn.end();
249
+
250
+ // 清理输出
251
+ const cleanOutput = cleanTerminalOutput(kubectlOutput || buffer);
252
+ resolve(cleanOutput);
253
+ });
254
+
255
+ stream.on('data', (data) => {
256
+ const text = data.toString();
257
+ buffer += text;
258
+
259
+ if (collectingOutput) {
260
+ kubectlOutput += text;
261
+ }
262
+
263
+ // JumpServer 状态机
264
+
265
+ // 阶段1: 等待 Opt> 提示符
266
+ if (stage === 'init' && buffer.includes('Opt>')) {
267
+ stage = 'opt';
268
+ console.error('[SSH] 输入目标服务器 IP');
269
+ stream.write(K8S_SERVER.host + '\r');
270
+ }
271
+ // 阶段2: 等待 [Host]> 提示符
272
+ else if (stage === 'opt' && buffer.includes('[Host]>')) {
273
+ stage = 'host';
274
+ console.error('[SSH] 选择服务器 ID: ' + K8S_SERVER.selectOption);
275
+ stream.write(K8S_SERVER.selectOption + '\r');
276
+ }
277
+ // 阶段3: 等待进入服务器 shell
278
+ else if (stage === 'host' && (buffer.includes('~]$') || buffer.includes('~]#'))) {
279
+ stage = 'server';
280
+ console.error('[SSH] 已进入服务器,执行 kubectl 命令');
281
+ console.error(`[SSH] 命令: ${kubectlCommand.substring(0, 100)}...`);
282
+
283
+ // 重置 buffer,开始收集 kubectl 输出
284
+ kubectlOutput = '';
285
+ collectingOutput = true;
286
+
287
+ stream.write(kubectlCommand + '\r');
288
+ stage = 'kubectl';
289
+ }
290
+ // 阶段4: kubectl 执行完成
291
+ else if (stage === 'kubectl' && collectingOutput && kubectlOutput.length > 50) {
292
+ // 检测命令执行完成(返回到 shell 提示符)
293
+ if (kubectlOutput.includes('~]$') || kubectlOutput.includes('~]#')) {
294
+ stage = 'done';
295
+ collectingOutput = false;
296
+ console.error('[SSH] 命令执行完成,退出');
297
+
298
+ // 立即退出
299
+ stream.write('exit\r');
300
+ stream.write('exit\r');
301
+ setTimeout(() => stream.end(), 500);
302
+ }
303
+ }
304
+ });
305
+
306
+ stream.stderr.on('data', (data) => {
307
+ console.error('[SSH stderr]', data.toString());
308
+ });
309
+ });
310
+ });
311
+
312
+ conn.on('error', (err) => {
313
+ clearTimeout(timeoutId);
314
+ reject(new Error(`SSH 连接错误: ${err.message}`));
315
+ });
316
+
317
+ // 连接堡垒机
318
+ conn.connect({
319
+ host: JUMP_HOST.host,
320
+ port: JUMP_HOST.port,
321
+ username: JUMP_HOST.username,
322
+ password: JUMP_HOST.password,
323
+ readyTimeout: DEFAULTS.connectTimeout
324
+ });
325
+ });
326
+ }