@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.
@@ -36,15 +36,8 @@ class MacOsSecretStore {
36
36
  return parseStoredSecret(result.stdout);
37
37
  }
38
38
  async save(profileId, secret) {
39
- await this.execute("add-generic-password", [
40
- "-U",
41
- "-s",
42
- this.serviceName,
43
- "-a",
44
- profileId,
45
- "-w",
46
- serializeSecret(secret),
47
- ]);
39
+ const serialized = serializeSecret(secret);
40
+ await this.execute("add-generic-password", ["-U", "-s", this.serviceName, "-a", profileId, "-w"], `${serialized}\n${serialized}\n`);
48
41
  }
49
42
  async delete(profileId) {
50
43
  try {
@@ -57,9 +50,9 @@ class MacOsSecretStore {
57
50
  throw error;
58
51
  }
59
52
  }
60
- async execute(subcommand, args) {
53
+ async execute(subcommand, args, stdin) {
61
54
  try {
62
- const result = await this.runner("security", [subcommand, ...args]);
55
+ const result = await this.runner("security", [subcommand, ...args], { stdin });
63
56
  if (result.exitCode === 44) {
64
57
  throw new SecretStoreError(`No saved credentials found for profile '${args.at(-1)}'.`, "NOT_FOUND");
65
58
  }
@@ -110,7 +103,10 @@ class LinuxSecretStore {
110
103
  const result = await this.runner("secret-tool", [command, ...args], { stdin });
111
104
  if (result.exitCode !== 0) {
112
105
  const stderr = result.stderr.trim().toLowerCase();
113
- if (stderr.includes("not found")) {
106
+ const missingWithoutMessage = (command === "lookup" || command === "clear") &&
107
+ result.exitCode === 1 &&
108
+ stderr.length === 0;
109
+ if (stderr.includes("not found") || missingWithoutMessage) {
114
110
  throw new SecretStoreError(`No saved credentials found for profile '${args.at(-1) ?? "unknown"}'.`, "NOT_FOUND");
115
111
  }
116
112
  throw new SecretStoreError(result.stderr.trim() || "Secret Service command failed.", "OPERATION_FAILED");
@@ -133,42 +129,45 @@ class WindowsSecretStore {
133
129
  const result = await this.execute(`
134
130
  [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
135
131
  $vault = New-Object Windows.Security.Credentials.PasswordVault
132
+ $payload = ConvertFrom-Json ([Console]::In.ReadToEnd())
136
133
  try {
137
- $credential = $vault.Retrieve($args[0], $args[1])
134
+ $credential = $vault.Retrieve($payload.serviceName, $payload.profileId)
138
135
  $credential.RetrievePassword()
139
136
  [Console]::Out.Write($credential.Password)
140
137
  exit 0
141
138
  } catch {
142
139
  exit 3
143
140
  }
144
- `, [this.serviceName, profileId]);
141
+ `, profileId);
145
142
  return parseStoredSecret(result.stdout);
146
143
  }
147
144
  async save(profileId, secret) {
148
145
  await this.execute(`
149
146
  [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
150
147
  $vault = New-Object Windows.Security.Credentials.PasswordVault
148
+ $payload = ConvertFrom-Json ([Console]::In.ReadToEnd())
151
149
  try {
152
- $existing = $vault.Retrieve($args[0], $args[1])
150
+ $existing = $vault.Retrieve($payload.serviceName, $payload.profileId)
153
151
  $vault.Remove($existing)
154
152
  } catch {}
155
- $credential = New-Object Windows.Security.Credentials.PasswordCredential($args[0], $args[1], $args[2])
153
+ $credential = New-Object Windows.Security.Credentials.PasswordCredential($payload.serviceName, $payload.profileId, $payload.secret)
156
154
  $vault.Add($credential)
157
- `, [this.serviceName, profileId, serializeSecret(secret)]);
155
+ `, profileId, secret);
158
156
  }
159
157
  async delete(profileId) {
160
158
  try {
161
159
  await this.execute(`
162
160
  [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
163
161
  $vault = New-Object Windows.Security.Credentials.PasswordVault
162
+ $payload = ConvertFrom-Json ([Console]::In.ReadToEnd())
164
163
  try {
165
- $credential = $vault.Retrieve($args[0], $args[1])
164
+ $credential = $vault.Retrieve($payload.serviceName, $payload.profileId)
166
165
  $vault.Remove($credential)
167
166
  exit 0
168
167
  } catch {
169
168
  exit 3
170
169
  }
171
- `, [this.serviceName, profileId]);
170
+ `, profileId);
172
171
  }
173
172
  catch (error) {
174
173
  if (error instanceof SecretStoreError && error.code === "NOT_FOUND") {
@@ -177,17 +176,16 @@ try {
177
176
  throw error;
178
177
  }
179
178
  }
180
- async execute(script, args) {
179
+ async execute(script, profileId, secret) {
181
180
  try {
182
- const result = await this.runner("powershell.exe", [
183
- "-NoProfile",
184
- "-NonInteractive",
185
- "-Command",
186
- script.trim(),
187
- ...args,
188
- ]);
181
+ const stdin = JSON.stringify({
182
+ serviceName: this.serviceName,
183
+ profileId,
184
+ ...(secret ? { secret: serializeSecret(secret) } : {}),
185
+ });
186
+ const result = await this.runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script.trim()], { stdin });
189
187
  if (result.exitCode === 3) {
190
- throw new SecretStoreError(`No saved credentials found for profile '${args[1]}'.`, "NOT_FOUND");
188
+ throw new SecretStoreError(`No saved credentials found for profile '${profileId}'.`, "NOT_FOUND");
191
189
  }
192
190
  if (result.exitCode !== 0) {
193
191
  throw new SecretStoreError(result.stderr.trim() || "Windows Credential Manager command failed.", "OPERATION_FAILED");
@@ -237,6 +235,18 @@ async function runCommand(command, args, options = {}) {
237
235
  });
238
236
  let stdout = "";
239
237
  let stderr = "";
238
+ let settled = false;
239
+ const finish = (callback) => {
240
+ if (settled)
241
+ return;
242
+ settled = true;
243
+ clearTimeout(timeout);
244
+ callback();
245
+ };
246
+ const timeout = setTimeout(() => {
247
+ child.kill("SIGTERM");
248
+ finish(() => reject(new Error("Credential-store command timed out after 30000ms.")));
249
+ }, 30000);
240
250
  child.stdout.setEncoding("utf8");
241
251
  child.stderr.setEncoding("utf8");
242
252
  child.stdout.on("data", (chunk) => {
@@ -245,13 +255,14 @@ async function runCommand(command, args, options = {}) {
245
255
  child.stderr.on("data", (chunk) => {
246
256
  stderr += chunk;
247
257
  });
248
- child.on("error", reject);
258
+ child.on("error", (error) => finish(() => reject(error)));
259
+ child.stdin.on("error", (error) => finish(() => reject(error)));
249
260
  child.on("close", (exitCode) => {
250
- resolve({
261
+ finish(() => resolve({
251
262
  stdout,
252
263
  stderr,
253
264
  exitCode: exitCode ?? 1,
254
- });
265
+ }));
255
266
  });
256
267
  if (options.stdin) {
257
268
  child.stdin.write(options.stdin);
@@ -1,12 +1,37 @@
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";
6
+ import { configureIndexInputSchema, configureIndexOutputSchema, createConfigureIndexCallToolResult, executeConfigureIndex, } from "./tools/configure_index.js";
6
7
  import { createDescribeFieldsCallToolResult, describeFieldsInputSchema, describeFieldsOutputSchema, executeDescribeFields, } from "./tools/describe_fields.js";
7
8
  import { createDiscoverCallToolResult, discoverInputSchema, discoverOutputSchema, executeDiscover, } from "./tools/discover.js";
8
9
  import { createFilterCallToolResult, executeFilter, filterInputSchema, filterOutputSchema, } from "./tools/filter.js";
9
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
+ }
10
35
  export function createApplication(initialConfig, dependencies) {
11
36
  const server = new McpServer({
12
37
  name: "kibana-log-investigation",
@@ -20,6 +45,24 @@ export function createApplication(initialConfig, dependencies) {
20
45
  ? (dependencies?.kibanaClient ?? kibanaClientFactory(activeConfig.kibana))
21
46
  : null;
22
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
+ }
23
66
  function requireConfigured() {
24
67
  if (!sourceCatalog || !kibanaClient || !schemaCatalog) {
25
68
  throw new Error("Server is not configured. Call the 'configure' tool first.");
@@ -41,24 +84,52 @@ export function createApplication(initialConfig, dependencies) {
41
84
  schemaCatalog = new SchemaCatalog(kibanaClient);
42
85
  return result;
43
86
  };
44
- const describeFieldsHandler = (input) => executeDescribeFields(describeFieldsInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().schemaCatalog);
87
+ const configureIndexHandler = async (input, callerSignal) => {
88
+ if (!activeConfig || !kibanaClient) {
89
+ throw new Error("No saved Kibana connection is available. Run connection bootstrap first.");
90
+ }
91
+ const configured = activeConfig;
92
+ const client = kibanaClient;
93
+ const configureIndexFn = dependencies?.configureIndexFn ?? executeConfigureIndex;
94
+ const { sources, result } = await withKibanaToolDeadline(callerSignal, (signal) => configureIndexFn(configureIndexInputSchema.parse(input), {
95
+ config: configured,
96
+ kibanaClient: client,
97
+ callerSignal: signal,
98
+ }));
99
+ activeConfig = { ...activeConfig, sources };
100
+ sourceCatalog = new SourceCatalog(sources);
101
+ schemaCatalog = new SchemaCatalog(client);
102
+ return result;
103
+ };
104
+ const describeFieldsHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeDescribeFields(describeFieldsInputSchema.parse(input), requireConfigured().sourceCatalog, requireConfigured().schemaCatalog, signal));
45
105
  const discoverHandler = (input) => executeDiscover(discoverInputSchema.parse(input), requireConfigured().sourceCatalog);
46
- 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, {
47
107
  schemaCatalog: requireConfigured().schemaCatalog,
48
- });
49
- 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, {
50
111
  schemaCatalog: requireConfigured().schemaCatalog,
51
- });
52
- server.registerTool("configure", {
53
- description: "Configure the Kibana connection and logical source catalog for this server session.",
54
- inputSchema: configureInputSchema,
55
- outputSchema: configureOutputSchema,
56
- }, async (input) => createConfigureCallToolResult(await configureHandler(input)));
112
+ callerSignal: signal,
113
+ }));
114
+ if (!initialConfig) {
115
+ server.registerTool("configure", {
116
+ description: "Advanced legacy setup for a server started without a saved profile. Replaces the Kibana connection and full source catalog.",
117
+ inputSchema: configureInputSchema,
118
+ outputSchema: configureOutputSchema,
119
+ }, async (input) => createConfigureCallToolResult(await configureHandler(input)));
120
+ }
121
+ else {
122
+ server.registerTool("configure_index", {
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.",
124
+ inputSchema: configureIndexInputSchema,
125
+ outputSchema: configureIndexOutputSchema,
126
+ }, async (input, context) => withKibanaRequestDiagnostics(async () => createConfigureIndexCallToolResult(await configureIndexHandler(input, context.mcpReq.signal))));
127
+ }
57
128
  server.registerTool("describe_fields", {
58
129
  description: "Describe the effective field capabilities for a configured logical source.",
59
130
  inputSchema: describeFieldsInputSchema,
60
131
  outputSchema: describeFieldsOutputSchema,
61
- }, async (input) => createDescribeFieldsCallToolResult(await describeFieldsHandler(input)));
132
+ }, async (input, context) => withKibanaRequestDiagnostics(async () => createDescribeFieldsCallToolResult(await describeFieldsHandler(input, context.mcpReq.signal))));
62
133
  server.registerTool("discover", {
63
134
  description: "List configured logical log sources and field hints for investigation work.",
64
135
  inputSchema: discoverInputSchema,
@@ -68,16 +139,17 @@ export function createApplication(initialConfig, dependencies) {
68
139
  description: "Run an exact-field filter when the field name is already known, bypassing alias resolution.",
69
140
  inputSchema: filterInputSchema,
70
141
  outputSchema: filterOutputSchema,
71
- }, async (input) => createFilterCallToolResult(await filterHandler(input)));
142
+ }, async (input, context) => withKibanaRequestDiagnostics(async () => createFilterCallToolResult(await filterHandler(input, context.mcpReq.signal))));
72
143
  server.registerTool("query", {
73
144
  description: "Query one or more logical log sources over an absolute time window with text, filters, and aggregate modes.",
74
145
  inputSchema: queryInputSchema,
75
146
  outputSchema: queryOutputSchema,
76
- }, async (input) => createQueryCallToolResult(await queryHandler(input)));
147
+ }, async (input, context) => withKibanaRequestDiagnostics(async () => createQueryCallToolResult(await queryHandler(input, context.mcpReq.signal))));
77
148
  return {
78
149
  server,
79
150
  handlers: {
80
151
  configure: configureHandler,
152
+ configure_index: configureIndexHandler,
81
153
  describe_fields: describeFieldsHandler,
82
154
  discover: discoverHandler,
83
155
  filter: filterHandler,
@@ -5,6 +5,7 @@ import { sourceCatalogSchema } from "./config.js";
5
5
  import { resolveProfilePaths } from "./profile_paths.js";
6
6
  import { ProfileStore, deriveProfileId } from "./profile_store.js";
7
7
  import { SecretStoreError, createSecretStore } from "./secret_store.js";
8
+ import { acquireStateLock } from "./state_lock.js";
8
9
  const DEFAULT_TIMEOUT_MS = 10000;
9
10
  const KNOWN_ENDPOINT_PATHS = [
10
11
  "/internal/search/es",
@@ -24,44 +25,52 @@ export async function runSetupFlow(prompter, dependencies = {}) {
24
25
  await prompter.info("Guided setup will save one machine-level Kibana profile and reuse it in later threads.");
25
26
  let continueAddingProfiles = true;
26
27
  while (continueAddingProfiles) {
27
- const existingProfiles = await profileStore.listProfiles();
28
+ const stateBeforePrompts = await profileStore.load();
29
+ const existingProfiles = stateBeforePrompts.profiles;
28
30
  const profileName = await promptForProfileName(prompter, existingProfiles.map((profile) => profile.name));
29
- const existingProfile = existingProfiles.find((profile) => profile.name.toLowerCase() === profileName.toLowerCase());
30
- const profileId = existingProfile?.id ??
31
- deriveProfileId(profileName, existingProfiles.map((profile) => profile.id));
32
31
  const baseUrl = await promptForBaseUrl(prompter);
33
32
  const username = await promptForRequiredValue(prompter, "Kibana username");
34
33
  const password = await promptForRequiredValue(prompter, "Kibana password", { secret: true });
35
34
  const importPath = await promptForSourceCatalogPath(prompter, cwd);
36
- const importedCatalogPath = join(paths.sourceCatalogsDir, `${profileId}.json`);
37
- await copyValidatedSourceCatalog(importPath, importedCatalogPath, {
38
- readFile: readFileImpl,
39
- writeFile: writeFileImpl,
40
- mkdir: mkdirImpl,
41
- });
35
+ const makeDefault = existingProfiles.length === 0
36
+ ? true
37
+ : await prompter.confirm(`Make '${profileName}' the default environment for new threads?`, false);
38
+ const releaseLock = await acquireStateLock(paths.stateRoot);
39
+ let profile;
40
+ let importedCatalogPath;
42
41
  try {
43
- await secretStore.save(profileId, { username, password });
44
- }
45
- catch (error) {
46
- if (error instanceof SecretStoreError) {
47
- throw new Error(`Could not save Kibana credentials securely: ${error.message}`);
42
+ const currentState = await profileStore.load();
43
+ if (JSON.stringify(currentState) !== JSON.stringify(stateBeforePrompts)) {
44
+ throw new Error("Saved Kibana profiles changed while setup was collecting input. Re-run setup to avoid overwriting concurrent changes.");
48
45
  }
49
- throw error;
50
- }
51
- const profile = await profileStore.upsertProfile({
52
- id: profileId,
53
- name: profileName,
54
- baseUrl,
55
- timeoutMs: DEFAULT_TIMEOUT_MS,
56
- sourceCatalogPath: importedCatalogPath,
57
- }, {
58
- makeDefault: existingProfiles.length === 0,
59
- });
60
- if (existingProfiles.length > 0) {
61
- const makeDefault = await prompter.confirm(`Make '${profile.name}' the default environment for new threads?`, false);
62
- if (makeDefault) {
63
- await profileStore.setDefaultProfile(profile.id);
46
+ const existingProfile = currentState.profiles.find((candidate) => candidate.name.toLowerCase() === profileName.toLowerCase());
47
+ const profileId = existingProfile?.id ??
48
+ deriveProfileId(profileName, currentState.profiles.map((candidate) => candidate.id));
49
+ importedCatalogPath = join(paths.sourceCatalogsDir, `${profileId}.json`);
50
+ await copyValidatedSourceCatalog(importPath, importedCatalogPath, {
51
+ readFile: readFileImpl,
52
+ writeFile: writeFileImpl,
53
+ mkdir: mkdirImpl,
54
+ });
55
+ try {
56
+ await secretStore.save(profileId, { username, password });
57
+ }
58
+ catch (error) {
59
+ if (error instanceof SecretStoreError) {
60
+ throw new Error(`Could not save Kibana credentials securely: ${error.message}`);
61
+ }
62
+ throw error;
64
63
  }
64
+ profile = await profileStore.upsertProfile({
65
+ id: profileId,
66
+ name: profileName,
67
+ baseUrl,
68
+ timeoutMs: DEFAULT_TIMEOUT_MS,
69
+ sourceCatalogPath: importedCatalogPath,
70
+ }, { makeDefault });
71
+ }
72
+ finally {
73
+ await releaseLock();
65
74
  }
66
75
  savedProfileNames.push(profile.name);
67
76
  savedCatalogPaths.push(importedCatalogPath);
@@ -123,18 +132,21 @@ async function promptForRequiredValue(prompter, label, options = {}) {
123
132
  }
124
133
  }
125
134
  async function promptForSourceCatalogPath(prompter, cwd) {
126
- const bundledCatalogPath = await resolveBundledSourceCatalogPath();
127
135
  while (true) {
128
- const rawValue = (await prompter.prompt("Source catalog path to import", {
129
- defaultValue: bundledCatalogPath,
130
- })).trim();
131
- const importPath = rawValue ? resolve(cwd, rawValue) : bundledCatalogPath;
136
+ const rawValue = (await prompter.prompt("Source catalog path to import (or type 'example')")).trim();
137
+ if (!rawValue) {
138
+ await prompter.info("A source catalog is required. Enter a JSON path or type 'example' explicitly.");
139
+ continue;
140
+ }
141
+ const importPath = rawValue.toLowerCase() === "example"
142
+ ? await resolveBundledSourceCatalogPath()
143
+ : resolve(cwd, rawValue);
132
144
  try {
133
145
  await access(importPath);
134
146
  return importPath;
135
147
  }
136
148
  catch {
137
- await prompter.info(`Source catalog not found at ${importPath}. Use a JSON file or accept the bundled example.`);
149
+ await prompter.info(`Source catalog not found at ${importPath}.`);
138
150
  }
139
151
  }
140
152
  }
@@ -5,6 +5,9 @@ export class SourceCatalog {
5
5
  this.sources = sources;
6
6
  this.sourceMap = new Map(sources.map((source) => [source.id, source]));
7
7
  }
8
+ count() {
9
+ return this.sources.length;
10
+ }
8
11
  list(query, limit = 20) {
9
12
  const normalizedQuery = query?.trim().toLowerCase();
10
13
  const filtered = normalizedQuery