@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.
@@ -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 fields = asRecord(asRecord(raw).fields);
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 rawFields = Array.isArray(raw) ? raw : asArray(asRecord(raw).fields);
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
- return null;
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
- constructor(config) {
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 controller = new AbortController();
269
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
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: controller.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 controller = new AbortController();
303
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
304
- try {
305
- const body = source.backend.kind === "kibana_internal_search_es"
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
- const responseJson = (await response.json());
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
- finally {
336
- clearTimeout(timeout);
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
- return Promise.all(sourceQueries.map((sourceQuery) => this.execute(sourceQuery)));
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 controller = new AbortController();
345
- const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
346
- try {
347
- const body = source.backend.kind === "kibana_internal_search_es"
348
- ? {
349
- params: {
350
- ...(source.backend.index ? { index: source.backend.index } : {}),
351
- body: {
352
- size: 20,
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
- return fields;
388
- }
389
- finally {
390
- clearTimeout(timeout);
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 Error(`Schema backend '${schemaBackend.kind}' returned an unexpected field capabilities payload for source '${source.id}': ${error instanceof Error ? error.message : String(error)}`);
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 Error(`Schema backend '${schemaBackend.kind}' returned an unexpected Kibana field payload for source '${source.id}' from '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
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 KibanaHttpError && error.status === 404) {
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.client
66
- .describeFields(source)
67
- .then((fields) => linkFieldRelationships(mergeFieldHints(source, fields)));
68
- this.cache.set(source.id, pending);
69
- return pending;
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;
@@ -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: activeConfig,
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(kibanaClient);
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
- const queryHandler = (input) => executeQuery(queryInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().kibanaClient, {
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: {