@havesomecode/kibana-mcp-server 1.1.2 → 1.2.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 CHANGED
@@ -5,6 +5,8 @@ Read-only MCP server for agent-driven log investigation against Kibana-backed se
5
5
  The server is intentionally small:
6
6
 
7
7
  - `setup` saves one or more machine-level Kibana profiles for later threads
8
+ - `bootstrap` verifies only the Kibana connection, creates an empty managed catalog, saves credentials, and registers Codex without prompts or config-file edits
9
+ - `configure_index` validates and persists exactly one index or pattern after the user explicitly names it
8
10
  - `configure` still exists for advanced MCP clients that want to drive credentials and source catalogs at runtime
9
11
  - `describe_fields` exposes effective field capabilities for one configured source
10
12
  - `discover` lists configured logical sources and field hints
@@ -17,7 +19,7 @@ It is designed as a general-purpose log investigation MCP for environments where
17
19
 
18
20
  - Node.js 22+
19
21
  - A Kibana-compatible read endpoint reachable with basic auth
20
- - A source catalog JSON file describing the logical log sources to expose, or the bundled `config/sources.example.json`
22
+ - One or more Elasticsearch index patterns. `bootstrap` generates the source catalog; hand-authored catalogs remain supported for advanced setups.
21
23
 
22
24
  ## Project Status
23
25
 
@@ -25,10 +27,11 @@ This repo is live for external adoption and AI-agent use.
25
27
 
26
28
  - Homepage: `https://havesomecode.github.io/kibana-mcp-server/`
27
29
  - Published package: `npx -y @havesomecode/kibana-mcp-server`
30
+ - Agent skill: `npx skills add Havesomecode/kibana-mcp-server --skill kibana-log-investigation`
28
31
  - Repo-local Codex plugin install remains a first-class path for cloned checkouts
29
32
 
30
- - Guaranteed: repo-local Codex plugin install (see `INSTALL.md`)
31
- - Guaranteed: guided machine setup through `npm run setup`
33
+ - Guaranteed: prompt-free package bootstrap and Codex registration (see `INSTALL.md`)
34
+ - Supported: guided machine setup through `npm run setup` for hand-authored catalogs
32
35
  - Available: published package install via `npx -y @havesomecode/kibana-mcp-server`
33
36
  - Active: semantic-release automation for tags, npm publish, and GitHub Releases
34
37
  - Support posture and compatibility details live in `docs/project/support-policy.md`
@@ -45,17 +48,44 @@ This repo is live for external adoption and AI-agent use.
45
48
 
46
49
  ## Installation
47
50
 
51
+ ### Prompt-free connection bootstrap
52
+
53
+ Pre-provision the connection values in the environment:
54
+
48
55
  ```bash
49
- npm install
50
- npm run build
51
- npm run setup
56
+ export KIBANA_BASE_URL='https://kibana.example.com'
57
+ export KIBANA_USERNAME='elastic'
58
+ export KIBANA_PASSWORD='use-a-secret-provider'
59
+
60
+ npx -y @havesomecode/kibana-mcp-server bootstrap \
61
+ --client codex
52
62
  ```
53
63
 
54
- ## Use In Codex
64
+ This command performs no prompts and does not edit Codex configuration files directly. It verifies only the Kibana connection, writes an empty managed source catalog on first install, stores credentials securely, and registers the exact running package transport. It does not list, scan, infer, or configure indexes.
65
+
66
+ After bootstrap, call `discover`. When no source is configured, the agent must ask the user which exact Kibana index or index pattern to use. Only after the user answers may the agent call `configure_index` with that value. That tool validates only the approved index and persists it without accepting credentials.
67
+
68
+ Bootstrap reruns preserve sources that were configured through this explicit flow. It refuses to replace a hand-authored or modified catalog unless `--replace` is supplied explicitly; `--replace` creates an empty managed catalog.
69
+
70
+ Never pass passwords as command-line arguments. Use `KIBANA_PASSWORD`, `--password-env NAME`, or `--password-stdin`.
71
+
72
+ ### Install the agent skill
73
+
74
+ The repository contains a standard Agent Skill discoverable by the `skills` CLI and skills.sh:
75
+
76
+ ```bash
77
+ npx skills add Havesomecode/kibana-mcp-server \
78
+ --skill kibana-log-investigation \
79
+ --agent codex \
80
+ --global \
81
+ --yes
82
+ ```
55
83
 
56
- This repo includes a repo-scoped Codex plugin so a cloned checkout can install the MCP without any extra packaging or hosted infrastructure.
84
+ The skill teaches compatible agents to run the same verified bootstrap instead of editing MCP config files. Its source is `skills/kibana-log-investigation/SKILL.md`.
57
85
 
58
- If you do not need the repo-local plugin workflow, the published package is already available through `npx -y @havesomecode/kibana-mcp-server`.
86
+ ## Use In Codex
87
+
88
+ The prompt-free package bootstrap above is the primary Codex path. The repo-scoped plugin remains available for contributors working from a cloned checkout.
59
89
 
60
90
  ### Quick path
61
91
 
@@ -68,19 +98,14 @@ If you do not need the repo-local plugin workflow, the published package is alre
68
98
  ```bash
69
99
  npm install
70
100
  npm run build
71
- npm run setup
72
101
  ```
73
102
 
74
103
  4. Open the cloned repo in Codex.
75
104
  5. Open the plugin directory in Codex and install `Kibana Log Investigation` from the repo marketplace.
76
105
  - if the current model cannot complete that install itself, do the Codex UI click manually and let the agent continue with configuration afterward
77
106
  6. Restart Codex if the new MCP server does not appear immediately.
78
- 7. Run guided setup once:
79
- - `npm run setup`
80
- - provide the environment name, Kibana base URL, username, password, and a source catalog file to import
81
- - use the bundled `config/sources.example.json` unless you already have a better catalog JSON
82
- 8. Let later threads reuse the saved default profile automatically.
83
- - if you add more than one environment during setup, select non-default ones with `KIBANA_PROFILE=<PROFILE_NAME>`
107
+ 7. Run `node dist/src/index.js bootstrap --client none` to verify the connection and save an empty profile. The repo plugin already supplies the client registration.
108
+ 8. In a fresh thread, call `discover`, ask the user for the exact index or pattern, then call `configure_index` only after they answer.
84
109
 
85
110
  Repo-scoped plugin files:
86
111
 
@@ -93,12 +118,21 @@ Repo-scoped plugin files:
93
118
  If you are handing only the repo link to another Codex agent, this usually works:
94
119
 
95
120
  ```text
96
- Clone this repo, ensure Node.js 22+ is installed at user level for the current OS, run npm install, npm run build, and npm run setup, install the repo plugin named "Kibana Log Investigation", import a source catalog during setup, and verify discover plus one query work in Codex without needing a later manual configure step.
121
+ Install the `kibana-log-investigation` skill from Havesomecode/kibana-mcp-server, then run its connection-only bootstrap with the provisioned KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD values. Do not edit Codex config files, import the example catalog, or inspect indexes. After install, ask me which exact index or pattern to configure before calling configure_index.
97
122
  ```
98
123
 
99
124
  ## Configuration
100
125
 
101
- The preferred path is guided machine setup:
126
+ The preferred path is deterministic connection-only bootstrap:
127
+
128
+ - `npx -y @havesomecode/kibana-mcp-server bootstrap --client codex`
129
+ - verifies `/api/status` without touching an index endpoint
130
+ - creates an empty managed source catalog on first install
131
+ - rejects conflicting MCP registrations instead of overwriting them
132
+ - saves non-secret profile metadata and credentials for later threads
133
+ - exposes `configure_index` for one user-approved index or pattern at a time
134
+
135
+ Guided setup remains available for hand-authored source catalogs:
102
136
 
103
137
  - `npm run setup` or `node dist/src/index.js setup`
104
138
  - saves non-secret profile metadata in the user’s machine-level app config directory
@@ -107,6 +141,7 @@ The preferred path is guided machine setup:
107
141
  - Windows Credential Manager
108
142
  - Linux Secret Service when available
109
143
  - imports the selected source catalog into machine-local state so later threads do not depend on the repo checkout
144
+ - requires an explicit catalog path; type `example` to opt into the bundled example deliberately
110
145
 
111
146
  The server also supports two advanced compatibility paths:
112
147
 
@@ -265,6 +300,21 @@ npm run dev
265
300
 
266
301
  ## Tool Shapes
267
302
 
303
+ ### `configure_index`
304
+
305
+ Use this tool only after the user explicitly names the exact index or index pattern to configure. It accepts no credentials and never lists or scans for candidate indexes.
306
+
307
+ Input:
308
+
309
+ ```json
310
+ {
311
+ "index": "app-logs-*",
312
+ "source_name": "Application logs"
313
+ }
314
+ ```
315
+
316
+ The tool inspects fields and runs one bounded validation search only for that index. It then persists the generated source in the managed catalog. Optional `source_id`, `time_field`, and `replace` fields support explicit collision or schema handling.
317
+
268
318
  ### `configure`
269
319
 
270
320
  Input:
@@ -0,0 +1,426 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CodexClientRegistrar } from "./client_registration.js";
6
+ import { sourceCatalogSchema, sourceDefinitionSchema } from "./config.js";
7
+ import { KibanaClient } from "./kibana_client.js";
8
+ import { resolveProfilePaths } from "./profile_paths.js";
9
+ import { ProfileStore, deriveProfileId } from "./profile_store.js";
10
+ import { SecretStoreError, createSecretStore } from "./secret_store.js";
11
+ import { acquireStateLock } from "./state_lock.js";
12
+ const DEFAULT_TIMEOUT_MS = 10000;
13
+ const DEFAULT_MCP_NAME = "kibana-log-investigation";
14
+ const KNOWN_ENDPOINT_PATHS = [
15
+ "/internal/search/es",
16
+ "/api/data_views/fields_for_wildcard",
17
+ "/api/index_patterns/_fields_for_wildcard",
18
+ ];
19
+ export function buildGeneratedSource(rawIndexes, sourceName, sourceId) {
20
+ const indexes = normalizeIndexes(rawIndexes);
21
+ const resolvedSourceId = normalizeSourceId(sourceId ?? sourceName ?? indexes[0] ?? "logs");
22
+ const resolvedSourceName = sourceName?.trim() || resolvedSourceId;
23
+ const indexConfig = indexes.length === 1 ? indexes[0] : indexes;
24
+ return {
25
+ id: resolvedSourceId,
26
+ name: resolvedSourceName,
27
+ description: `Generated from Elasticsearch index patterns: ${indexes.join(", ")}.`,
28
+ tags: ["generated"],
29
+ timeField: "@timestamp",
30
+ backend: {
31
+ kind: "kibana_internal_search_es",
32
+ path: "/internal/search/es",
33
+ index: indexConfig,
34
+ },
35
+ schema: {
36
+ kind: "kibana_data_views_fields",
37
+ index: indexConfig,
38
+ },
39
+ fieldHints: [],
40
+ defaultTextFields: ["message"],
41
+ evidenceFields: [],
42
+ };
43
+ }
44
+ export function chooseGeneratedSourceFields(fields, explicitTimeField) {
45
+ if (fields.length === 0) {
46
+ throw new Error("The index pattern resolved to no fields. Check the index and Kibana permissions.");
47
+ }
48
+ const byName = new Map(fields.map((field) => [field.name, field]));
49
+ const requestedTimeField = explicitTimeField?.trim();
50
+ if (requestedTimeField && !byName.has(requestedTimeField)) {
51
+ throw new Error(`Requested time field '${requestedTimeField}' was not found in the index pattern.`);
52
+ }
53
+ const timeField = requestedTimeField ||
54
+ ["@timestamp", "timestamp", "event.created", "event.ingested", "date"].find((name) => {
55
+ const field = byName.get(name);
56
+ return field?.type === "date" || field?.type === "date_nanos";
57
+ }) ||
58
+ fields
59
+ .filter((field) => field.type === "date" || field.type === "date_nanos")
60
+ .map((field) => field.name)
61
+ .sort((left, right) => left.localeCompare(right))[0];
62
+ if (!timeField) {
63
+ throw new Error("Could not determine a time field from the discovered schema. Retry configure_index with time_field set explicitly.");
64
+ }
65
+ const preferredTextFields = ["message", "event.original", "log.original"];
66
+ const defaultTextFields = preferredTextFields.filter((name) => byName.has(name));
67
+ if (defaultTextFields.length === 0) {
68
+ const fallbackTextField = fields
69
+ .filter((field) => field.searchable !== false && (field.type === "text" || field.type === "match_only_text"))
70
+ .map((field) => field.name)
71
+ .sort((left, right) => left.localeCompare(right))[0];
72
+ if (fallbackTextField) {
73
+ defaultTextFields.push(fallbackTextField);
74
+ }
75
+ }
76
+ const evidenceFields = [
77
+ "trace.id",
78
+ "traceId",
79
+ "trace_id",
80
+ "correlation_id",
81
+ "request.id",
82
+ "transaction.id",
83
+ "service.name",
84
+ "host.name",
85
+ ].filter((name) => byName.has(name));
86
+ return { timeField, defaultTextFields, evidenceFields };
87
+ }
88
+ export async function runBootstrap(rawOptions, dependencies = {}) {
89
+ const paths = dependencies.paths ?? resolveProfilePaths();
90
+ const releaseLock = await acquireStateLock(paths.stateRoot);
91
+ try {
92
+ return await runBootstrapLocked(rawOptions, { ...dependencies, paths });
93
+ }
94
+ finally {
95
+ await releaseLock();
96
+ }
97
+ }
98
+ async function runBootstrapLocked(rawOptions, dependencies) {
99
+ const normalizedOptions = normalizeOptions(rawOptions);
100
+ const options = {
101
+ ...normalizedOptions,
102
+ packageSpecifier: normalizedOptions.packageSpecifier ||
103
+ (normalizedOptions.client === "codex" ? await resolveCurrentPackageSpecifier() : ""),
104
+ };
105
+ const paths = dependencies.paths ?? resolveProfilePaths();
106
+ const profileStore = dependencies.profileStore ?? new ProfileStore(paths);
107
+ const secretStore = dependencies.secretStore ?? createSecretStore();
108
+ const verifier = dependencies.verifier ?? new NetworkBootstrapVerifier();
109
+ const stateBefore = await profileStore.load();
110
+ const existingProfile = stateBefore.profiles.find((profile) => profile.name.toLowerCase() === options.profileName.toLowerCase());
111
+ const profileId = existingProfile?.id ??
112
+ deriveProfileId(options.profileName, stateBefore.profiles.map((profile) => profile.id));
113
+ const sourceCatalogPath = join(paths.sourceCatalogsDir, `${profileId}.json`);
114
+ const profileCatalog = existingProfile
115
+ ? await readOptionalFile(existingProfile.sourceCatalogPath)
116
+ : undefined;
117
+ const targetCatalog = await readOptionalFile(sourceCatalogPath);
118
+ const protectedCatalog = [profileCatalog, targetCatalog].find((content) => content !== undefined && !isGeneratedCatalog(content));
119
+ if (!options.replaceExisting && protectedCatalog !== undefined) {
120
+ throw new Error(`Profile '${options.profileName}' would replace an existing hand-authored, modified, or orphaned source catalog. Re-run with --replace to replace it explicitly.`);
121
+ }
122
+ const connection = {
123
+ baseUrl: options.baseUrl,
124
+ username: options.username,
125
+ password: options.password,
126
+ timeoutMs: options.timeoutMs,
127
+ };
128
+ const registrar = resolveRegistrar(options, dependencies.clientRegistrar);
129
+ if (registrar) {
130
+ await registrar.preflight();
131
+ }
132
+ await verifier.verify(connection);
133
+ const existingGeneratedCatalog = [targetCatalog, profileCatalog]
134
+ .filter((content) => content !== undefined)
135
+ .find((content) => isGeneratedCatalog(content));
136
+ const catalog = !options.replaceExisting && existingGeneratedCatalog
137
+ ? sourceCatalogSchema.parse(JSON.parse(existingGeneratedCatalog))
138
+ : createGeneratedCatalog([]);
139
+ const previousCatalog = targetCatalog;
140
+ const previousSecret = await loadPreviousSecret(secretStore, profileId);
141
+ let localStateChanged = false;
142
+ let secretPossiblyChanged = false;
143
+ try {
144
+ await writeJsonAtomically(sourceCatalogPath, catalog);
145
+ localStateChanged = true;
146
+ secretPossiblyChanged = true;
147
+ await secretStore.save(profileId, {
148
+ username: options.username,
149
+ password: options.password,
150
+ });
151
+ await profileStore.upsertProfile({
152
+ id: profileId,
153
+ name: options.profileName,
154
+ baseUrl: options.baseUrl,
155
+ timeoutMs: options.timeoutMs,
156
+ sourceCatalogPath,
157
+ }, { makeDefault: options.makeDefault });
158
+ if (registrar) {
159
+ await registrar.register();
160
+ }
161
+ return {
162
+ profileName: options.profileName,
163
+ profileId,
164
+ sourceCount: catalog.sources.length,
165
+ sourceCatalogPath,
166
+ client: options.client,
167
+ registered: options.client === "codex",
168
+ verified: true,
169
+ };
170
+ }
171
+ catch (error) {
172
+ const rollbackErrors = [];
173
+ if (registrar) {
174
+ await rollbackClientRegistration(registrar).catch((rollbackError) => {
175
+ rollbackErrors.push(asErrorMessage(rollbackError));
176
+ });
177
+ }
178
+ if (localStateChanged) {
179
+ await profileStore.replaceState(stateBefore).catch((rollbackError) => {
180
+ rollbackErrors.push(asErrorMessage(rollbackError));
181
+ });
182
+ await restoreFile(sourceCatalogPath, previousCatalog).catch((rollbackError) => {
183
+ rollbackErrors.push(asErrorMessage(rollbackError));
184
+ });
185
+ if (secretPossiblyChanged) {
186
+ await restoreSecret(secretStore, profileId, previousSecret).catch((rollbackError) => {
187
+ rollbackErrors.push(asErrorMessage(rollbackError));
188
+ });
189
+ }
190
+ }
191
+ if (rollbackErrors.length > 0) {
192
+ throw new Error(`${asErrorMessage(error)} Rollback also failed: ${rollbackErrors.join("; ")}`, { cause: error });
193
+ }
194
+ throw error;
195
+ }
196
+ }
197
+ class NetworkBootstrapVerifier {
198
+ async verify(connection) {
199
+ await new KibanaClient(connection).verifyConnection();
200
+ }
201
+ }
202
+ function resolveRegistrar(options, injectedRegistrar) {
203
+ if (options.client === "none") {
204
+ return undefined;
205
+ }
206
+ return (injectedRegistrar ??
207
+ new CodexClientRegistrar({
208
+ mcpName: options.mcpName,
209
+ profileName: options.profileName,
210
+ packageSpecifier: options.packageSpecifier,
211
+ }));
212
+ }
213
+ function normalizeOptions(options) {
214
+ const profileName = options.profileName.trim();
215
+ if (!profileName || !/^[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(profileName)) {
216
+ throw new Error("Profile name must start with a letter or number and contain only letters, numbers, spaces, dashes, or underscores.");
217
+ }
218
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
219
+ const username = options.username.trim();
220
+ const password = options.password;
221
+ if (!username)
222
+ throw new Error("Kibana username is required.");
223
+ if (!password)
224
+ throw new Error("Kibana password is required.");
225
+ if (options.client !== "codex" && options.client !== "none") {
226
+ throw new Error("Client must be either 'codex' or 'none'.");
227
+ }
228
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
229
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120000) {
230
+ throw new Error("Timeout must be a positive number no greater than 120000 milliseconds.");
231
+ }
232
+ return {
233
+ ...options,
234
+ profileName,
235
+ baseUrl,
236
+ username,
237
+ password,
238
+ timeoutMs,
239
+ mcpName: options.mcpName?.trim() || DEFAULT_MCP_NAME,
240
+ packageSpecifier: options.packageSpecifier?.trim() || "",
241
+ replaceExisting: options.replaceExisting ?? false,
242
+ };
243
+ }
244
+ export async function resolveCurrentPackageSpecifier() {
245
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
246
+ const candidates = [
247
+ join(moduleDirectory, "..", "package.json"),
248
+ join(moduleDirectory, "..", "..", "package.json"),
249
+ ];
250
+ for (const candidate of candidates) {
251
+ const content = await readOptionalFile(candidate);
252
+ if (!content)
253
+ continue;
254
+ try {
255
+ const parsed = JSON.parse(content);
256
+ if (typeof parsed.name === "string" && typeof parsed.version === "string") {
257
+ return `${parsed.name}@${parsed.version}`;
258
+ }
259
+ }
260
+ catch {
261
+ // Try the next package.json candidate.
262
+ }
263
+ }
264
+ throw new Error("Could not resolve the running package version for deterministic registration.");
265
+ }
266
+ export function createGeneratedCatalog(sourceOrSources) {
267
+ const sources = Array.isArray(sourceOrSources) ? sourceOrSources : [sourceOrSources];
268
+ const parsedSources = sources.map((source) => sourceDefinitionSchema.parse(source));
269
+ return sourceCatalogSchema.parse({
270
+ generatedBy: {
271
+ tool: "@havesomecode/kibana-mcp-server",
272
+ formatVersion: 1,
273
+ sourceHash: hashSources(parsedSources),
274
+ },
275
+ sources: parsedSources,
276
+ });
277
+ }
278
+ export function parseGeneratedCatalog(content) {
279
+ try {
280
+ const raw = JSON.parse(content);
281
+ const parsed = sourceCatalogSchema.parse(raw);
282
+ if (!isPlainRecord(raw) || !hasExactKeys(raw, ["generatedBy", "sources"]))
283
+ return undefined;
284
+ if (!isPlainRecord(raw.generatedBy))
285
+ return undefined;
286
+ if (!hasExactKeys(raw.generatedBy, ["formatVersion", "sourceHash", "tool"])) {
287
+ return undefined;
288
+ }
289
+ if (!Array.isArray(raw.sources))
290
+ return undefined;
291
+ if (!parsed.generatedBy || parsed.generatedBy.sourceHash !== hashSources(raw.sources)) {
292
+ return undefined;
293
+ }
294
+ return parsed;
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ }
300
+ function isGeneratedCatalog(content) {
301
+ return parseGeneratedCatalog(content) !== undefined;
302
+ }
303
+ function hashSources(sources) {
304
+ return createHash("sha256").update(JSON.stringify(sources)).digest("hex");
305
+ }
306
+ function isPlainRecord(value) {
307
+ return typeof value === "object" && value !== null && !Array.isArray(value);
308
+ }
309
+ function hasExactKeys(record, expectedKeys) {
310
+ const keys = Object.keys(record).sort();
311
+ return JSON.stringify(keys) === JSON.stringify([...expectedKeys].sort());
312
+ }
313
+ async function loadPreviousSecret(secretStore, profileId) {
314
+ try {
315
+ return await secretStore.load(profileId);
316
+ }
317
+ catch (error) {
318
+ if (error instanceof SecretStoreError && error.code === "NOT_FOUND") {
319
+ return undefined;
320
+ }
321
+ throw error;
322
+ }
323
+ }
324
+ function normalizeIndexes(rawIndexes) {
325
+ const indexes = [...new Set(rawIndexes.map((index) => index.trim()).filter(Boolean))];
326
+ if (indexes.length === 0) {
327
+ throw new Error("At least one Elasticsearch index pattern is required.");
328
+ }
329
+ return indexes;
330
+ }
331
+ function normalizeSourceId(value) {
332
+ const normalized = value
333
+ .trim()
334
+ .toLowerCase()
335
+ .replace(/\*+$/g, "")
336
+ .replace(/[^a-z0-9]+/g, "-")
337
+ .replace(/^-+|-+$/g, "");
338
+ return normalized || "logs";
339
+ }
340
+ function normalizeBaseUrl(rawValue) {
341
+ let parsed;
342
+ try {
343
+ parsed = new URL(rawValue.trim());
344
+ }
345
+ catch {
346
+ throw new Error("Kibana base URL must be a valid http or https URL.");
347
+ }
348
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
349
+ throw new Error("Kibana base URL must use http or https.");
350
+ }
351
+ if (parsed.username || parsed.password) {
352
+ throw new Error("Kibana base URL must not embed credentials.");
353
+ }
354
+ if (parsed.search || parsed.hash) {
355
+ throw new Error("Kibana base URL must not include a query string or fragment.");
356
+ }
357
+ if (KNOWN_ENDPOINT_PATHS.some((path) => parsed.pathname.includes(path))) {
358
+ throw new Error("Kibana base URL must not include endpoint paths such as /internal/search/es.");
359
+ }
360
+ return parsed.toString().replace(/\/+$/, "");
361
+ }
362
+ async function readOptionalFile(path) {
363
+ try {
364
+ return await readFile(path, "utf8");
365
+ }
366
+ catch (error) {
367
+ if (isMissingFileError(error))
368
+ return undefined;
369
+ throw error;
370
+ }
371
+ }
372
+ async function writeJsonAtomically(path, value) {
373
+ await writeTextAtomically(path, `${JSON.stringify(value, null, 2)}\n`);
374
+ }
375
+ async function writeTextAtomically(path, content) {
376
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
377
+ await mkdir(dirname(path), { recursive: true });
378
+ try {
379
+ await writeFile(temporaryPath, content, {
380
+ encoding: "utf8",
381
+ mode: 0o600,
382
+ });
383
+ await rename(temporaryPath, path);
384
+ }
385
+ finally {
386
+ await rm(temporaryPath, { force: true }).catch(() => { });
387
+ }
388
+ }
389
+ async function restoreFile(path, previousContent) {
390
+ if (previousContent === undefined) {
391
+ await rm(path, { force: true });
392
+ return;
393
+ }
394
+ await writeTextAtomically(path, previousContent);
395
+ }
396
+ async function restoreSecret(secretStore, profileId, previousSecret) {
397
+ if (previousSecret) {
398
+ await secretStore.save(profileId, previousSecret);
399
+ return;
400
+ }
401
+ await secretStore.delete(profileId);
402
+ }
403
+ function isMissingFileError(error) {
404
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
405
+ }
406
+ function formatIndex(index) {
407
+ if (!index)
408
+ return "unknown";
409
+ return Array.isArray(index) ? index.join(",") : index;
410
+ }
411
+ function asErrorMessage(error) {
412
+ return error instanceof Error ? error.message : String(error);
413
+ }
414
+ async function rollbackClientRegistration(registrar) {
415
+ let lastError;
416
+ for (let attempt = 0; attempt < 2; attempt += 1) {
417
+ try {
418
+ await registrar.rollback();
419
+ return;
420
+ }
421
+ catch (error) {
422
+ lastError = error;
423
+ }
424
+ }
425
+ throw lastError;
426
+ }