@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
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
|
-
-
|
|
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:
|
|
31
|
-
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
+
```
|
|
83
|
+
|
|
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`.
|
|
55
85
|
|
|
56
|
-
|
|
86
|
+
## Use In Codex
|
|
57
87
|
|
|
58
|
-
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -142,6 +177,10 @@ Environment variables:
|
|
|
142
177
|
- `KIBANA_TIMEOUT_MS` optional, default `10000`
|
|
143
178
|
- `KIBANA_SOURCE_CATALOG_PATH` optional, default `config/sources.runtime.json`
|
|
144
179
|
|
|
180
|
+
`KIBANA_TIMEOUT_MS` bounds each Kibana HTTP request end to end: it includes waiting for a request slot, connecting, and reading the response body. The server allows at most eight concurrent Kibana requests and 32 queued requests per configured client; excess work fails immediately with `KIBANA_OVERLOADED`. Cancelled MCP tool calls abort their queued or in-flight backend work.
|
|
181
|
+
|
|
182
|
+
Backend failures use stable diagnostic codes such as `KIBANA_TIMEOUT`, `KIBANA_CANCELLED`, `KIBANA_OVERLOADED`, `KIBANA_AUTHENTICATION`, `KIBANA_DNS`, `KIBANA_TLS`, `KIBANA_CONNECTION`, and `KIBANA_RESPONSE`. Failed backend tool results also expose the diagnostic fields in `_meta.kibana_request_error`.
|
|
183
|
+
|
|
145
184
|
`KIBANA_BASE_URL` is the Kibana base prefix that the server joins with each configured backend or schema path.
|
|
146
185
|
|
|
147
186
|
Use:
|
|
@@ -265,6 +304,21 @@ npm run dev
|
|
|
265
304
|
|
|
266
305
|
## Tool Shapes
|
|
267
306
|
|
|
307
|
+
### `configure_index`
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
Input:
|
|
312
|
+
|
|
313
|
+
```json
|
|
314
|
+
{
|
|
315
|
+
"index": "app-logs-*",
|
|
316
|
+
"source_name": "Application logs"
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
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.
|
|
321
|
+
|
|
268
322
|
### `configure`
|
|
269
323
|
|
|
270
324
|
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
|
+
}
|