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.
- package/config.js +162 -1
- package/index.js +252 -42
- package/loki-client.js +464 -0
- 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: '
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|