@havesomecode/kibana-mcp-server 1.1.2 → 1.2.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/README.md +72 -18
- package/dist/src/bootstrap.js +426 -0
- package/dist/src/cli.js +172 -9
- package/dist/src/client_registration.js +197 -0
- package/dist/src/config.js +43 -4
- package/dist/src/kibana_client.js +280 -92
- package/dist/src/profile_store.js +20 -2
- package/dist/src/schema_catalog.js +20 -6
- package/dist/src/secret_store.js +42 -31
- package/dist/src/server.js +86 -14
- package/dist/src/setup_flow.js +48 -36
- package/dist/src/source_catalog.js +3 -0
- package/dist/src/state_lock.js +370 -0
- package/dist/src/tools/configure_index.js +104 -0
- package/dist/src/tools/describe_fields.js +2 -2
- package/dist/src/tools/discover.js +5 -0
- package/dist/src/tools/filter.js +1 -0
- package/dist/src/tools/query.js +5 -2
- package/docs/project/compatibility-matrix.md +2 -1
- package/docs/project/distribution-strategy.md +17 -4
- package/package.json +2 -1
- package/plugins/kibana-log-investigation/.codex-plugin/plugin.json +3 -3
- package/skills/kibana-log-investigation/SKILL.md +99 -0
|
@@ -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) {
|
|
@@ -136,6 +204,11 @@ function inferPrimitiveFieldType(value, fieldName) {
|
|
|
136
204
|
if (fieldName?.endsWith(".keyword")) {
|
|
137
205
|
return "keyword";
|
|
138
206
|
}
|
|
207
|
+
if (fieldName &&
|
|
208
|
+
/(^@timestamp$)|((^|[._-])(timestamp|time|date|created|ingested|updated)([._-]|$))/i.test(fieldName) &&
|
|
209
|
+
!Number.isNaN(Date.parse(value))) {
|
|
210
|
+
return "date";
|
|
211
|
+
}
|
|
139
212
|
return "text";
|
|
140
213
|
}
|
|
141
214
|
return undefined;
|
|
@@ -254,18 +327,99 @@ function normalizeSearchSampleFields(raw) {
|
|
|
254
327
|
}
|
|
255
328
|
return [...fieldMap.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
256
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
|
+
}
|
|
257
396
|
export class KibanaClient {
|
|
258
397
|
config;
|
|
259
|
-
|
|
398
|
+
requestGate;
|
|
399
|
+
queueTimeoutMs;
|
|
400
|
+
constructor(config, options = {}) {
|
|
260
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);
|
|
261
404
|
}
|
|
262
405
|
async requestJson(url, options = {}) {
|
|
263
|
-
const
|
|
264
|
-
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;
|
|
265
416
|
try {
|
|
417
|
+
release = await this.requestGate.acquire(queueSignal);
|
|
418
|
+
clearTimeout(queueTimeout);
|
|
419
|
+
phase = "request";
|
|
266
420
|
const response = await fetch(url, {
|
|
267
421
|
method: options.method ?? "GET",
|
|
268
|
-
signal
|
|
422
|
+
signal,
|
|
269
423
|
headers: {
|
|
270
424
|
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
271
425
|
"Content-Type": "application/json",
|
|
@@ -273,113 +427,134 @@ export class KibanaClient {
|
|
|
273
427
|
},
|
|
274
428
|
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
275
429
|
});
|
|
430
|
+
phase = "response";
|
|
276
431
|
if (!response.ok) {
|
|
277
432
|
const errorBody = await response.text();
|
|
278
433
|
throw new KibanaHttpError(response.status, errorBody);
|
|
279
434
|
}
|
|
280
|
-
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);
|
|
281
461
|
}
|
|
282
462
|
finally {
|
|
463
|
+
release?.();
|
|
464
|
+
clearTimeout(queueTimeout);
|
|
283
465
|
clearTimeout(timeout);
|
|
284
466
|
}
|
|
285
467
|
}
|
|
286
|
-
async
|
|
468
|
+
async verifyConnection() {
|
|
469
|
+
const response = await this.requestJson(joinUrl(this.config.baseUrl, "/api/status"));
|
|
470
|
+
const overall = asRecord(asRecord(asRecord(response).status).overall);
|
|
471
|
+
const indicator = overall.level ?? overall.state;
|
|
472
|
+
if (typeof indicator !== "string" || !indicator.trim()) {
|
|
473
|
+
throw new Error("Kibana connection verification did not return a valid Kibana status response.");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async execute(compiledQuery, callerSignal) {
|
|
287
477
|
const source = compiledQuery.source;
|
|
288
478
|
const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
params: {
|
|
295
|
-
...(source.backend.index ? { index: source.backend.index } : {}),
|
|
296
|
-
body: compiledQuery.request.body,
|
|
297
|
-
},
|
|
298
|
-
}
|
|
299
|
-
: compiledQuery.request.body;
|
|
300
|
-
const response = await fetch(endpoint, {
|
|
301
|
-
method: "POST",
|
|
302
|
-
signal: controller.signal,
|
|
303
|
-
headers: {
|
|
304
|
-
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
305
|
-
"Content-Type": "application/json",
|
|
306
|
-
...(source.backend.kind === "kibana_internal_search_es"
|
|
307
|
-
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
308
|
-
: {}),
|
|
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,
|
|
309
484
|
},
|
|
310
|
-
body: JSON.stringify(body),
|
|
311
|
-
});
|
|
312
|
-
if (!response.ok) {
|
|
313
|
-
const errorBody = await response.text();
|
|
314
|
-
throw new Error(`Kibana request failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
|
|
315
485
|
}
|
|
316
|
-
|
|
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 {
|
|
317
497
|
return {
|
|
318
498
|
source,
|
|
319
499
|
rawResponse: unwrapSearchResponse(responseJson),
|
|
320
500
|
};
|
|
321
501
|
}
|
|
322
|
-
|
|
323
|
-
|
|
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);
|
|
324
504
|
}
|
|
325
505
|
}
|
|
326
|
-
async executeMany(sourceQueries) {
|
|
327
|
-
|
|
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
|
+
}
|
|
328
520
|
}
|
|
329
|
-
async describeFieldsViaSearchBackend(source) {
|
|
521
|
+
async describeFieldsViaSearchBackend(source, callerSignal) {
|
|
330
522
|
const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
sort: [{ [source.timeField]: { order: "desc" } }],
|
|
341
|
-
fields: ["*"],
|
|
342
|
-
_source: true,
|
|
343
|
-
track_total_hits: false,
|
|
344
|
-
},
|
|
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,
|
|
345
532
|
},
|
|
346
|
-
}
|
|
347
|
-
: {
|
|
348
|
-
size: 20,
|
|
349
|
-
sort: [{ [source.timeField]: { order: "desc" } }],
|
|
350
|
-
fields: ["*"],
|
|
351
|
-
_source: true,
|
|
352
|
-
track_total_hits: false,
|
|
353
|
-
};
|
|
354
|
-
const response = await fetch(endpoint, {
|
|
355
|
-
method: "POST",
|
|
356
|
-
signal: controller.signal,
|
|
357
|
-
headers: {
|
|
358
|
-
Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
|
|
359
|
-
"Content-Type": "application/json",
|
|
360
|
-
...(source.backend.kind === "kibana_internal_search_es"
|
|
361
|
-
? { "kbn-xsrf": "kibana-mcp-server" }
|
|
362
|
-
: {}),
|
|
363
533
|
},
|
|
364
|
-
body: JSON.stringify(body),
|
|
365
|
-
});
|
|
366
|
-
if (!response.ok) {
|
|
367
|
-
const errorBody = await response.text();
|
|
368
|
-
throw new Error(`Search transport fallback failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
|
|
369
534
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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.`);
|
|
380
554
|
}
|
|
555
|
+
return fields;
|
|
381
556
|
}
|
|
382
|
-
async describeFields(source) {
|
|
557
|
+
async describeFields(source, callerSignal) {
|
|
383
558
|
const schemaBackend = source.schema;
|
|
384
559
|
if (!schemaBackend) {
|
|
385
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.`);
|
|
@@ -397,16 +572,21 @@ export class KibanaClient {
|
|
|
397
572
|
try {
|
|
398
573
|
responseJson = await this.requestJson(url, {
|
|
399
574
|
method: "GET",
|
|
575
|
+
callerSignal,
|
|
576
|
+
sourceId: source.id,
|
|
400
577
|
});
|
|
401
578
|
}
|
|
402
579
|
catch (error) {
|
|
580
|
+
if (error instanceof KibanaRequestError) {
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
403
583
|
throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
404
584
|
}
|
|
405
585
|
try {
|
|
406
586
|
return normalizeFieldCapsResponse(responseJson);
|
|
407
587
|
}
|
|
408
588
|
catch (error) {
|
|
409
|
-
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);
|
|
410
590
|
}
|
|
411
591
|
}
|
|
412
592
|
const attempts = [];
|
|
@@ -415,6 +595,8 @@ export class KibanaClient {
|
|
|
415
595
|
try {
|
|
416
596
|
const responseJson = await this.requestJson(url, {
|
|
417
597
|
method: "GET",
|
|
598
|
+
callerSignal,
|
|
599
|
+
sourceId: source.id,
|
|
418
600
|
headers: {
|
|
419
601
|
"kbn-xsrf": "kibana-mcp-server",
|
|
420
602
|
},
|
|
@@ -423,18 +605,24 @@ export class KibanaClient {
|
|
|
423
605
|
return normalizeKibanaFieldsResponse(responseJson);
|
|
424
606
|
}
|
|
425
607
|
catch (error) {
|
|
426
|
-
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);
|
|
427
609
|
}
|
|
428
610
|
}
|
|
429
611
|
catch (error) {
|
|
430
|
-
if (error instanceof
|
|
612
|
+
if (error instanceof KibanaRequestError && error.status === 404) {
|
|
431
613
|
attempts.push(`${schemaPath} -> 404`);
|
|
432
614
|
continue;
|
|
433
615
|
}
|
|
616
|
+
if (error instanceof KibanaRequestError) {
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
434
619
|
throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
435
620
|
}
|
|
436
621
|
}
|
|
437
|
-
return this.describeFieldsViaSearchBackend(source).catch((fallbackError) => {
|
|
622
|
+
return this.describeFieldsViaSearchBackend(source, callerSignal).catch((fallbackError) => {
|
|
623
|
+
if (fallbackError instanceof KibanaRequestError) {
|
|
624
|
+
throw fallbackError;
|
|
625
|
+
}
|
|
438
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)}`);
|
|
439
627
|
});
|
|
440
628
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { resolveProfilePaths } from "./profile_paths.js";
|
|
@@ -105,11 +106,28 @@ export class ProfileStore {
|
|
|
105
106
|
defaultProfileId: profileId,
|
|
106
107
|
});
|
|
107
108
|
}
|
|
109
|
+
async replaceState(state) {
|
|
110
|
+
await this.save(savedProfileStoreStateSchema.parse(state));
|
|
111
|
+
}
|
|
108
112
|
async save(state) {
|
|
109
113
|
const mkdirImpl = this.io.mkdir ?? mkdir;
|
|
110
114
|
const writeFileImpl = this.io.writeFile ?? writeFile;
|
|
111
115
|
await mkdirImpl(dirname(this.paths.profilesPath), { recursive: true });
|
|
112
|
-
|
|
116
|
+
if (this.io.writeFile) {
|
|
117
|
+
await writeFileImpl(this.paths.profilesPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const temporaryPath = `${this.paths.profilesPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
121
|
+
try {
|
|
122
|
+
await writeFileImpl(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
mode: 0o600,
|
|
125
|
+
});
|
|
126
|
+
await rename(temporaryPath, this.paths.profilesPath);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await rm(temporaryPath, { force: true }).catch(() => { });
|
|
130
|
+
}
|
|
113
131
|
}
|
|
114
132
|
}
|
|
115
133
|
export function normalizeProfileId(name) {
|
|
@@ -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;
|