mcp-log-query-server 1.0.0 → 2.1.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.
Files changed (4) hide show
  1. package/config.js +162 -1
  2. package/index.js +252 -42
  3. package/loki-client.js +464 -0
  4. package/package.json +3 -3
package/config.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Log Query MCP Server - 配置文件
3
- *
3
+ *
4
4
  * 包含:
5
5
  * - 堡垒机连接信息
6
6
  * - K8s 服务器信息
7
7
  * - 服务/容器映射
8
+ * - Grafana Loki 生产环境配置
8
9
  */
9
10
 
10
11
  // 堡垒机配置 - 从环境变量读取
@@ -471,4 +472,164 @@ export function detectContextFromPath(workspacePath) {
471
472
  function findServiceByName(serviceName) {
472
473
  const lower = serviceName.toLowerCase();
473
474
  return SERVICES[lower] || null;
475
+ }
476
+
477
+
478
+ // ============================================================
479
+ // Grafana Loki 生产环境配置
480
+ // ============================================================
481
+
482
+ /**
483
+ * Loki 默认参数
484
+ */
485
+ export const LOKI_DEFAULTS = {
486
+ // 默认查询时间范围:1 小时(毫秒)
487
+ defaultTimeRange: 60 * 60 * 1000,
488
+ // 默认最大返回行数
489
+ maxLines: 100,
490
+ // 默认项目名
491
+ defaultProject: 'senior'
492
+ };
493
+
494
+ /**
495
+ * Loki 环境配置
496
+ * 支持多个 Grafana/Loki 实例(CMS 生产、私有化部署等)
497
+ *
498
+ * 每个环境包含:
499
+ * - grafanaUrl: Grafana 地址
500
+ * - datasourceUid: Loki 数据源 UID
501
+ * - datasourceId: Loki 数据源 ID
502
+ * - orgId: Grafana 组织 ID
503
+ * - username/password: 认证信息(可选)
504
+ * - defaultProject: 默认项目名
505
+ * - description: 环境描述
506
+ */
507
+ export const LOKI_ENVIRONMENTS = {
508
+ // CMS 生产环境(华为云)— 有 project 标签
509
+ 'cms': {
510
+ description: 'CMS 生产环境(华为云)',
511
+ grafanaUrl: process.env.MCP_GRAFANA_URL || 'http://10.6.14.2:3000',
512
+ datasourceUid: process.env.MCP_GRAFANA_LOKI_UID || 'af2718a2-9c32-4364-a495-3bb29035199c',
513
+ datasourceId: parseInt(process.env.MCP_GRAFANA_DATASOURCE_ID || '35'),
514
+ orgId: parseInt(process.env.MCP_GRAFANA_ORG_ID || '1'),
515
+ username: process.env.MCP_GRAFANA_USER || 'loki',
516
+ password: process.env.MCP_GRAFANA_PASSWORD || 'nihao123!!',
517
+ defaultProject: 'senior',
518
+ hasProjectLabel: true // CMS 有 project 标签,可用 {project="senior"} 查询
519
+ },
520
+
521
+ // ---- 私有化环境(均无 project 标签,通过 filename 正则匹配) ----
522
+
523
+ // 城阳私有化
524
+ 'chengyang': {
525
+ description: '城阳私有化环境',
526
+ grafanaUrl: 'https://cyjy-iot.chengyang.gov.cn/journals-loki',
527
+ datasourceUid: 'f17c8456-9ef4-4f44-9292-366681ac4f0c',
528
+ datasourceId: 1,
529
+ orgId: 1,
530
+ username: 'loki',
531
+ password: 'nihao123!!',
532
+ defaultProject: 'senior',
533
+ hasProjectLabel: false // 无 project 标签,用 filename 正则匹配
534
+ },
535
+
536
+ // 临颖私有化
537
+ 'linying': {
538
+ description: '临颖私有化环境',
539
+ grafanaUrl: 'https://zhyl-linying.cn/journals-loki',
540
+ datasourceUid: 'ae7df92c-e2d8-4fca-ac47-80d9c97aec95',
541
+ datasourceId: 1,
542
+ orgId: 1,
543
+ username: 'loki',
544
+ password: 'nihao123!!',
545
+ defaultProject: 'senior',
546
+ hasProjectLabel: false
547
+ },
548
+
549
+ // 漯河私有化
550
+ 'luohe': {
551
+ description: '漯河私有化环境',
552
+ grafanaUrl: 'https://zhyl.mzj.luohe.gov.cn/journals-loki',
553
+ datasourceUid: 'e527a5a6-0cf7-43de-8476-d9a00e0aa075',
554
+ datasourceId: 1,
555
+ orgId: 1,
556
+ username: 'loki',
557
+ password: 'nihao123!!',
558
+ defaultProject: 'senior',
559
+ hasProjectLabel: false
560
+ },
561
+
562
+ // 德阳私有化
563
+ 'deyang': {
564
+ description: '德阳私有化环境',
565
+ grafanaUrl: 'https://www.deyangyinfa.com/journals-loki',
566
+ datasourceUid: 'c628c402-062f-4ed0-ad32-b3c8aa7cec90',
567
+ datasourceId: 2,
568
+ orgId: 1,
569
+ username: 'loki',
570
+ password: 'nihao123!!',
571
+ defaultProject: 'senior',
572
+ hasProjectLabel: false
573
+ }
574
+ };
575
+
576
+ /**
577
+ * Loki 环境别名映射
578
+ * 支持用户使用简称来指定环境
579
+ */
580
+ export const LOKI_ENV_ALIASES = {
581
+ // CMS
582
+ 'prod': 'cms',
583
+ 'production': 'cms',
584
+ '生产': 'cms',
585
+ '生产环境': 'cms',
586
+ 'cms': 'cms',
587
+ // 城阳
588
+ '城阳': 'chengyang',
589
+ 'chengyang': 'chengyang',
590
+ 'cy': 'chengyang',
591
+ // 临颖
592
+ '临颖': 'linying',
593
+ 'linying': 'linying',
594
+ 'ly': 'linying',
595
+ // 漯河
596
+ '漯河': 'luohe',
597
+ 'luohe': 'luohe',
598
+ 'lh': 'luohe',
599
+ // 德阳
600
+ '德阳': 'deyang',
601
+ 'deyang': 'deyang',
602
+ 'dy': 'deyang'
603
+ };
604
+
605
+ /**
606
+ * 根据名称或别名获取 Loki 环境配置
607
+ * @param {string} envName - 环境名称或别名
608
+ * @returns {Object|null} 环境配置
609
+ */
610
+ export function getLokiEnvironment(envName) {
611
+ if (!envName) return null;
612
+ const normalizedName = LOKI_ENV_ALIASES[envName.toLowerCase()] || envName.toLowerCase();
613
+ return LOKI_ENVIRONMENTS[normalizedName] || null;
614
+ }
615
+
616
+ /**
617
+ * 判断是否为 Loki 环境请求
618
+ * @param {string} env - 环境参数
619
+ * @returns {boolean}
620
+ */
621
+ export function isLokiEnv(env) {
622
+ if (!env) return false;
623
+ const normalizedName = LOKI_ENV_ALIASES[env.toLowerCase()] || env.toLowerCase();
624
+ return !!LOKI_ENVIRONMENTS[normalizedName];
625
+ }
626
+
627
+ /**
628
+ * 解析环境名称为标准 Loki 环境 key
629
+ * @param {string} env - 环境参数
630
+ * @returns {string|null} 标准环境 key,如 'cms'
631
+ */
632
+ export function resolveLokiEnvName(env) {
633
+ if (!env) return null;
634
+ return LOKI_ENV_ALIASES[env.toLowerCase()] || env.toLowerCase();
474
635
  }
package/index.js CHANGED
@@ -4,16 +4,18 @@
4
4
  * Log Query MCP Server
5
5
  *
6
6
  * 提供以下工具:
7
- * - query_log: 查询服务日志
8
- * - search_log: 搜索日志关键词
7
+ * - query_log: 查询服务日志(支持测试环境 SSH + 生产环境 Loki)
8
+ * - search_log: 搜索日志关键词(生产环境自动提取 traceId)
9
9
  * - list_services: 列出可用服务
10
10
  * - test_connection: 测试 SSH 连接
11
11
  * - list_pods: 列出 pods 及状态
12
12
  * - describe_pod: 获取 pod 详情
13
13
  * - get_pod_logs: 获取 pod 日志
14
14
  * - get_events: 获取 namespace 事件
15
- * - trace_log: 根据 traceId 跨服务查询日志
15
+ * - trace_log: 根据 traceId 跨服务查询日志(生产环境一次查询所有服务)
16
16
  * - detect_context: 根据工作目录自动检测 namespace 和服务
17
+ * - list_loki_environments: 列出可用的 Loki 生产环境
18
+ * - list_loki_services: 列出 Loki 环境下的服务
17
19
  */
18
20
 
19
21
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -24,13 +26,19 @@ import {
24
26
  } from '@modelcontextprotocol/sdk/types.js';
25
27
 
26
28
  import { queryLog, testConnection, executeKubectl } from './ssh-client.js';
27
- import { findService, getAllServices, DEFAULTS, DEFAULT_NAMESPACE, SERVICES, NAMESPACES, detectContextFromPath } from './config.js';
29
+ import { findService, getAllServices, DEFAULTS, DEFAULT_NAMESPACE, SERVICES, NAMESPACES, detectContextFromPath, isLokiEnv, resolveLokiEnvName, LOKI_ENVIRONMENTS } from './config.js';
30
+ import {
31
+ queryLoki, queryLokiAutoRange, parseTimeStr,
32
+ extractTraceIds, parseServiceFromFilename, groupLogsByService,
33
+ buildServiceLogQL, buildProjectLogQL, getLokiServiceDirName,
34
+ listLokiEnvironments as getLokiEnvList, listLokiServices as getLokiSvcList
35
+ } from './loki-client.js';
28
36
 
29
37
  // 创建 MCP Server
30
38
  const server = new Server(
31
39
  {
32
40
  name: 'mcp-log-query',
33
- version: '2.0.0',
41
+ version: '3.0.0',
34
42
  },
35
43
  {
36
44
  capabilities: {
@@ -45,7 +53,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
45
53
  tools: [
46
54
  {
47
55
  name: 'query_log',
48
- description: '查询服务容器的日志文件。返回最近的日志内容。',
56
+ description: '查询服务容器的日志文件。返回最近的日志内容。支持通过 env 参数查询生产环境日志(Loki)。',
49
57
  inputSchema: {
50
58
  type: 'object',
51
59
  properties: {
@@ -61,6 +69,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
61
69
  type: 'number',
62
70
  description: '返回的日志行数,默认 100',
63
71
  default: 100
72
+ },
73
+ env: {
74
+ type: 'string',
75
+ description: '环境标识,不指定则查询测试环境(走 SSH)。可选值:cms/prod/生产(CMS生产环境)、城阳/cy/chengyang、临颖/ly/linying、漯河/lh/luohe、德阳/dy/deyang(私有化环境)'
76
+ },
77
+ from: {
78
+ type: 'string',
79
+ description: '(Loki) 查询起始时间,如 "2026-02-05 10:00:00"。指定后禁用自动递进'
80
+ },
81
+ to: {
82
+ type: 'string',
83
+ description: '(Loki) 查询结束时间,如 "2026-02-06 12:00:00"。不指定则为当前时间'
64
84
  }
65
85
  },
66
86
  required: ['service']
@@ -68,7 +88,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
68
88
  },
69
89
  {
70
90
  name: 'search_log',
71
- description: '在服务日志中搜索关键词。支持正则表达式。',
91
+ description: '在服务日志中搜索关键词。支持正则表达式。生产环境会自动提取 traceId 列表。',
72
92
  inputSchema: {
73
93
  type: 'object',
74
94
  properties: {
@@ -93,6 +113,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
93
113
  type: 'boolean',
94
114
  description: '是否区分大小写,默认 false',
95
115
  default: false
116
+ },
117
+ env: {
118
+ type: 'string',
119
+ description: '环境标识,不指定则查询测试环境(走 SSH)。可选值:cms/prod/生产(CMS生产环境)、城阳/cy/chengyang、临颖/ly/linying、漯河/lh/luohe、德阳/dy/deyang(私有化环境)'
120
+ },
121
+ from: {
122
+ type: 'string',
123
+ description: '(Loki) 查询起始时间,如 "2026-02-05 10:00:00"。指定后禁用自动递进'
124
+ },
125
+ to: {
126
+ type: 'string',
127
+ description: '(Loki) 查询结束时间,如 "2026-02-06 12:00:00"。不指定则为当前时间'
96
128
  }
97
129
  },
98
130
  required: ['service', 'keyword']
@@ -201,7 +233,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
201
233
  },
202
234
  {
203
235
  name: 'trace_log',
204
- description: '根据 traceId 跨服务查询日志,用于追踪完整调用链',
236
+ description: '根据 traceId 跨服务查询日志,用于追踪完整调用链。生产环境使用 Loki API 一次查询所有服务。',
205
237
  inputSchema: {
206
238
  type: 'object',
207
239
  properties: {
@@ -222,6 +254,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
222
254
  type: 'number',
223
255
  description: '显示匹配行前后的上下文行数,默认 3',
224
256
  default: 3
257
+ },
258
+ env: {
259
+ type: 'string',
260
+ description: '环境标识,不指定则查询测试环境(走 SSH)。可选值:cms/prod/生产(CMS生产环境)、城阳/cy/chengyang、临颖/ly/linying、漯河/lh/luohe、德阳/dy/deyang(私有化环境)'
261
+ },
262
+ from: {
263
+ type: 'string',
264
+ description: '(Loki) 查询起始时间,如 "2026-02-05 10:00:00"。指定后禁用自动递进'
265
+ },
266
+ to: {
267
+ type: 'string',
268
+ description: '(Loki) 查询结束时间,如 "2026-02-06 12:00:00"。不指定则为当前时间'
225
269
  }
226
270
  },
227
271
  required: ['traceId']
@@ -241,6 +285,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
241
285
  },
242
286
  required: ['workspace_path']
243
287
  }
288
+ },
289
+ // ========== Loki 生产环境工具 ==========
290
+ {
291
+ name: 'list_loki_environments',
292
+ description: '列出所有可用的 Loki 生产环境',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {}
296
+ }
297
+ },
298
+ {
299
+ name: 'list_loki_services',
300
+ description: '列出指定 Loki 环境下的所有可用服务',
301
+ inputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ env: {
305
+ type: 'string',
306
+ description: '环境标识。可选值:cms/prod/生产(CMS生产环境)、城阳/cy/chengyang、临颖/ly/linying、漯河/lh/luohe、德阳/dy/deyang(私有化环境)',
307
+ default: 'cms'
308
+ },
309
+ project: {
310
+ type: 'string',
311
+ description: '项目名,默认 senior',
312
+ default: 'senior'
313
+ }
314
+ }
315
+ }
244
316
  }
245
317
  ]
246
318
  };
@@ -252,15 +324,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
324
  try {
253
325
  switch (name) {
254
326
  case 'query_log': {
255
- // 支持传入 namespace 参数覆盖默认值
327
+ // 判断是否走 Loki(生产环境)
328
+ if (isLokiEnv(args.env)) {
329
+ const envKey = resolveLokiEnvName(args.env);
330
+ const envConfig = LOKI_ENVIRONMENTS[envKey];
331
+ const project = envConfig.defaultProject || 'senior';
332
+ const serviceDirName = getLokiServiceDirName(args.service);
333
+ const maxLines = args.lines || DEFAULTS.lines;
334
+
335
+ const expr = buildServiceLogQL(project, serviceDirName, '', envKey);
336
+ console.error(`[MCP] Loki 查询日志: env=${envKey}, service=${args.service}, expr=${expr}`);
337
+
338
+ // 构建时间范围选项
339
+ const timeOpts = { maxLines };
340
+ if (args.from) timeOpts.from = parseTimeStr(args.from);
341
+ if (args.to) timeOpts.to = parseTimeStr(args.to);
342
+
343
+ const lokiResult = await queryLokiAutoRange(envKey, expr, timeOpts);
344
+
345
+ if (lokiResult.logs.length === 0) {
346
+ const errorHint = lokiResult.error ? `\n\n⚠️ **${lokiResult.error}**` : '';
347
+ return { content: [{ type: 'text', text: `## ${args.service} 日志 (${envKey} 生产环境)\n\n⚠️ 已自动搜索 5分钟 → 30分钟 → 1小时 → 3小时 → 24小时 范围,均未找到日志。${errorHint}\n\n请确认:\n1. 服务名是否正确\n2. 如需查询更早的日志,请使用 \`from\`/\`to\` 参数指定具体时间范围` }] };
348
+ }
349
+
350
+ let text = `## ${args.service} 日志 (${envKey} 生产环境, ${lokiResult.timeRange.label}内, ${lokiResult.logs.length} 行)\n\n`;
351
+ text += `\`\`\`\n${lokiResult.logs.join('\n')}\n\`\`\``;
352
+ if (lokiResult.traceIds.length > 0) {
353
+ text += `\n\n🔑 **提取到的 traceId** (${lokiResult.traceIds.length} 个):\n`;
354
+ lokiResult.traceIds.slice(0, 20).forEach((id, i) => { text += ` ${i + 1}. \`${id}\`\n`; });
355
+ if (lokiResult.traceIds.length > 20) text += ` ... 还有 ${lokiResult.traceIds.length - 20} 个\n`;
356
+ }
357
+ return { content: [{ type: 'text', text }] };
358
+ }
359
+
360
+ // 测试环境:走 SSH
256
361
  const service = findService(args.service, args.namespace);
257
362
  if (!service) {
258
- return {
259
- content: [{
260
- type: 'text',
261
- text: `错误: 未找到服务 "${args.service}"。使用 list_services 查看可用服务。`
262
- }]
263
- };
363
+ return { content: [{ type: 'text', text: `错误: 未找到服务 "${args.service}"。使用 list_services 查看可用服务。` }] };
264
364
  }
265
365
 
266
366
  const lines = args.lines || DEFAULTS.lines;
@@ -278,15 +378,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
278
378
  }
279
379
 
280
380
  case 'search_log': {
281
- // 支持传入 namespace 参数覆盖默认值
381
+ // 判断是否走 Loki(生产环境)
382
+ if (isLokiEnv(args.env)) {
383
+ const envKey = resolveLokiEnvName(args.env);
384
+ const envConfig = LOKI_ENVIRONMENTS[envKey];
385
+ const project = envConfig.defaultProject || 'senior';
386
+ const serviceDirName = getLokiServiceDirName(args.service);
387
+ const keyword = args.keyword;
388
+
389
+ const expr = buildServiceLogQL(project, serviceDirName, keyword, envKey);
390
+ console.error(`[MCP] Loki 搜索日志: env=${envKey}, service=${args.service}, keyword=${keyword}`);
391
+
392
+ // 构建时间范围选项
393
+ const timeOpts = { maxLines: 200 };
394
+ if (args.from) timeOpts.from = parseTimeStr(args.from);
395
+ if (args.to) timeOpts.to = parseTimeStr(args.to);
396
+
397
+ const lokiResult = await queryLokiAutoRange(envKey, expr, timeOpts);
398
+
399
+ if (lokiResult.logs.length === 0) {
400
+ const errorHint = lokiResult.error ? `\n\n⚠️ **${lokiResult.error}**` : '';
401
+ return { content: [{ type: 'text', text: `## ${args.service} 日志搜索结果 (${envKey} 生产环境)\n\n**关键词**: ${keyword}\n\n⚠️ 已自动搜索 5分钟 → 30分钟 → 1小时 → 3小时 → 24小时 范围,均未找到匹配内容。${errorHint}\n\n请确认:\n1. 关键词是否正确\n2. 服务名是否正确\n3. 如需查询更早的日志,请使用 \`from\`/\`to\` 参数指定具体时间范围` }] };
402
+ }
403
+
404
+ let text = `## ${args.service} 日志搜索结果 (${envKey} 生产环境, ${lokiResult.timeRange.label}内)\n\n`;
405
+ text += `**关键词**: ${keyword}\n**匹配行数**: ${lokiResult.logs.length}\n**时间范围**: ${lokiResult.timeRange.label}\n\n`;
406
+ text += `\`\`\`\n${lokiResult.logs.join('\n')}\n\`\`\``;
407
+
408
+ // 自动提取 traceId(核心功能:帮助用户获取 traceId 进行链路追踪)
409
+ if (lokiResult.traceIds.length > 0) {
410
+ text += `\n\n🔑 **提取到的 traceId** (${lokiResult.traceIds.length} 个):\n`;
411
+ lokiResult.traceIds.slice(0, 20).forEach((id, i) => { text += ` ${i + 1}. \`${id}\`\n`; });
412
+ if (lokiResult.traceIds.length > 20) text += ` ... 还有 ${lokiResult.traceIds.length - 20} 个\n`;
413
+ text += `\n💡 **提示**: 可以使用 \`trace_log(traceId: "xxx", env: "${args.env}")\` 查看完整调用链`;
414
+ }
415
+ return { content: [{ type: 'text', text }] };
416
+ }
417
+
418
+ // 测试环境:走 SSH
282
419
  const service = findService(args.service, args.namespace);
283
420
  if (!service) {
284
- return {
285
- content: [{
286
- type: 'text',
287
- text: `错误: 未找到服务 "${args.service}"。使用 list_services 查看可用服务。`
288
- }]
289
- };
421
+ return { content: [{ type: 'text', text: `错误: 未找到服务 "${args.service}"。使用 list_services 查看可用服务。` }] };
290
422
  }
291
423
 
292
424
  const keyword = args.keyword;
@@ -416,14 +548,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
416
548
  case 'trace_log': {
417
549
  const traceId = args.traceId;
418
550
  const contextLines = args.context_lines || 3;
419
- const targetNamespace = args.namespace || null; // 支持指定 namespace
551
+
552
+ // 判断是否走 Loki(生产环境)- 一次 API 调用搜索所有服务
553
+ if (isLokiEnv(args.env)) {
554
+ const envKey = resolveLokiEnvName(args.env);
555
+ const envConfig = LOKI_ENVIRONMENTS[envKey];
556
+ const project = envConfig.defaultProject || 'senior';
557
+
558
+ // 构建时间范围选项
559
+ const timeOpts = {};
560
+ if (args.from) timeOpts.from = parseTimeStr(args.from);
561
+ if (args.to) timeOpts.to = parseTimeStr(args.to);
562
+
563
+ // 如果指定了服务列表,按服务查询;否则按项目查询(一次搜索所有服务)
564
+ let lokiResult;
565
+ const targetServices = args.services || [];
566
+
567
+ if (targetServices.length > 0) {
568
+ // 指定服务:逐个查询
569
+ const allLogs = [];
570
+ const allLabels = [];
571
+ for (const svc of targetServices) {
572
+ const dirName = getLokiServiceDirName(svc);
573
+ const expr = buildServiceLogQL(project, dirName, traceId, envKey);
574
+ console.error(`[MCP] Loki trace: env=${envKey}, service=${svc}, traceId=${traceId}`);
575
+ const r = await queryLokiAutoRange(envKey, expr, { ...timeOpts, maxLines: 500 });
576
+ allLogs.push(...r.logs);
577
+ allLabels.push(...r.labels);
578
+ }
579
+ lokiResult = { logs: allLogs, labels: allLabels, traceIds: extractTraceIds(allLogs), timeRange: { label: '自动递进' } };
580
+ } else {
581
+ // 未指定服务:按项目一次查询所有服务(高效!)
582
+ const expr = buildProjectLogQL(project, traceId, envKey);
583
+ console.error(`[MCP] Loki trace (全项目): env=${envKey}, project=${project}, traceId=${traceId}`);
584
+ lokiResult = await queryLokiAutoRange(envKey, expr, { ...timeOpts, maxLines: 1000 });
585
+ }
586
+
587
+ if (lokiResult.logs.length === 0) {
588
+ const errorHint = lokiResult.error ? `\n\n⚠️ **${lokiResult.error}**` : '';
589
+ return { content: [{ type: 'text', text: `## TraceId 追踪结果 (${envKey} 生产环境)\n\n**traceId**: \`${traceId}\`\n\n❌ 已自动搜索 5分钟 → 30分钟 → 1小时 → 3小时 → 24小时 范围,均未找到匹配日志。${errorHint}\n\n请确认:\n1. traceId 是否正确\n2. 如需查询更早的日志,请使用 \`from\`/\`to\` 参数指定具体时间范围` }] };
590
+ }
591
+
592
+ // 按服务分组展示
593
+ const groups = groupLogsByService(lokiResult);
594
+ const serviceNames = Object.keys(groups).sort();
595
+
596
+ let text = `## TraceId 追踪结果 (${envKey} 生产环境, ${lokiResult.timeRange.label}内)\n\n`;
597
+ text += `**traceId**: \`${traceId}\`\n`;
598
+ text += `**匹配服务数**: ${serviceNames.length}\n`;
599
+ text += `**总日志行数**: ${lokiResult.logs.length}\n\n`;
600
+
601
+ for (const svcName of serviceNames) {
602
+ const group = groups[svcName];
603
+ text += `### ${svcName}\n`;
604
+ text += `\`\`\`\n${group.logs.join('\n')}\n\`\`\`\n\n`;
605
+ }
606
+
607
+ return { content: [{ type: 'text', text }] };
608
+ }
609
+
610
+ // 测试环境:走 SSH(逐个服务搜索)
611
+ const targetNamespace = args.namespace || null;
420
612
  let servicesToSearch = args.services || [];
421
613
 
422
- // 如果没有指定服务,搜索所有服务
423
614
  if (servicesToSearch.length === 0) {
424
615
  servicesToSearch = Object.keys(SERVICES);
425
616
  } else {
426
- // 解析服务别名
427
617
  servicesToSearch = servicesToSearch.map(s => {
428
618
  const service = findService(s, targetNamespace);
429
619
  return service ? service.name : s;
@@ -434,7 +624,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
434
624
 
435
625
  const results = [];
436
626
  for (const serviceName of servicesToSearch) {
437
- // 使用 findService 获取服务配置,支持 namespace 覆盖
438
627
  const service = findService(serviceName, targetNamespace);
439
628
  if (!service) continue;
440
629
 
@@ -443,30 +632,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
443
632
  const result = await queryLog(service, command);
444
633
 
445
634
  if (result && result.trim() && !result.includes('未找到')) {
446
- results.push({
447
- service: serviceName,
448
- namespace: service.namespace,
449
- logs: result
450
- });
635
+ results.push({ service: serviceName, namespace: service.namespace, logs: result });
451
636
  }
452
637
  } catch (err) {
453
- // 忽略单个服务的错误,继续搜索其他服务
454
638
  console.error(`[MCP] 搜索 ${serviceName} 失败: ${err.message}`);
455
639
  }
456
640
  }
457
641
 
458
642
  if (results.length === 0) {
459
- return {
460
- content: [{
461
- type: 'text',
462
- text: `## TraceId 追踪结果\n\n**traceId**: ${traceId}\n**namespace**: ${targetNamespace || '默认'}\n\n❌ 未在任何服务中找到匹配的日志`
463
- }]
464
- };
643
+ return { content: [{ type: 'text', text: `## TraceId 追踪结果\n\n**traceId**: ${traceId}\n**namespace**: ${targetNamespace || '默认'}\n\n❌ 未在任何服务中找到匹配的日志` }] };
465
644
  }
466
645
 
467
- const output = results.map(r =>
468
- `### ${r.service} (${r.namespace})\n\`\`\`\n${r.logs}\n\`\`\``
469
- ).join('\n\n');
646
+ const output = results.map(r => `### ${r.service} (${r.namespace})\n\`\`\`\n${r.logs}\n\`\`\``).join('\n\n');
470
647
 
471
648
  return {
472
649
  content: [{
@@ -522,6 +699,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
522
699
  };
523
700
  }
524
701
 
702
+ // ========== Loki 生产环境工具处理 ==========
703
+ case 'list_loki_environments': {
704
+ const envs = getLokiEnvList();
705
+ if (envs.length === 0) {
706
+ return { content: [{ type: 'text', text: '## Loki 环境列表\n\n⚠️ 未配置任何 Loki 环境' }] };
707
+ }
708
+
709
+ const list = envs.map(e =>
710
+ `- **${e.name}**: ${e.description}\n Grafana: ${e.grafanaUrl}\n 默认项目: ${e.project}`
711
+ ).join('\n');
712
+
713
+ return { content: [{ type: 'text', text: `## Loki 生产环境列表\n\n${list}` }] };
714
+ }
715
+
716
+ case 'list_loki_services': {
717
+ const envKey = resolveLokiEnvName(args.env || 'cms');
718
+ const project = args.project || 'senior';
719
+
720
+ if (!envKey || !LOKI_ENVIRONMENTS[envKey]) {
721
+ return { content: [{ type: 'text', text: `错误: 未知环境 "${args.env}"。使用 list_loki_environments 查看可用环境。` }] };
722
+ }
723
+
724
+ console.error(`[MCP] 列出 Loki 服务: env=${envKey}, project=${project}`);
725
+ const services = await getLokiSvcList(envKey, project);
726
+
727
+ if (services.length === 0) {
728
+ return { content: [{ type: 'text', text: `## Loki 服务列表 (${envKey})\n\n⚠️ 未找到任何服务` }] };
729
+ }
730
+
731
+ const list = services.map((s, i) => ` ${i + 1}. ${s}`).join('\n');
732
+ return { content: [{ type: 'text', text: `## Loki 服务列表 (${envKey}, project=${project})\n\n共 ${services.length} 个服务:\n${list}` }] };
733
+ }
734
+
525
735
  default:
526
736
  return {
527
737
  content: [{
@@ -546,7 +756,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
546
756
  async function main() {
547
757
  const transport = new StdioServerTransport();
548
758
  await server.connect(transport);
549
- console.error('[MCP] Log Query Server v2.0 已启动');
759
+ console.error('[MCP] Log Query Server v3.0 已启动 (支持 Loki 生产环境日志)');
550
760
  }
551
761
 
552
762
  main().catch((error) => {
package/loki-client.js ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Grafana Loki API 客户端
3
+ *
4
+ * 通过 Grafana 代理接口查询 Loki 日志,支持:
5
+ * - 日志查询(LogQL)
6
+ * - 标签/标签值获取
7
+ * - traceId 自动提取
8
+ * - 服务名自动识别(从 filename 标签解析)
9
+ * - 时间范围自动递进(1h → 24h → 72h → 7d)
10
+ */
11
+
12
+ import { LOKI_ENVIRONMENTS, LOKI_DEFAULTS } from './config.js';
13
+
14
+ // 时间范围自动递进策略(毫秒)
15
+ const AUTO_RANGE_STEPS = [
16
+ { range: 5 * 60 * 1000, label: '5 分钟' },
17
+ { range: 30 * 60 * 1000, label: '30 分钟' },
18
+ { range: 1 * 60 * 60 * 1000, label: '1 小时' },
19
+ { range: 3 * 60 * 60 * 1000, label: '3 小时' },
20
+ { range: 24 * 60 * 60 * 1000, label: '24 小时' },
21
+ ];
22
+
23
+ // ============================================================
24
+ // 核心查询
25
+ // ============================================================
26
+
27
+ /**
28
+ * 执行 Loki 日志查询
29
+ * @param {string} envName - 环境名称,如 'cms'
30
+ * @param {string} expr - LogQL 表达式
31
+ * @param {Object} options - 查询选项
32
+ * @param {number} options.from - 起始时间(毫秒时间戳),默认 1 小时前
33
+ * @param {number} options.to - 结束时间(毫秒时间戳),默认当前
34
+ * @param {number} options.maxLines - 最大返回行数,默认 100
35
+ * @param {string} options.direction - 排序方向 'backward'|'forward',默认 'backward'
36
+ * @returns {Object} { logs: string[], labels: Object[], traceIds: string[], stats: Object }
37
+ */
38
+ export async function queryLoki(envName, expr, options = {}) {
39
+ const env = getLokiEnv(envName);
40
+ const now = Date.now();
41
+ const from = options.from || (now - LOKI_DEFAULTS.defaultTimeRange);
42
+ const to = options.to || now;
43
+ const maxLines = options.maxLines || LOKI_DEFAULTS.maxLines;
44
+ const direction = options.direction || 'backward';
45
+
46
+ const url = `${env.grafanaUrl}/api/ds/query?ds_type=loki`;
47
+ const body = {
48
+ queries: [{
49
+ refId: 'A',
50
+ expr,
51
+ queryType: 'range',
52
+ datasource: { type: 'loki', uid: env.datasourceUid },
53
+ editorMode: 'builder',
54
+ direction,
55
+ maxLines,
56
+ datasourceId: env.datasourceId,
57
+ intervalMs: 1000,
58
+ maxDataPoints: 1000
59
+ }],
60
+ from: String(from),
61
+ to: String(to)
62
+ };
63
+
64
+ console.error(`[Loki] 查询: env=${envName}, expr=${expr}`);
65
+
66
+ const resp = await fetch(url, {
67
+ method: 'POST',
68
+ headers: buildHeaders(env),
69
+ body: JSON.stringify(body)
70
+ });
71
+
72
+ if (!resp.ok) {
73
+ const text = await resp.text();
74
+ throw new Error(`Loki 查询失败 (${resp.status}): ${text}`);
75
+ }
76
+
77
+ const data = await resp.json();
78
+ return parseLokiResponse(data);
79
+ }
80
+
81
+ /**
82
+ * 带时间范围自动递进的 Loki 查询
83
+ *
84
+ * 策略:1h → 24h → 72h → 7d,找到结果立即返回
85
+ * 如果用户指定了 from/to,则直接使用指定范围,不递进
86
+ *
87
+ * @param {string} envName - 环境名称
88
+ * @param {string} expr - LogQL 表达式
89
+ * @param {Object} options - 查询选项
90
+ * @param {number} options.from - 起始时间戳(毫秒),指定后不递进
91
+ * @param {number} options.to - 结束时间戳(毫秒),指定后不递进
92
+ * @param {number} options.maxLines - 最大返回行数
93
+ * @param {string} options.direction - 排序方向
94
+ * @returns {Object} { logs, labels, traceIds, stats, timeRange: { label, from, to } }
95
+ */
96
+ export async function queryLokiAutoRange(envName, expr, options = {}) {
97
+ // 如果用户明确指定了 from/to,直接查询不递进
98
+ if (options.from && options.to) {
99
+ console.error(`[Loki] 使用指定时间范围查询: ${new Date(options.from).toLocaleString()} ~ ${new Date(options.to).toLocaleString()}`);
100
+ try {
101
+ const result = await queryLoki(envName, expr, options);
102
+ result.timeRange = { label: '自定义', from: options.from, to: options.to };
103
+ return result;
104
+ } catch (e) {
105
+ const isTimeout = e.message.includes('timeout') || e.message.includes('504') || e.message.includes('Timeout');
106
+ console.error(`[Loki] ❌ 指定时间范围查询${isTimeout ? '超时' : '失败'}: ${e.message.substring(0, 200)}`);
107
+ return {
108
+ logs: [], labels: [], traceIds: [], stats: null,
109
+ timeRange: { label: '自定义', from: options.from, to: options.to },
110
+ notFound: true,
111
+ error: isTimeout
112
+ ? '查询超时(数据量过大),请缩小时间范围或指定具体服务'
113
+ : `查询失败: ${e.message.substring(0, 200)}`
114
+ };
115
+ }
116
+ }
117
+
118
+ // 自动递进:从小范围到大范围
119
+ const now = Date.now();
120
+ for (const step of AUTO_RANGE_STEPS) {
121
+ const from = now - step.range;
122
+ const to = now;
123
+
124
+ console.error(`[Loki] 自动递进: 尝试 ${step.label} 范围...`);
125
+
126
+ try {
127
+ const result = await queryLoki(envName, expr, { ...options, from, to });
128
+
129
+ if (result.logs.length > 0) {
130
+ console.error(`[Loki] ✅ 在 ${step.label} 范围内找到 ${result.logs.length} 行日志`);
131
+ result.timeRange = { label: step.label, from, to };
132
+ return result;
133
+ }
134
+
135
+ console.error(`[Loki] ⏭️ ${step.label} 范围内无结果,扩大范围...`);
136
+ } catch (e) {
137
+ // 查询超时或失败,停止递进,返回优雅降级结果
138
+ const isTimeout = e.message.includes('timeout') || e.message.includes('504') || e.message.includes('Timeout');
139
+ console.error(`[Loki] ⚠️ ${step.label} 范围查询${isTimeout ? '超时' : '失败'}: ${e.message.substring(0, 200)}`);
140
+ return {
141
+ logs: [], labels: [], traceIds: [], stats: null,
142
+ timeRange: { label: step.label, from, to },
143
+ notFound: true,
144
+ error: isTimeout
145
+ ? `查询在递进到 ${step.label} 范围时超时(数据量过大),请缩小时间范围或指定具体服务查询`
146
+ : `查询在递进到 ${step.label} 范围时失败: ${e.message.substring(0, 200)}`
147
+ };
148
+ }
149
+ }
150
+
151
+ // 所有范围都没找到
152
+ console.error(`[Loki] ❌ 所有时间范围均未找到结果`);
153
+ return {
154
+ logs: [],
155
+ labels: [],
156
+ traceIds: [],
157
+ stats: null,
158
+ timeRange: { label: '未找到', from: null, to: null },
159
+ notFound: true
160
+ };
161
+ }
162
+
163
+ /**
164
+ * 解析用户传入的时间字符串为毫秒时间戳
165
+ * 支持格式: "2026-02-06 12:00:00", "2026-02-06", ISO 8601 等
166
+ * @param {string} timeStr - 时间字符串
167
+ * @returns {number|null} 毫秒时间戳,解析失败返回 null
168
+ */
169
+ export function parseTimeStr(timeStr) {
170
+ if (!timeStr) return null;
171
+ // 如果是纯数字,当作时间戳
172
+ if (/^\d{10,13}$/.test(timeStr)) {
173
+ const ts = parseInt(timeStr);
174
+ return ts < 1e12 ? ts * 1000 : ts; // 秒 → 毫秒
175
+ }
176
+ const d = new Date(timeStr);
177
+ return isNaN(d.getTime()) ? null : d.getTime();
178
+ }
179
+
180
+ // ============================================================
181
+ // 标签查询
182
+ // ============================================================
183
+
184
+ /** 获取 Loki 标签列表 */
185
+ export async function getLokiLabels(envName) {
186
+ const env = getLokiEnv(envName);
187
+ const now = Date.now();
188
+ const start = (now - LOKI_DEFAULTS.defaultTimeRange) * 1_000_000;
189
+ const end = now * 1_000_000;
190
+ const url = `${env.grafanaUrl}/api/datasources/uid/${env.datasourceUid}/resources/labels?start=${start}&end=${end}`;
191
+ const resp = await fetch(url, { headers: buildHeaders(env) });
192
+ if (!resp.ok) throw new Error(`获取标签失败 (${resp.status})`);
193
+ const data = await resp.json();
194
+ return data.data || [];
195
+ }
196
+
197
+ /** 获取 Loki 标签值 */
198
+ export async function getLokiLabelValues(envName, label, query = '') {
199
+ const env = getLokiEnv(envName);
200
+ const now = Date.now();
201
+ const start = (now - LOKI_DEFAULTS.defaultTimeRange) * 1_000_000;
202
+ const end = now * 1_000_000;
203
+ let url = `${env.grafanaUrl}/api/datasources/uid/${env.datasourceUid}/resources/label/${label}/values?start=${start}&end=${end}`;
204
+ if (query) url += `&query=${encodeURIComponent(query)}`;
205
+ const resp = await fetch(url, { headers: buildHeaders(env) });
206
+ if (!resp.ok) throw new Error(`获取标签值失败 (${resp.status})`);
207
+ const data = await resp.json();
208
+ return data.data || [];
209
+ }
210
+
211
+ // ============================================================
212
+ // 响应解析
213
+ // ============================================================
214
+
215
+ /** 解析 Grafana Loki 查询响应 */
216
+ export function parseLokiResponse(data) {
217
+ const result = { logs: [], labels: [], traceIds: [], stats: null };
218
+ const frames = data?.results?.A?.frames;
219
+ if (!frames || frames.length === 0) return result;
220
+
221
+ for (const frame of frames) {
222
+ const values = frame?.data?.values;
223
+ if (!values || values.length < 3) continue;
224
+ // values[0]: 标签数组, values[1]: 时间戳数组, values[2]: 日志行数组
225
+ const labelsArr = values[0] || [];
226
+ const linesArr = values[2] || [];
227
+ for (let i = 0; i < linesArr.length; i++) {
228
+ result.logs.push(linesArr[i]);
229
+ result.labels.push(labelsArr[i] || {});
230
+ }
231
+ }
232
+
233
+ result.traceIds = extractTraceIds(result.logs);
234
+
235
+ const stats = frames[0]?.schema?.meta?.stats;
236
+ if (stats) {
237
+ result.stats = {};
238
+ for (const s of stats) { result.stats[s.displayName] = s.value; }
239
+ }
240
+ return result;
241
+ }
242
+
243
+ // ============================================================
244
+ // traceId 提取
245
+ // ============================================================
246
+
247
+ /**
248
+ * 从日志行中提取 traceId(32位十六进制,在方括号中)
249
+ * 日志格式: [clife-senior] 时间 级别 [服务] [pod] [线程] [OT-spanId] [traceId] 类名 - 内容
250
+ */
251
+ export function extractTraceIds(lines) {
252
+ const traceIdSet = new Set();
253
+
254
+ const regex = /\[([a-f0-9]{32})\]/gi;
255
+ for (const line of lines) {
256
+ let match;
257
+ while ((match = regex.exec(line)) !== null) {
258
+ traceIdSet.add(match[1].toLowerCase());
259
+ }
260
+ regex.lastIndex = 0;
261
+ }
262
+ return [...traceIdSet];
263
+ }
264
+
265
+ // ============================================================
266
+ // 服务名解析
267
+ // ============================================================
268
+
269
+ /**
270
+ * 从 Loki filename 标签中解析服务名
271
+ * filename 格式: /data/services/logs/senior/clife-senior-health-app/normal_logs/normal.log
272
+ * 解析结果: clife-senior-health
273
+ */
274
+ export function parseServiceFromFilename(filename) {
275
+ if (!filename) return null;
276
+ // 匹配 /{service-name}-app/ 或 /{service-name}-service/ 模式
277
+ const match = filename.match(/\/(clife-senior-[a-zA-Z0-9-]+?)(?:-app|-service)\//);
278
+ if (match) return match[1];
279
+ // 兜底:匹配非 clife-senior 前缀的服务(如 device-manage-service)
280
+ const match2 = filename.match(/\/([a-zA-Z0-9-]+?)(?:-app|-service)\//);
281
+ if (match2) return match2[1];
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * 将查询结果按服务分组
287
+ * @param {Object} lokiResult - parseLokiResponse 的返回值
288
+ * @returns {Object} { serviceName: { logs: string[], traceIds: string[] } }
289
+ */
290
+ export function groupLogsByService(lokiResult) {
291
+ const groups = {};
292
+
293
+ for (let i = 0; i < lokiResult.logs.length; i++) {
294
+ const label = lokiResult.labels[i] || {};
295
+ const serviceName = parseServiceFromFilename(label.filename) || 'unknown';
296
+ const logLine = lokiResult.logs[i];
297
+
298
+ if (!groups[serviceName]) {
299
+ groups[serviceName] = { logs: [], traceIds: new Set() };
300
+ }
301
+ groups[serviceName].logs.push(logLine);
302
+
303
+ // 从该行提取 traceId
304
+ const ids = extractTraceIds([logLine]);
305
+ ids.forEach(id => groups[serviceName].traceIds.add(id));
306
+ }
307
+
308
+ // Set → Array
309
+ for (const key of Object.keys(groups)) {
310
+ groups[key].traceIds = [...groups[key].traceIds];
311
+ }
312
+
313
+ return groups;
314
+ }
315
+
316
+ // ============================================================
317
+ // LogQL 构建辅助
318
+ // ============================================================
319
+
320
+ /**
321
+ * 构建按服务查询的 LogQL 表达式
322
+ * 根据环境是否有 project 标签,自动选择不同的 filename 路径格式:
323
+ * - 有 project 标签(CMS): /data/services/logs/senior/clife-senior-health-app/normal_logs/normal.log
324
+ * - 无 project 标签(私有化): /data/services/logs/clife-senior-health-app/normal_logs/normal.log
325
+ *
326
+ * @param {string} project - 项目名,如 'senior'
327
+ * @param {string} servicePodPattern - 服务目录名,如 'clife-senior-health-app'
328
+ * @param {string} keyword - 搜索关键词(可选)
329
+ * @param {string} envName - 环境名称,如 'cms'、'chengyang'
330
+ */
331
+ export function buildServiceLogQL(project, servicePodPattern, keyword = '', envName = '') {
332
+ const env = envName ? LOKI_ENVIRONMENTS[envName] : null;
333
+ const hasProject = env ? env.hasProjectLabel !== false : true;
334
+
335
+ // CMS: /data/services/logs/senior/xxx-app/... 私有化: /data/services/logs/xxx-app/...
336
+ const filename = hasProject
337
+ ? `/data/services/logs/${project}/${servicePodPattern}/normal_logs/normal.log`
338
+ : `/data/services/logs/${servicePodPattern}/normal_logs/normal.log`;
339
+
340
+ let expr = `{filename="${filename}"}`;
341
+ if (keyword) {
342
+ expr += ` |= \`${keyword}\``;
343
+ }
344
+ return expr;
345
+ }
346
+
347
+ /**
348
+ * 构建按项目查询的 LogQL 表达式(搜索整个项目所有服务)
349
+ * 根据环境是否有 project 标签,自动选择不同的查询方式:
350
+ * - 有 project 标签(CMS): {project="senior"} |= `keyword`
351
+ * - 无 project 标签(私有化): {filename=~"/data/services/logs/clife-senior-.*normal.log"} |= `keyword`
352
+ *
353
+ * @param {string} project - 项目名,如 'senior'
354
+ * @param {string} keyword - 搜索关键词
355
+ * @param {string} envName - 环境名称,如 'cms'、'chengyang'
356
+ */
357
+ export function buildProjectLogQL(project, keyword, envName = '') {
358
+ const env = envName ? LOKI_ENVIRONMENTS[envName] : null;
359
+ const hasProject = env ? env.hasProjectLabel !== false : true;
360
+
361
+ if (hasProject) {
362
+ // CMS: 直接用 project 标签,高效精确
363
+ return `{project="${project}"} |= \`${keyword}\``;
364
+ } else {
365
+ // 私有化: 用 filename 正则匹配所有 clife-{project}-* 服务的 normal.log
366
+ return `{filename=~"/data/services/logs/clife-${project}-.*normal.log"} |= \`${keyword}\``;
367
+ }
368
+ }
369
+
370
+ // ============================================================
371
+ // 内部辅助函数
372
+ // ============================================================
373
+
374
+ /** 获取 Loki 环境配置 */
375
+ function getLokiEnv(envName) {
376
+ const env = LOKI_ENVIRONMENTS[envName];
377
+ if (!env) {
378
+ const available = Object.keys(LOKI_ENVIRONMENTS).join(', ');
379
+ throw new Error(`未知的 Loki 环境 "${envName}",可用环境: ${available}`);
380
+ }
381
+ return env;
382
+ }
383
+
384
+ /** 构建请求头 */
385
+ function buildHeaders(env) {
386
+ const headers = {
387
+ 'Content-Type': 'application/json',
388
+ 'Accept': 'application/json',
389
+ 'x-grafana-org-id': String(env.orgId || 1),
390
+ 'x-plugin-id': 'loki',
391
+ 'x-datasource-uid': env.datasourceUid
392
+ };
393
+
394
+ // 如果配置了认证信息,添加 Basic Auth
395
+ if (env.username && env.password) {
396
+ const auth = Buffer.from(`${env.username}:${env.password}`).toString('base64');
397
+ headers['Authorization'] = `Basic ${auth}`;
398
+ }
399
+
400
+ return headers;
401
+ }
402
+
403
+ // ============================================================
404
+ // 公共辅助函数
405
+ // ============================================================
406
+
407
+ /** 获取所有可用的 Loki 环境列表 */
408
+ export function listLokiEnvironments() {
409
+ return Object.entries(LOKI_ENVIRONMENTS).map(([key, env]) => ({
410
+ name: key,
411
+ description: env.description,
412
+ grafanaUrl: env.grafanaUrl,
413
+ project: env.defaultProject
414
+ }));
415
+ }
416
+
417
+ /**
418
+ * 获取指定环境下的服务列表(从 Loki filename 标签动态获取)
419
+ * 根据环境是否有 project 标签,使用不同的查询方式:
420
+ * - 有 project 标签(CMS): 用 {project="senior"} 过滤
421
+ * - 无 project 标签(私有化): 获取全部 filename 后按 clife-{project}- 前缀过滤
422
+ *
423
+ * @param {string} envName - 环境名称
424
+ * @param {string} project - 项目名,如 'senior'
425
+ * @returns {string[]} 服务名列表
426
+ */
427
+ export async function listLokiServices(envName, project = 'senior') {
428
+ const env = LOKI_ENVIRONMENTS[envName];
429
+ const hasProject = env ? env.hasProjectLabel !== false : true;
430
+
431
+ let filenames;
432
+ if (hasProject) {
433
+ // CMS: 直接用 project 标签过滤
434
+ filenames = await getLokiLabelValues(envName, 'filename', `{project="${project}"}`);
435
+ } else {
436
+ // 私有化: 获取全部 filename,然后按 clife-{project}- 前缀过滤
437
+ filenames = await getLokiLabelValues(envName, 'filename');
438
+ filenames = filenames.filter(f => f.includes(`/clife-${project}-`));
439
+ }
440
+
441
+ const serviceSet = new Set();
442
+ for (const f of filenames) {
443
+ if (!f.includes('/normal_logs/normal.log')) continue;
444
+ const svc = parseServiceFromFilename(f);
445
+ if (svc) serviceSet.add(svc);
446
+ }
447
+
448
+ return [...serviceSet].sort();
449
+ }
450
+
451
+ /**
452
+ * 根据服务简称获取 Loki 中的服务目录名
453
+ * 例如: 'health' → 'clife-senior-health-app'
454
+ * @param {string} serviceName - 服务简称,如 'health', 'core', 'gateway'
455
+ * @returns {string} 服务目录名
456
+ */
457
+ export function getLokiServiceDirName(serviceName) {
458
+ // 如果已经是完整名称,直接返回
459
+ if (serviceName.startsWith('clife-senior-')) {
460
+ return serviceName.endsWith('-app') ? serviceName : `${serviceName}-app`;
461
+ }
462
+ // 简称转完整名称
463
+ return `clife-senior-${serviceName}-app`;
464
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-log-query-server",
3
- "version": "1.0.0",
4
- "description": "MCP Server for querying server logs via SSH jump host",
3
+ "version": "2.1.1",
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",
7
7
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "index.js",
12
12
  "config.js",
13
13
  "ssh-client.js",
14
+ "loki-client.js",
14
15
  "server-sse.js",
15
16
  "README.md"
16
17
  ],
@@ -39,4 +40,3 @@
39
40
  },
40
41
  "homepage": "https://github.com/huawang1258/mcp-services#readme"
41
42
  }
42
-