@havesomecode/kibana-mcp-server 1.2.0 → 1.3.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/README.md +6 -2
- package/dist/src/bin/kibana-mcp-server.js +54 -0
- package/dist/src/commands/context.js +1 -0
- package/dist/src/commands/serve.js +19 -0
- package/dist/src/commands/setup.js +132 -0
- package/dist/src/kibana_client.js +267 -90
- package/dist/src/schema_catalog.js +20 -6
- package/dist/src/server.js +63 -16
- package/dist/src/tools/configure_index.js +2 -2
- package/dist/src/tools/describe_fields.js +2 -2
- package/dist/src/tools/filter.js +1 -0
- package/dist/src/tools/query.js +5 -2
- package/dist/src/utils/cli_io.js +11 -0
- package/dist/src/utils/cli_prompts.js +67 -0
- package/dist/src/utils/cli_ui.js +19 -0
- package/package.json +6 -4
- package/dist/src/cli.js +0 -323
- package/dist/src/index.js +0 -10
|
@@ -9,6 +9,24 @@ class KibanaHttpError extends Error {
|
|
|
9
9
|
this.name = "KibanaHttpError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
export class KibanaRequestError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
phase;
|
|
15
|
+
sourceId;
|
|
16
|
+
timeoutMs;
|
|
17
|
+
status;
|
|
18
|
+
causeCode;
|
|
19
|
+
constructor(code, phase, sourceId, timeoutMs, message, cause, status, causeCode) {
|
|
20
|
+
super(message, { cause });
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.phase = phase;
|
|
23
|
+
this.sourceId = sourceId;
|
|
24
|
+
this.timeoutMs = timeoutMs;
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.causeCode = causeCode;
|
|
27
|
+
this.name = "KibanaRequestError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
12
30
|
function joinUrl(baseUrl, path) {
|
|
13
31
|
return path.startsWith("http://") || path.startsWith("https://")
|
|
14
32
|
? path
|
|
@@ -17,6 +35,39 @@ function joinUrl(baseUrl, path) {
|
|
|
17
35
|
function encodeBasicAuth(username, password) {
|
|
18
36
|
return Buffer.from(`${username}:${password}`).toString("base64");
|
|
19
37
|
}
|
|
38
|
+
function getNetworkCauseCode(error) {
|
|
39
|
+
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const code = error.cause.code;
|
|
43
|
+
return typeof code === "string" ? code : undefined;
|
|
44
|
+
}
|
|
45
|
+
function classifyNetworkError(causeCode) {
|
|
46
|
+
if (causeCode === "ENOTFOUND" || causeCode === "EAI_AGAIN") {
|
|
47
|
+
return "KIBANA_DNS";
|
|
48
|
+
}
|
|
49
|
+
if (causeCode?.startsWith("CERT_") ||
|
|
50
|
+
causeCode?.startsWith("ERR_TLS_") ||
|
|
51
|
+
[
|
|
52
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
53
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
54
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
55
|
+
].includes(causeCode ?? "")) {
|
|
56
|
+
return "KIBANA_TLS";
|
|
57
|
+
}
|
|
58
|
+
if ([
|
|
59
|
+
"ECONNREFUSED",
|
|
60
|
+
"ECONNRESET",
|
|
61
|
+
"EHOSTUNREACH",
|
|
62
|
+
"ENETUNREACH",
|
|
63
|
+
"ETIMEDOUT",
|
|
64
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
65
|
+
"UND_ERR_SOCKET",
|
|
66
|
+
].includes(causeCode ?? "")) {
|
|
67
|
+
return "KIBANA_CONNECTION";
|
|
68
|
+
}
|
|
69
|
+
return "KIBANA_NETWORK";
|
|
70
|
+
}
|
|
20
71
|
function unwrapSearchResponse(raw) {
|
|
21
72
|
if (!raw || typeof raw !== "object") {
|
|
22
73
|
throw new Error("Unexpected Kibana search response shape");
|
|
@@ -46,7 +97,17 @@ function asArray(value) {
|
|
|
46
97
|
return Array.isArray(value) ? value : [];
|
|
47
98
|
}
|
|
48
99
|
function normalizeFieldCapsResponse(raw) {
|
|
49
|
-
const
|
|
100
|
+
const record = asRecord(raw);
|
|
101
|
+
if (!raw ||
|
|
102
|
+
typeof raw !== "object" ||
|
|
103
|
+
Array.isArray(raw) ||
|
|
104
|
+
!("fields" in record) ||
|
|
105
|
+
!record.fields ||
|
|
106
|
+
typeof record.fields !== "object" ||
|
|
107
|
+
Array.isArray(record.fields)) {
|
|
108
|
+
throw new Error("Unexpected Elasticsearch field capabilities response shape");
|
|
109
|
+
}
|
|
110
|
+
const fields = asRecord(record.fields);
|
|
50
111
|
return Object.entries(fields)
|
|
51
112
|
.map(([name, typeEntries]) => {
|
|
52
113
|
const typeRecord = asRecord(typeEntries);
|
|
@@ -63,13 +124,21 @@ function normalizeFieldCapsResponse(raw) {
|
|
|
63
124
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
64
125
|
}
|
|
65
126
|
function normalizeKibanaFieldsResponse(raw) {
|
|
66
|
-
const
|
|
127
|
+
const record = asRecord(raw);
|
|
128
|
+
const rawFields = Array.isArray(raw)
|
|
129
|
+
? raw
|
|
130
|
+
: raw && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(record.fields)
|
|
131
|
+
? record.fields
|
|
132
|
+
: undefined;
|
|
133
|
+
if (!rawFields) {
|
|
134
|
+
throw new Error("Unexpected Kibana fields response shape");
|
|
135
|
+
}
|
|
67
136
|
return rawFields
|
|
68
137
|
.map((field) => {
|
|
69
138
|
const fieldRecord = asRecord(field);
|
|
70
139
|
const name = fieldRecord.name;
|
|
71
140
|
if (typeof name !== "string") {
|
|
72
|
-
|
|
141
|
+
throw new Error("Kibana fields response contained an entry without a field name");
|
|
73
142
|
}
|
|
74
143
|
const subType = asRecord(fieldRecord.subType);
|
|
75
144
|
const nested = asRecord(subType.nested);
|
|
@@ -84,7 +153,6 @@ function normalizeKibanaFieldsResponse(raw) {
|
|
|
84
153
|
subfields: [],
|
|
85
154
|
};
|
|
86
155
|
})
|
|
87
|
-
.filter((field) => field !== null)
|
|
88
156
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
89
157
|
}
|
|
90
158
|
function resolveSchemaIndexPatterns(source, schemaBackend) {
|
|
@@ -259,18 +327,99 @@ function normalizeSearchSampleFields(raw) {
|
|
|
259
327
|
}
|
|
260
328
|
return [...fieldMap.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
261
329
|
}
|
|
330
|
+
class RequestGate {
|
|
331
|
+
maxConcurrency;
|
|
332
|
+
maxQueueDepth;
|
|
333
|
+
active = 0;
|
|
334
|
+
waiters = [];
|
|
335
|
+
constructor(maxConcurrency, maxQueueDepth) {
|
|
336
|
+
this.maxConcurrency = maxConcurrency;
|
|
337
|
+
this.maxQueueDepth = maxQueueDepth;
|
|
338
|
+
}
|
|
339
|
+
acquire(signal) {
|
|
340
|
+
if (signal.aborted) {
|
|
341
|
+
return Promise.reject(signal.reason);
|
|
342
|
+
}
|
|
343
|
+
if (this.active >= this.maxConcurrency && this.waiters.length >= this.maxQueueDepth) {
|
|
344
|
+
return Promise.reject(new RequestQueueFullError(this.maxQueueDepth));
|
|
345
|
+
}
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
const waiter = {
|
|
348
|
+
signal,
|
|
349
|
+
resolve,
|
|
350
|
+
reject,
|
|
351
|
+
onAbort: () => {
|
|
352
|
+
const index = this.waiters.indexOf(waiter);
|
|
353
|
+
if (index >= 0) {
|
|
354
|
+
this.waiters.splice(index, 1);
|
|
355
|
+
}
|
|
356
|
+
reject(signal.reason);
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
360
|
+
this.waiters.push(waiter);
|
|
361
|
+
this.dispatch();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
dispatch() {
|
|
365
|
+
while (this.active < this.maxConcurrency) {
|
|
366
|
+
const waiter = this.waiters.shift();
|
|
367
|
+
if (!waiter) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
371
|
+
if (waiter.signal.aborted) {
|
|
372
|
+
waiter.reject(waiter.signal.reason);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
this.active += 1;
|
|
376
|
+
let released = false;
|
|
377
|
+
waiter.resolve(() => {
|
|
378
|
+
if (released) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
released = true;
|
|
382
|
+
this.active -= 1;
|
|
383
|
+
this.dispatch();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
class RequestQueueFullError extends Error {
|
|
389
|
+
maxQueueDepth;
|
|
390
|
+
constructor(maxQueueDepth) {
|
|
391
|
+
super(`Kibana request queue is full (${maxQueueDepth} waiting requests)`);
|
|
392
|
+
this.maxQueueDepth = maxQueueDepth;
|
|
393
|
+
this.name = "RequestQueueFullError";
|
|
394
|
+
}
|
|
395
|
+
}
|
|
262
396
|
export class KibanaClient {
|
|
263
397
|
config;
|
|
264
|
-
|
|
398
|
+
requestGate;
|
|
399
|
+
queueTimeoutMs;
|
|
400
|
+
constructor(config, options = {}) {
|
|
265
401
|
this.config = config;
|
|
402
|
+
this.requestGate = new RequestGate(options.maxConcurrency ?? 8, options.maxQueueDepth ?? 32);
|
|
403
|
+
this.queueTimeoutMs = Math.min(options.queueTimeoutMs ?? config.timeoutMs, config.timeoutMs);
|
|
266
404
|
}
|
|
267
405
|
async requestJson(url, options = {}) {
|
|
268
|
-
const
|
|
269
|
-
const timeout = setTimeout(() =>
|
|
406
|
+
const timeoutController = new AbortController();
|
|
407
|
+
const timeout = setTimeout(() => timeoutController.abort(new DOMException(`Timed out after ${this.config.timeoutMs}ms`, "TimeoutError")), this.config.timeoutMs);
|
|
408
|
+
const signal = options.callerSignal
|
|
409
|
+
? AbortSignal.any([timeoutController.signal, options.callerSignal])
|
|
410
|
+
: timeoutController.signal;
|
|
411
|
+
const queueController = new AbortController();
|
|
412
|
+
const queueTimeout = setTimeout(() => queueController.abort(new DOMException(`Queue wait timed out after ${this.queueTimeoutMs}ms`, "TimeoutError")), this.queueTimeoutMs);
|
|
413
|
+
const queueSignal = AbortSignal.any([signal, queueController.signal]);
|
|
414
|
+
let phase = "queue";
|
|
415
|
+
let release;
|
|
270
416
|
try {
|
|
417
|
+
release = await this.requestGate.acquire(queueSignal);
|
|
418
|
+
clearTimeout(queueTimeout);
|
|
419
|
+
phase = "request";
|
|
271
420
|
const response = await fetch(url, {
|
|
272
421
|
method: options.method ?? "GET",
|
|
273
|
-
signal
|
|
422
|
+
signal,
|
|
274
423
|
headers: {
|
|
275
424
|
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
276
425
|
"Content-Type": "application/json",
|
|
@@ -278,13 +427,41 @@ export class KibanaClient {
|
|
|
278
427
|
},
|
|
279
428
|
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
280
429
|
});
|
|
430
|
+
phase = "response";
|
|
281
431
|
if (!response.ok) {
|
|
282
432
|
const errorBody = await response.text();
|
|
283
433
|
throw new KibanaHttpError(response.status, errorBody);
|
|
284
434
|
}
|
|
285
|
-
return response.json();
|
|
435
|
+
return await response.json();
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
const sourceLabel = options.sourceId ? ` for source '${options.sourceId}'` : "";
|
|
439
|
+
if (error instanceof KibanaRequestError) {
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
442
|
+
if (options.callerSignal?.aborted) {
|
|
443
|
+
throw new KibanaRequestError("KIBANA_CANCELLED", phase, options.sourceId, this.config.timeoutMs, `[KIBANA_CANCELLED] Kibana request${sourceLabel} was cancelled during ${phase}`, error);
|
|
444
|
+
}
|
|
445
|
+
if (error instanceof RequestQueueFullError) {
|
|
446
|
+
throw new KibanaRequestError("KIBANA_OVERLOADED", "queue", options.sourceId, this.queueTimeoutMs, `[KIBANA_OVERLOADED] Kibana request queue${sourceLabel} is full (${error.maxQueueDepth} waiting requests)`, error);
|
|
447
|
+
}
|
|
448
|
+
if (queueController.signal.aborted) {
|
|
449
|
+
throw new KibanaRequestError("KIBANA_TIMEOUT", "queue", options.sourceId, this.queueTimeoutMs, `[KIBANA_TIMEOUT] Kibana request${sourceLabel} exceeded the ${this.queueTimeoutMs}ms queue-wait timeout`, error);
|
|
450
|
+
}
|
|
451
|
+
if (timeoutController.signal.aborted) {
|
|
452
|
+
throw new KibanaRequestError("KIBANA_TIMEOUT", phase, options.sourceId, this.config.timeoutMs, `[KIBANA_TIMEOUT] Kibana request${sourceLabel} timed out after ${this.config.timeoutMs}ms during ${phase}`, error);
|
|
453
|
+
}
|
|
454
|
+
if (error instanceof KibanaHttpError) {
|
|
455
|
+
const code = error.status === 401 || error.status === 403 ? "KIBANA_AUTHENTICATION" : "KIBANA_HTTP";
|
|
456
|
+
throw new KibanaRequestError(code, "response", options.sourceId, this.config.timeoutMs, `[${code}] Kibana request${sourceLabel} returned HTTP ${error.status}`, error, error.status);
|
|
457
|
+
}
|
|
458
|
+
const causeCode = getNetworkCauseCode(error);
|
|
459
|
+
const code = phase === "response" && !causeCode ? "KIBANA_RESPONSE" : classifyNetworkError(causeCode);
|
|
460
|
+
throw new KibanaRequestError(code, phase, options.sourceId, this.config.timeoutMs, `[${code}] Kibana request${sourceLabel} failed during ${phase}${causeCode ? ` (${causeCode})` : ""}`, error, undefined, causeCode);
|
|
286
461
|
}
|
|
287
462
|
finally {
|
|
463
|
+
release?.();
|
|
464
|
+
clearTimeout(queueTimeout);
|
|
288
465
|
clearTimeout(timeout);
|
|
289
466
|
}
|
|
290
467
|
}
|
|
@@ -296,101 +473,88 @@ export class KibanaClient {
|
|
|
296
473
|
throw new Error("Kibana connection verification did not return a valid Kibana status response.");
|
|
297
474
|
}
|
|
298
475
|
}
|
|
299
|
-
async execute(compiledQuery) {
|
|
476
|
+
async execute(compiledQuery, callerSignal) {
|
|
300
477
|
const source = compiledQuery.source;
|
|
301
478
|
const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
params: {
|
|
308
|
-
...(source.backend.index ? { index: source.backend.index } : {}),
|
|
309
|
-
body: compiledQuery.request.body,
|
|
310
|
-
},
|
|
311
|
-
}
|
|
312
|
-
: compiledQuery.request.body;
|
|
313
|
-
const response = await fetch(endpoint, {
|
|
314
|
-
method: "POST",
|
|
315
|
-
signal: controller.signal,
|
|
316
|
-
headers: {
|
|
317
|
-
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
318
|
-
"Content-Type": "application/json",
|
|
319
|
-
...(source.backend.kind === "kibana_internal_search_es"
|
|
320
|
-
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
321
|
-
: {}),
|
|
479
|
+
const body = source.backend.kind === "kibana_internal_search_es"
|
|
480
|
+
? {
|
|
481
|
+
params: {
|
|
482
|
+
...(source.backend.index ? { index: source.backend.index } : {}),
|
|
483
|
+
body: compiledQuery.request.body,
|
|
322
484
|
},
|
|
323
|
-
body: JSON.stringify(body),
|
|
324
|
-
});
|
|
325
|
-
if (!response.ok) {
|
|
326
|
-
const errorBody = await response.text();
|
|
327
|
-
throw new Error(`Kibana request failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
|
|
328
485
|
}
|
|
329
|
-
|
|
486
|
+
: compiledQuery.request.body;
|
|
487
|
+
const responseJson = await this.requestJson(endpoint, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
callerSignal,
|
|
490
|
+
sourceId: source.id,
|
|
491
|
+
headers: source.backend.kind === "kibana_internal_search_es"
|
|
492
|
+
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
493
|
+
: undefined,
|
|
494
|
+
body,
|
|
495
|
+
});
|
|
496
|
+
try {
|
|
330
497
|
return {
|
|
331
498
|
source,
|
|
332
499
|
rawResponse: unwrapSearchResponse(responseJson),
|
|
333
500
|
};
|
|
334
501
|
}
|
|
335
|
-
|
|
336
|
-
|
|
502
|
+
catch (error) {
|
|
503
|
+
throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Kibana returned an unexpected search response for source '${source.id}'`, error);
|
|
337
504
|
}
|
|
338
505
|
}
|
|
339
|
-
async executeMany(sourceQueries) {
|
|
340
|
-
|
|
506
|
+
async executeMany(sourceQueries, callerSignal) {
|
|
507
|
+
const siblingController = new AbortController();
|
|
508
|
+
const signal = callerSignal
|
|
509
|
+
? AbortSignal.any([callerSignal, siblingController.signal])
|
|
510
|
+
: siblingController.signal;
|
|
511
|
+
const executions = sourceQueries.map((sourceQuery) => this.execute(sourceQuery, signal));
|
|
512
|
+
try {
|
|
513
|
+
return await Promise.all(executions);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
siblingController.abort(error);
|
|
517
|
+
await Promise.allSettled(executions);
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
341
520
|
}
|
|
342
|
-
async describeFieldsViaSearchBackend(source) {
|
|
521
|
+
async describeFieldsViaSearchBackend(source, callerSignal) {
|
|
343
522
|
const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
fields: ["*"],
|
|
354
|
-
_source: true,
|
|
355
|
-
track_total_hits: false,
|
|
356
|
-
},
|
|
523
|
+
const body = source.backend.kind === "kibana_internal_search_es"
|
|
524
|
+
? {
|
|
525
|
+
params: {
|
|
526
|
+
...(source.backend.index ? { index: source.backend.index } : {}),
|
|
527
|
+
body: {
|
|
528
|
+
size: 20,
|
|
529
|
+
fields: ["*"],
|
|
530
|
+
_source: true,
|
|
531
|
+
track_total_hits: false,
|
|
357
532
|
},
|
|
358
|
-
}
|
|
359
|
-
: {
|
|
360
|
-
size: 20,
|
|
361
|
-
fields: ["*"],
|
|
362
|
-
_source: true,
|
|
363
|
-
track_total_hits: false,
|
|
364
|
-
};
|
|
365
|
-
const response = await fetch(endpoint, {
|
|
366
|
-
method: "POST",
|
|
367
|
-
signal: controller.signal,
|
|
368
|
-
headers: {
|
|
369
|
-
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
370
|
-
"Content-Type": "application/json",
|
|
371
|
-
...(source.backend.kind === "kibana_internal_search_es"
|
|
372
|
-
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
373
|
-
: {}),
|
|
374
533
|
},
|
|
375
|
-
body: JSON.stringify(body),
|
|
376
|
-
});
|
|
377
|
-
if (!response.ok) {
|
|
378
|
-
const errorBody = await response.text();
|
|
379
|
-
throw new Error(`Search transport fallback failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
|
|
380
|
-
}
|
|
381
|
-
const responseJson = (await response.json());
|
|
382
|
-
const rawResponse = unwrapSearchResponse(responseJson);
|
|
383
|
-
const fields = normalizeSearchSampleFields(rawResponse);
|
|
384
|
-
if (fields.length === 0) {
|
|
385
|
-
throw new Error(`Search transport fallback returned no fields for source '${source.id}'. Sample hits may be empty for the requested index pattern.`);
|
|
386
534
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
535
|
+
: {
|
|
536
|
+
size: 20,
|
|
537
|
+
fields: ["*"],
|
|
538
|
+
_source: true,
|
|
539
|
+
track_total_hits: false,
|
|
540
|
+
};
|
|
541
|
+
const responseJson = await this.requestJson(endpoint, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
callerSignal,
|
|
544
|
+
sourceId: source.id,
|
|
545
|
+
headers: source.backend.kind === "kibana_internal_search_es"
|
|
546
|
+
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
547
|
+
: undefined,
|
|
548
|
+
body,
|
|
549
|
+
});
|
|
550
|
+
const rawResponse = unwrapSearchResponse(responseJson);
|
|
551
|
+
const fields = normalizeSearchSampleFields(rawResponse);
|
|
552
|
+
if (fields.length === 0) {
|
|
553
|
+
throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Search transport fallback returned no fields for source '${source.id}'. Sample hits may be empty for the requested index pattern.`);
|
|
391
554
|
}
|
|
555
|
+
return fields;
|
|
392
556
|
}
|
|
393
|
-
async describeFields(source) {
|
|
557
|
+
async describeFields(source, callerSignal) {
|
|
394
558
|
const schemaBackend = source.schema;
|
|
395
559
|
if (!schemaBackend) {
|
|
396
560
|
throw new Error(`Source '${source.id}' does not configure a schema backend. Add source.schema before using describe_fields or schema-dependent query features.`);
|
|
@@ -408,16 +572,21 @@ export class KibanaClient {
|
|
|
408
572
|
try {
|
|
409
573
|
responseJson = await this.requestJson(url, {
|
|
410
574
|
method: "GET",
|
|
575
|
+
callerSignal,
|
|
576
|
+
sourceId: source.id,
|
|
411
577
|
});
|
|
412
578
|
}
|
|
413
579
|
catch (error) {
|
|
580
|
+
if (error instanceof KibanaRequestError) {
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
414
583
|
throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
415
584
|
}
|
|
416
585
|
try {
|
|
417
586
|
return normalizeFieldCapsResponse(responseJson);
|
|
418
587
|
}
|
|
419
588
|
catch (error) {
|
|
420
|
-
throw new
|
|
589
|
+
throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Schema backend '${schemaBackend.kind}' returned an unexpected field capabilities payload for source '${source.id}': ${error instanceof Error ? error.message : String(error)}`, error);
|
|
421
590
|
}
|
|
422
591
|
}
|
|
423
592
|
const attempts = [];
|
|
@@ -426,6 +595,8 @@ export class KibanaClient {
|
|
|
426
595
|
try {
|
|
427
596
|
const responseJson = await this.requestJson(url, {
|
|
428
597
|
method: "GET",
|
|
598
|
+
callerSignal,
|
|
599
|
+
sourceId: source.id,
|
|
429
600
|
headers: {
|
|
430
601
|
"kbn-xsrf": "kibana-mcp-server",
|
|
431
602
|
},
|
|
@@ -434,18 +605,24 @@ export class KibanaClient {
|
|
|
434
605
|
return normalizeKibanaFieldsResponse(responseJson);
|
|
435
606
|
}
|
|
436
607
|
catch (error) {
|
|
437
|
-
throw new
|
|
608
|
+
throw new KibanaRequestError("KIBANA_RESPONSE", "response", source.id, this.config.timeoutMs, `[KIBANA_RESPONSE] Schema backend '${schemaBackend.kind}' returned an unexpected Kibana field payload for source '${source.id}' from '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`, error);
|
|
438
609
|
}
|
|
439
610
|
}
|
|
440
611
|
catch (error) {
|
|
441
|
-
if (error instanceof
|
|
612
|
+
if (error instanceof KibanaRequestError && error.status === 404) {
|
|
442
613
|
attempts.push(`${schemaPath} -> 404`);
|
|
443
614
|
continue;
|
|
444
615
|
}
|
|
616
|
+
if (error instanceof KibanaRequestError) {
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
445
619
|
throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
446
620
|
}
|
|
447
621
|
}
|
|
448
|
-
return this.describeFieldsViaSearchBackend(source).catch((fallbackError) => {
|
|
622
|
+
return this.describeFieldsViaSearchBackend(source, callerSignal).catch((fallbackError) => {
|
|
623
|
+
if (fallbackError instanceof KibanaRequestError) {
|
|
624
|
+
throw fallbackError;
|
|
625
|
+
}
|
|
449
626
|
throw new Error(`Schema backend '${schemaBackend.kind}' returned 404 for source '${source.id}' on every known Kibana field-discovery path (${attempts.join(", ")}). Search transport fallback also failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
450
627
|
});
|
|
451
628
|
}
|
|
@@ -54,19 +54,33 @@ function mergeFieldHints(source, fields) {
|
|
|
54
54
|
export class SchemaCatalog {
|
|
55
55
|
client;
|
|
56
56
|
cache = new Map();
|
|
57
|
+
inFlight = new Map();
|
|
57
58
|
constructor(client) {
|
|
58
59
|
this.client = client;
|
|
59
60
|
}
|
|
60
|
-
async getFields(source) {
|
|
61
|
+
async getFields(source, callerSignal) {
|
|
61
62
|
const existing = this.cache.get(source.id);
|
|
62
63
|
if (existing) {
|
|
63
64
|
return existing;
|
|
64
65
|
}
|
|
65
|
-
const pending = this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
const pending = this.inFlight.get(source.id);
|
|
67
|
+
if (pending) {
|
|
68
|
+
return pending;
|
|
69
|
+
}
|
|
70
|
+
const discovery = this.client.describeFields(source, callerSignal).then((fields) => {
|
|
71
|
+
const resolved = linkFieldRelationships(mergeFieldHints(source, fields));
|
|
72
|
+
this.cache.set(source.id, resolved);
|
|
73
|
+
return resolved;
|
|
74
|
+
});
|
|
75
|
+
this.inFlight.set(source.id, discovery);
|
|
76
|
+
try {
|
|
77
|
+
return await discovery;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
if (this.inFlight.get(source.id) === discovery) {
|
|
81
|
+
this.inFlight.delete(source.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
70
84
|
}
|
|
71
85
|
filterFields(fields, query, limit = 100) {
|
|
72
86
|
const normalizedQuery = query ? normalizeFieldName(query) : undefined;
|
package/dist/src/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
-
import { KibanaClient } from "./kibana_client.js";
|
|
2
|
+
import { KibanaClient, KibanaRequestError } from "./kibana_client.js";
|
|
3
3
|
import { SchemaCatalog } from "./schema_catalog.js";
|
|
4
4
|
import { SourceCatalog } from "./source_catalog.js";
|
|
5
5
|
import { configureInputSchema, configureOutputSchema, createConfigureCallToolResult, executeConfigure, } from "./tools/configure.js";
|
|
@@ -8,6 +8,30 @@ import { createDescribeFieldsCallToolResult, describeFieldsInputSchema, describe
|
|
|
8
8
|
import { createDiscoverCallToolResult, discoverInputSchema, discoverOutputSchema, executeDiscover, } from "./tools/discover.js";
|
|
9
9
|
import { createFilterCallToolResult, executeFilter, filterInputSchema, filterOutputSchema, } from "./tools/filter.js";
|
|
10
10
|
import { createQueryCallToolResult, executeQuery, queryInputSchema, queryOutputSchema, } from "./tools/query.js";
|
|
11
|
+
async function withKibanaRequestDiagnostics(operation) {
|
|
12
|
+
try {
|
|
13
|
+
return await operation();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (!(error instanceof KibanaRequestError)) {
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: error.message }],
|
|
21
|
+
isError: true,
|
|
22
|
+
_meta: {
|
|
23
|
+
kibana_request_error: {
|
|
24
|
+
code: error.code,
|
|
25
|
+
phase: error.phase,
|
|
26
|
+
source_id: error.sourceId,
|
|
27
|
+
timeout_ms: error.timeoutMs,
|
|
28
|
+
...(error.status === undefined ? {} : { status: error.status }),
|
|
29
|
+
...(error.causeCode === undefined ? {} : { cause_code: error.causeCode }),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
11
35
|
export function createApplication(initialConfig, dependencies) {
|
|
12
36
|
const server = new McpServer({
|
|
13
37
|
name: "kibana-log-investigation",
|
|
@@ -21,6 +45,24 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
21
45
|
? (dependencies?.kibanaClient ?? kibanaClientFactory(activeConfig.kibana))
|
|
22
46
|
: null;
|
|
23
47
|
let schemaCatalog = kibanaClient ? new SchemaCatalog(kibanaClient) : null;
|
|
48
|
+
async function withKibanaToolDeadline(callerSignal, operation) {
|
|
49
|
+
const timeoutMs = activeConfig?.kibana.timeoutMs;
|
|
50
|
+
if (timeoutMs === undefined) {
|
|
51
|
+
throw new Error("Server is not configured. Call the 'configure' tool first.");
|
|
52
|
+
}
|
|
53
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
54
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
55
|
+
try {
|
|
56
|
+
return await operation(signal);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (callerSignal?.aborted || !timeoutSignal.aborted) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const requestError = error instanceof KibanaRequestError ? error : undefined;
|
|
63
|
+
throw new KibanaRequestError("KIBANA_TIMEOUT", requestError?.phase ?? "request", requestError?.sourceId, timeoutMs, `Kibana tool call exceeded its ${timeoutMs} ms backend deadline.`, error, requestError?.status, requestError?.causeCode);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
24
66
|
function requireConfigured() {
|
|
25
67
|
if (!sourceCatalog || !kibanaClient || !schemaCatalog) {
|
|
26
68
|
throw new Error("Server is not configured. Call the 'configure' tool first.");
|
|
@@ -42,28 +84,33 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
42
84
|
schemaCatalog = new SchemaCatalog(kibanaClient);
|
|
43
85
|
return result;
|
|
44
86
|
};
|
|
45
|
-
const configureIndexHandler = async (input) => {
|
|
87
|
+
const configureIndexHandler = async (input, callerSignal) => {
|
|
46
88
|
if (!activeConfig || !kibanaClient) {
|
|
47
89
|
throw new Error("No saved Kibana connection is available. Run connection bootstrap first.");
|
|
48
90
|
}
|
|
91
|
+
const configured = activeConfig;
|
|
92
|
+
const client = kibanaClient;
|
|
49
93
|
const configureIndexFn = dependencies?.configureIndexFn ?? executeConfigureIndex;
|
|
50
|
-
const { sources, result } = await configureIndexFn(configureIndexInputSchema.parse(input), {
|
|
51
|
-
config:
|
|
52
|
-
kibanaClient,
|
|
53
|
-
|
|
94
|
+
const { sources, result } = await withKibanaToolDeadline(callerSignal, (signal) => configureIndexFn(configureIndexInputSchema.parse(input), {
|
|
95
|
+
config: configured,
|
|
96
|
+
kibanaClient: client,
|
|
97
|
+
callerSignal: signal,
|
|
98
|
+
}));
|
|
54
99
|
activeConfig = { ...activeConfig, sources };
|
|
55
100
|
sourceCatalog = new SourceCatalog(sources);
|
|
56
|
-
schemaCatalog = new SchemaCatalog(
|
|
101
|
+
schemaCatalog = new SchemaCatalog(client);
|
|
57
102
|
return result;
|
|
58
103
|
};
|
|
59
|
-
const describeFieldsHandler = (input) => executeDescribeFields(describeFieldsInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().schemaCatalog);
|
|
104
|
+
const describeFieldsHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeDescribeFields(describeFieldsInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().schemaCatalog, signal));
|
|
60
105
|
const discoverHandler = (input) => executeDiscover(discoverInputSchema.parse(input), requireConfigured().sourceCatalog);
|
|
61
|
-
const filterHandler = (input) => executeFilter(filterInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().kibanaClient, {
|
|
106
|
+
const filterHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeFilter(filterInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().kibanaClient, {
|
|
62
107
|
schemaCatalog: requireConfigured().schemaCatalog,
|
|
63
|
-
|
|
64
|
-
|
|
108
|
+
callerSignal: signal,
|
|
109
|
+
}));
|
|
110
|
+
const queryHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeQuery(queryInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().kibanaClient, {
|
|
65
111
|
schemaCatalog: requireConfigured().schemaCatalog,
|
|
66
|
-
|
|
112
|
+
callerSignal: signal,
|
|
113
|
+
}));
|
|
67
114
|
if (!initialConfig) {
|
|
68
115
|
server.registerTool("configure", {
|
|
69
116
|
description: "Advanced legacy setup for a server started without a saved profile. Replaces the Kibana connection and full source catalog.",
|
|
@@ -76,13 +123,13 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
76
123
|
description: "Configure exactly one Kibana index or index pattern after the user has explicitly named it. Never guess, list, scan, or auto-select indexes before calling this tool.",
|
|
77
124
|
inputSchema: configureIndexInputSchema,
|
|
78
125
|
outputSchema: configureIndexOutputSchema,
|
|
79
|
-
}, async (input) => createConfigureIndexCallToolResult(await configureIndexHandler(input)));
|
|
126
|
+
}, async (input, context) => withKibanaRequestDiagnostics(async () => createConfigureIndexCallToolResult(await configureIndexHandler(input, context.mcpReq.signal))));
|
|
80
127
|
}
|
|
81
128
|
server.registerTool("describe_fields", {
|
|
82
129
|
description: "Describe the effective field capabilities for a configured logical source.",
|
|
83
130
|
inputSchema: describeFieldsInputSchema,
|
|
84
131
|
outputSchema: describeFieldsOutputSchema,
|
|
85
|
-
}, async (input) => createDescribeFieldsCallToolResult(await describeFieldsHandler(input)));
|
|
132
|
+
}, async (input, context) => withKibanaRequestDiagnostics(async () => createDescribeFieldsCallToolResult(await describeFieldsHandler(input, context.mcpReq.signal))));
|
|
86
133
|
server.registerTool("discover", {
|
|
87
134
|
description: "List configured logical log sources and field hints for investigation work.",
|
|
88
135
|
inputSchema: discoverInputSchema,
|
|
@@ -92,12 +139,12 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
92
139
|
description: "Run an exact-field filter when the field name is already known, bypassing alias resolution.",
|
|
93
140
|
inputSchema: filterInputSchema,
|
|
94
141
|
outputSchema: filterOutputSchema,
|
|
95
|
-
}, async (input) => createFilterCallToolResult(await filterHandler(input)));
|
|
142
|
+
}, async (input, context) => withKibanaRequestDiagnostics(async () => createFilterCallToolResult(await filterHandler(input, context.mcpReq.signal))));
|
|
96
143
|
server.registerTool("query", {
|
|
97
144
|
description: "Query one or more logical log sources over an absolute time window with text, filters, and aggregate modes.",
|
|
98
145
|
inputSchema: queryInputSchema,
|
|
99
146
|
outputSchema: queryOutputSchema,
|
|
100
|
-
}, async (input) => createQueryCallToolResult(await queryHandler(input)));
|
|
147
|
+
}, async (input, context) => withKibanaRequestDiagnostics(async () => createQueryCallToolResult(await queryHandler(input, context.mcpReq.signal))));
|
|
101
148
|
return {
|
|
102
149
|
server,
|
|
103
150
|
handlers: {
|