@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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
5
|
+
const DEFAULT_STALE_MS = 5 * 60 * 1000;
|
|
6
|
+
const DEFAULT_RETRY_MS = 50;
|
|
7
|
+
const DEFAULT_RECLAIM_GRACE_MS = 25;
|
|
8
|
+
const LOCK_DIRECTORY_NAME = ".state.lock";
|
|
9
|
+
export async function acquireStateLock(stateRoot, options = {}) {
|
|
10
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
11
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
12
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
13
|
+
const reclaimGraceMs = options.reclaimGraceMs ?? DEFAULT_RECLAIM_GRACE_MS;
|
|
14
|
+
const releaseGraceMs = options.releaseGraceMs ?? 0;
|
|
15
|
+
const releaseTimeoutMs = options.releaseTimeoutMs ?? Math.max(1000, reclaimGraceMs * 4);
|
|
16
|
+
const startedAt = Date.now();
|
|
17
|
+
const lockPath = join(stateRoot, LOCK_DIRECTORY_NAME);
|
|
18
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
19
|
+
const token = randomUUID();
|
|
20
|
+
const pendingPath = join(stateRoot, `${LOCK_DIRECTORY_NAME}.${token}.pending`);
|
|
21
|
+
const pendingOwnerPath = join(pendingPath, "owner.json");
|
|
22
|
+
const owner = {
|
|
23
|
+
token,
|
|
24
|
+
pid: process.pid,
|
|
25
|
+
createdAt: new Date().toISOString(),
|
|
26
|
+
};
|
|
27
|
+
await mkdir(stateRoot, { recursive: true });
|
|
28
|
+
await mkdir(pendingPath);
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(pendingOwnerPath, `${JSON.stringify(owner)}\n`, {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
});
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
await rename(pendingPath, lockPath);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!isLockOccupiedError(error))
|
|
40
|
+
throw error;
|
|
41
|
+
if (!(await lockExists(lockPath))) {
|
|
42
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
43
|
+
throw error;
|
|
44
|
+
await sleep(retryMs);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (await tryReclaimStaleLock(lockPath, ownerPath, staleMs, token, reclaimGraceMs)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
51
|
+
throw new Error(`Timed out waiting for another Kibana MCP setup process to release '${lockPath}'.`);
|
|
52
|
+
}
|
|
53
|
+
await sleep(retryMs);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const heartbeatMs = options.heartbeatMs ?? Math.min(30000, Math.max(1000, Math.floor(staleMs / 3)));
|
|
57
|
+
const heartbeatClaimStaleMs = Math.max(1000, Math.min(staleMs, 30000));
|
|
58
|
+
let heartbeatInFlight = Promise.resolve();
|
|
59
|
+
const heartbeat = setInterval(() => {
|
|
60
|
+
heartbeatInFlight = heartbeatInFlight
|
|
61
|
+
.then(() => refreshOwnerHeartbeat(lockPath, ownerPath, token, heartbeatClaimStaleMs))
|
|
62
|
+
.catch(() => { });
|
|
63
|
+
}, heartbeatMs);
|
|
64
|
+
heartbeat.unref();
|
|
65
|
+
return async () => {
|
|
66
|
+
clearInterval(heartbeat);
|
|
67
|
+
await heartbeatInFlight;
|
|
68
|
+
// The critical section has already committed or rolled back. Release is best effort;
|
|
69
|
+
// lease recovery handles any remaining lock without changing that outcome.
|
|
70
|
+
await releaseOwnedLock(lockPath, ownerPath, token, staleMs, retryMs, reclaimGraceMs, releaseGraceMs, releaseTimeoutMs).catch(() => { });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await rm(pendingPath, { recursive: true, force: true }).catch(() => { });
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function refreshOwnerHeartbeat(lockPath, ownerPath, ownerToken, claimStaleMs) {
|
|
80
|
+
const claimPath = join(lockPath, ".reclaim");
|
|
81
|
+
const claimToken = `${ownerToken}.heartbeat.${randomUUID()}`;
|
|
82
|
+
const releaseClaim = await acquireTransitionLease(claimPath, claimToken, claimStaleMs);
|
|
83
|
+
if (!releaseClaim)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
if ((await readOwner(ownerPath))?.token !== ownerToken)
|
|
87
|
+
return;
|
|
88
|
+
const now = new Date();
|
|
89
|
+
await utimes(ownerPath, now, now);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await releaseClaim();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function acquireTransitionLease(claimPath, token, staleMs) {
|
|
96
|
+
if (!(await acquireReclaimClaim(claimPath, token, staleMs)))
|
|
97
|
+
return undefined;
|
|
98
|
+
const claimHandle = await open(claimPath, "r+");
|
|
99
|
+
const heartbeatMs = Math.max(100, Math.floor(staleMs / 3));
|
|
100
|
+
const heartbeat = setInterval(() => {
|
|
101
|
+
const now = new Date();
|
|
102
|
+
void claimHandle.utimes(now, now).catch(() => { });
|
|
103
|
+
}, heartbeatMs);
|
|
104
|
+
heartbeat.unref();
|
|
105
|
+
return async (removeMarker = true) => {
|
|
106
|
+
clearInterval(heartbeat);
|
|
107
|
+
await claimHandle.close().catch(() => { });
|
|
108
|
+
if (removeMarker && (await claimIsOwned(claimPath, token))) {
|
|
109
|
+
await rm(claimPath, { force: true }).catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function releaseOwnedLock(lockPath, ownerPath, token, staleMs, retryMs, reclaimGraceMs, releaseGraceMs, releaseTimeoutMs) {
|
|
114
|
+
const releasePath = join(lockPath, `.release.${token}`);
|
|
115
|
+
const releaseDeadline = Date.now() + releaseTimeoutMs;
|
|
116
|
+
const claimPath = join(lockPath, ".reclaim");
|
|
117
|
+
const claimStaleMs = Math.max(1000, Math.min(staleMs, 30000), reclaimGraceMs * 4);
|
|
118
|
+
let releaseClaim;
|
|
119
|
+
while (!releaseClaim) {
|
|
120
|
+
releaseClaim = await acquireTransitionLease(claimPath, token, claimStaleMs);
|
|
121
|
+
if (releaseClaim)
|
|
122
|
+
break;
|
|
123
|
+
if (Date.now() >= releaseDeadline)
|
|
124
|
+
return;
|
|
125
|
+
await sleep(Math.max(1, Math.min(10, retryMs)));
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const ownerBeforeRelease = await readOwner(ownerPath);
|
|
129
|
+
const partialRelease = await readOwner(releasePath);
|
|
130
|
+
if (ownerBeforeRelease?.token !== token && partialRelease?.token !== token)
|
|
131
|
+
return;
|
|
132
|
+
while (true) {
|
|
133
|
+
try {
|
|
134
|
+
await rename(ownerPath, releasePath);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!isMissingFileError(error))
|
|
138
|
+
throw error;
|
|
139
|
+
const existingReleaseOwner = await readOwner(releasePath);
|
|
140
|
+
if (existingReleaseOwner?.token === token)
|
|
141
|
+
break;
|
|
142
|
+
if (!(await hasLockTransitionMarker(lockPath)))
|
|
143
|
+
return;
|
|
144
|
+
if (Date.now() >= releaseDeadline)
|
|
145
|
+
return;
|
|
146
|
+
await sleep(Math.max(1, Math.min(10, retryMs)));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
const claimedOwner = await readOwner(releasePath);
|
|
152
|
+
if (claimedOwner?.token !== token) {
|
|
153
|
+
await rename(releasePath, ownerPath).catch(() => { });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (releaseGraceMs > 0)
|
|
157
|
+
await sleep(releaseGraceMs);
|
|
158
|
+
if ((await readOwner(releasePath))?.token !== token)
|
|
159
|
+
return;
|
|
160
|
+
if (!(await claimIsOwned(claimPath, token)))
|
|
161
|
+
return;
|
|
162
|
+
await releaseClaim(false);
|
|
163
|
+
await removeLockGeneration(lockPath, token, "released");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await releaseClaim();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function tryReclaimStaleLock(lockPath, ownerPath, staleMs, claimantToken, reclaimGraceMs) {
|
|
170
|
+
const claimPath = join(lockPath, ".reclaim");
|
|
171
|
+
const claimStaleMs = Math.max(1000, Math.min(staleMs, 30000), reclaimGraceMs * 4);
|
|
172
|
+
const observedOwner = await readOwner(ownerPath);
|
|
173
|
+
const ownerStat = await stat(ownerPath).catch(() => undefined);
|
|
174
|
+
if (ownerStat) {
|
|
175
|
+
if (Date.now() - ownerStat.mtimeMs <= staleMs)
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const claimStat = await stat(claimPath).catch(() => undefined);
|
|
180
|
+
if (claimStat && Date.now() - claimStat.mtimeMs <= claimStaleMs)
|
|
181
|
+
return false;
|
|
182
|
+
const claimedOwnerStat = await newestTransitionOwnerStat(lockPath);
|
|
183
|
+
if (claimedOwnerStat && Date.now() - claimedOwnerStat.mtimeMs <= staleMs)
|
|
184
|
+
return false;
|
|
185
|
+
if (!claimStat && !claimedOwnerStat) {
|
|
186
|
+
const lockStat = await stat(lockPath).catch(() => undefined);
|
|
187
|
+
if (!lockStat || Date.now() - lockStat.mtimeMs <= staleMs)
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!(await acquireReclaimClaim(claimPath, claimantToken, claimStaleMs)))
|
|
192
|
+
return false;
|
|
193
|
+
const claimHandle = await open(claimPath, "r+");
|
|
194
|
+
const claimHeartbeatMs = Math.max(100, Math.floor(claimStaleMs / 3));
|
|
195
|
+
const claimHeartbeat = setInterval(() => {
|
|
196
|
+
const now = new Date();
|
|
197
|
+
void claimHandle.utimes(now, now).catch(() => { });
|
|
198
|
+
}, claimHeartbeatMs);
|
|
199
|
+
claimHeartbeat.unref();
|
|
200
|
+
try {
|
|
201
|
+
if (reclaimGraceMs > 0)
|
|
202
|
+
await sleep(reclaimGraceMs);
|
|
203
|
+
if (!(await claimIsOwned(claimPath, claimantToken)))
|
|
204
|
+
return false;
|
|
205
|
+
const claimedOwnerPath = join(lockPath, `.owner.${claimantToken}`);
|
|
206
|
+
try {
|
|
207
|
+
await rename(ownerPath, claimedOwnerPath);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (isMissingFileError(error)) {
|
|
211
|
+
if (!(await claimIsOwned(claimPath, claimantToken)) ||
|
|
212
|
+
observedOwner ||
|
|
213
|
+
(await readOwner(ownerPath))) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
clearInterval(claimHeartbeat);
|
|
217
|
+
await claimHandle.close().catch(() => { });
|
|
218
|
+
await removeLockGeneration(lockPath, claimantToken, "reclaimed");
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
if (reclaimGraceMs > 0)
|
|
224
|
+
await sleep(reclaimGraceMs);
|
|
225
|
+
if (!(await claimIsOwned(claimPath, claimantToken))) {
|
|
226
|
+
await rename(claimedOwnerPath, ownerPath).catch(() => { });
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const claimedOwner = await readOwner(claimedOwnerPath);
|
|
230
|
+
const claimedStat = await stat(claimedOwnerPath).catch(() => undefined);
|
|
231
|
+
const ownerUnchanged = claimedOwner?.token === observedOwner?.token;
|
|
232
|
+
const stillStale = Boolean(claimedStat && Date.now() - claimedStat.mtimeMs > staleMs);
|
|
233
|
+
if (ownerUnchanged && stillStale) {
|
|
234
|
+
clearInterval(claimHeartbeat);
|
|
235
|
+
await claimHandle.close().catch(() => { });
|
|
236
|
+
await removeLockGeneration(lockPath, claimantToken, "reclaimed");
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
await rename(claimedOwnerPath, ownerPath).catch(() => { });
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
clearInterval(claimHeartbeat);
|
|
244
|
+
await claimHandle.close().catch(() => { });
|
|
245
|
+
if (await claimIsOwned(claimPath, claimantToken)) {
|
|
246
|
+
await rm(claimPath, { force: true }).catch(() => { });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function removeLockGeneration(lockPath, token, reason) {
|
|
251
|
+
const disposalPath = `${lockPath}.${token}.${reason}`;
|
|
252
|
+
try {
|
|
253
|
+
await rename(lockPath, disposalPath);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (isMissingFileError(error))
|
|
257
|
+
return;
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
await rm(disposalPath, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
async function newestTransitionOwnerStat(lockPath) {
|
|
263
|
+
let names;
|
|
264
|
+
try {
|
|
265
|
+
names = (await readdir(lockPath)).filter((name) => name.startsWith(".owner.") || name.startsWith(".release."));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const stats = await Promise.all(names.map(async (name) => await stat(join(lockPath, name)).catch(() => undefined)));
|
|
271
|
+
return stats.reduce((newest, candidate) => {
|
|
272
|
+
if (!candidate)
|
|
273
|
+
return newest;
|
|
274
|
+
return !newest || candidate.mtimeMs > newest.mtimeMs ? candidate : newest;
|
|
275
|
+
}, undefined);
|
|
276
|
+
}
|
|
277
|
+
async function acquireReclaimClaim(claimPath, claimantToken, staleMs) {
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(claimPath, claimantToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (isMissingFileError(error))
|
|
284
|
+
return false;
|
|
285
|
+
if (!isAlreadyExistsError(error))
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
const observedStat = await stat(claimPath).catch(() => undefined);
|
|
289
|
+
if (!observedStat || Date.now() - observedStat.mtimeMs <= staleMs)
|
|
290
|
+
return false;
|
|
291
|
+
const quarantinePath = `${claimPath}.${claimantToken}.stale`;
|
|
292
|
+
try {
|
|
293
|
+
await rename(claimPath, quarantinePath);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (isMissingFileError(error))
|
|
297
|
+
return false;
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
const claimedStat = await stat(quarantinePath).catch(() => undefined);
|
|
301
|
+
if (claimedStat && Date.now() - claimedStat.mtimeMs <= staleMs) {
|
|
302
|
+
await rename(quarantinePath, claimPath).catch(async () => {
|
|
303
|
+
await rm(quarantinePath, { force: true }).catch(() => { });
|
|
304
|
+
});
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
await rm(quarantinePath, { force: true });
|
|
308
|
+
try {
|
|
309
|
+
await writeFile(claimPath, claimantToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (isAlreadyExistsError(error) || isMissingFileError(error))
|
|
314
|
+
return false;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function claimIsOwned(claimPath, claimantToken) {
|
|
319
|
+
try {
|
|
320
|
+
return (await readFile(claimPath, "utf8")) === claimantToken;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function readOwner(path) {
|
|
327
|
+
try {
|
|
328
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
329
|
+
return typeof parsed.token === "string" ? parsed : undefined;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function hasLockTransitionMarker(lockPath) {
|
|
336
|
+
try {
|
|
337
|
+
return (await readdir(lockPath)).some((name) => name === ".reclaim" ||
|
|
338
|
+
name.startsWith(".reclaim.") ||
|
|
339
|
+
name.startsWith(".owner.") ||
|
|
340
|
+
name.startsWith(".release."));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function lockExists(lockPath) {
|
|
347
|
+
try {
|
|
348
|
+
await stat(lockPath);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (isMissingFileError(error))
|
|
353
|
+
return false;
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function isLockOccupiedError(error) {
|
|
358
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
359
|
+
return false;
|
|
360
|
+
return ["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(String(error.code));
|
|
361
|
+
}
|
|
362
|
+
function isAlreadyExistsError(error) {
|
|
363
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
|
|
364
|
+
}
|
|
365
|
+
function isMissingFileError(error) {
|
|
366
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
367
|
+
}
|
|
368
|
+
async function sleep(milliseconds) {
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
370
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildGeneratedSource, chooseGeneratedSourceFields, createGeneratedCatalog, parseGeneratedCatalog, } from "../bootstrap.js";
|
|
4
|
+
import { persistSourceCatalog } from "../config.js";
|
|
5
|
+
import { resolveProfilePaths } from "../profile_paths.js";
|
|
6
|
+
import { acquireStateLock } from "../state_lock.js";
|
|
7
|
+
export const configureIndexInputSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
index: z.string().trim().min(1),
|
|
10
|
+
source_name: z.string().trim().min(1).optional(),
|
|
11
|
+
source_id: z.string().trim().min(1).optional(),
|
|
12
|
+
time_field: z.string().trim().min(1).optional(),
|
|
13
|
+
replace: z.boolean().default(false),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
export const configureIndexOutputSchema = z.object({
|
|
17
|
+
configured: z.literal(true),
|
|
18
|
+
persisted: z.literal(true),
|
|
19
|
+
source_catalog_path: z.string(),
|
|
20
|
+
source_count: z.number().int().positive(),
|
|
21
|
+
source_id: z.string(),
|
|
22
|
+
index: z.string(),
|
|
23
|
+
time_field: z.string(),
|
|
24
|
+
});
|
|
25
|
+
export async function executeConfigureIndex(inputValue, options) {
|
|
26
|
+
const input = configureIndexInputSchema.parse(inputValue);
|
|
27
|
+
const sourceCatalogPath = options.config.sourceCatalogPath;
|
|
28
|
+
if (!sourceCatalogPath) {
|
|
29
|
+
throw new Error("configure_index requires a persisted source catalog. Run connection bootstrap first.");
|
|
30
|
+
}
|
|
31
|
+
const stateRoot = options.stateRoot ?? resolveProfilePaths().stateRoot;
|
|
32
|
+
const releaseLock = await acquireStateLock(stateRoot);
|
|
33
|
+
try {
|
|
34
|
+
const existingContent = await readFile(sourceCatalogPath, "utf8");
|
|
35
|
+
const existingCatalog = parseGeneratedCatalog(existingContent);
|
|
36
|
+
if (!existingCatalog) {
|
|
37
|
+
throw new Error("configure_index can update only a managed generated catalog. Refusing to overwrite a hand-authored or modified catalog.");
|
|
38
|
+
}
|
|
39
|
+
const proposedSource = buildGeneratedSource([input.index], input.source_name, input.source_id);
|
|
40
|
+
const existingSource = existingCatalog.sources.find((source) => source.id === proposedSource.id);
|
|
41
|
+
if (existingSource &&
|
|
42
|
+
!input.replace &&
|
|
43
|
+
JSON.stringify(existingSource.backend.index) !== JSON.stringify(proposedSource.backend.index)) {
|
|
44
|
+
throw new Error(`Source id '${proposedSource.id}' already refers to a different index. Re-run with replace=true only after the user explicitly approves replacement.`);
|
|
45
|
+
}
|
|
46
|
+
const discoveredFields = await options.kibanaClient.describeFields(proposedSource, options.callerSignal);
|
|
47
|
+
if (discoveredFields.length === 0) {
|
|
48
|
+
throw new Error(`Explicit index pattern '${input.index}' resolved to no fields. Check the index and Kibana permissions.`);
|
|
49
|
+
}
|
|
50
|
+
const generatedFields = chooseGeneratedSourceFields(discoveredFields, input.time_field);
|
|
51
|
+
const verifiedSource = {
|
|
52
|
+
...proposedSource,
|
|
53
|
+
...generatedFields,
|
|
54
|
+
};
|
|
55
|
+
await options.kibanaClient.execute({
|
|
56
|
+
source: verifiedSource,
|
|
57
|
+
request: {
|
|
58
|
+
body: {
|
|
59
|
+
size: 1,
|
|
60
|
+
track_total_hits: false,
|
|
61
|
+
query: { match_all: {} },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
resolvedFilters: [],
|
|
65
|
+
resolvedNestedFilters: [],
|
|
66
|
+
resolvedSortBy: verifiedSource.timeField,
|
|
67
|
+
advisories: [],
|
|
68
|
+
}, options.callerSignal);
|
|
69
|
+
const sources = existingSource
|
|
70
|
+
? existingCatalog.sources.map((source) => source.id === verifiedSource.id ? verifiedSource : source)
|
|
71
|
+
: [...existingCatalog.sources, verifiedSource];
|
|
72
|
+
const generatedCatalog = createGeneratedCatalog(sources);
|
|
73
|
+
await persistSourceCatalog(sources, {
|
|
74
|
+
sourceCatalogPath,
|
|
75
|
+
catalog: generatedCatalog,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
sources,
|
|
79
|
+
result: {
|
|
80
|
+
configured: true,
|
|
81
|
+
persisted: true,
|
|
82
|
+
source_catalog_path: sourceCatalogPath,
|
|
83
|
+
source_count: sources.length,
|
|
84
|
+
source_id: verifiedSource.id,
|
|
85
|
+
index: input.index,
|
|
86
|
+
time_field: verifiedSource.timeField,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await releaseLock();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function createConfigureIndexCallToolResult(result) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: `Configured source '${result.source_id}' for the explicitly requested index '${result.index}' with time field '${result.time_field}'.`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
structuredContent: result,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -21,9 +21,9 @@ export const describeFieldsOutputSchema = z.object({
|
|
|
21
21
|
preferred_exact_field: z.string().optional(),
|
|
22
22
|
})),
|
|
23
23
|
});
|
|
24
|
-
export async function executeDescribeFields(input, sourceCatalog, schemaCatalog) {
|
|
24
|
+
export async function executeDescribeFields(input, sourceCatalog, schemaCatalog, callerSignal) {
|
|
25
25
|
const [source] = sourceCatalog.getRequiredSources([input.source_id]);
|
|
26
|
-
const fields = await schemaCatalog.getFields(source);
|
|
26
|
+
const fields = await schemaCatalog.getFields(source, callerSignal);
|
|
27
27
|
const filteredFields = schemaCatalog.filterFields(fields, input.query, input.limit);
|
|
28
28
|
return {
|
|
29
29
|
source_id: source.id,
|
|
@@ -4,6 +4,7 @@ export const discoverInputSchema = z.object({
|
|
|
4
4
|
limit: z.number().int().positive().max(100).default(20),
|
|
5
5
|
});
|
|
6
6
|
export const discoverOutputSchema = z.object({
|
|
7
|
+
configured_total: z.number().int().nonnegative(),
|
|
7
8
|
total: z.number().int().nonnegative(),
|
|
8
9
|
sources: z.array(z.object({
|
|
9
10
|
id: z.string(),
|
|
@@ -22,11 +23,15 @@ export const discoverOutputSchema = z.object({
|
|
|
22
23
|
export function executeDiscover(input, sourceCatalog) {
|
|
23
24
|
const sources = sourceCatalog.list(input.query, input.limit);
|
|
24
25
|
return {
|
|
26
|
+
configured_total: sourceCatalog.count(),
|
|
25
27
|
total: sources.length,
|
|
26
28
|
sources,
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
export function formatDiscoverResult(result) {
|
|
32
|
+
if (result.configured_total === 0) {
|
|
33
|
+
return "No log source is configured. Ask the user which Kibana index or index pattern they want to use, then call 'configure_index' with exactly that value.";
|
|
34
|
+
}
|
|
30
35
|
if (result.sources.length === 0) {
|
|
31
36
|
return "No sources matched the requested discovery query.";
|
|
32
37
|
}
|
package/dist/src/tools/filter.js
CHANGED
|
@@ -111,6 +111,7 @@ export async function executeFilter(input, sourceCatalog, kibanaClient, options)
|
|
|
111
111
|
resolveFieldAliases: false,
|
|
112
112
|
resolvePreferredExactFields: true,
|
|
113
113
|
schemaCatalog: options?.schemaCatalog,
|
|
114
|
+
callerSignal: options?.callerSignal,
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
export function formatFilterResult(result) {
|
package/dist/src/tools/query.js
CHANGED
|
@@ -234,9 +234,12 @@ export async function executeQuery(input, sourceCatalog, kibanaClient, options)
|
|
|
234
234
|
if (schemaCatalog && needsSchemaResolution) {
|
|
235
235
|
for (const source of sources) {
|
|
236
236
|
try {
|
|
237
|
-
sourceSchemas.set(source.id, await schemaCatalog.getFields(source));
|
|
237
|
+
sourceSchemas.set(source.id, await schemaCatalog.getFields(source, options?.callerSignal));
|
|
238
238
|
}
|
|
239
239
|
catch (error) {
|
|
240
|
+
if (options?.callerSignal?.aborted) {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
240
243
|
sourceSchemaErrors.set(source.id, error instanceof Error ? error.message : String(error));
|
|
241
244
|
}
|
|
242
245
|
}
|
|
@@ -246,7 +249,7 @@ export async function executeQuery(input, sourceCatalog, kibanaClient, options)
|
|
|
246
249
|
...(sourceSchemas.size > 0 ? { sourceSchemas } : {}),
|
|
247
250
|
...(sourceSchemaErrors.size > 0 ? { sourceSchemaErrors } : {}),
|
|
248
251
|
});
|
|
249
|
-
const executions = await kibanaClient.executeMany(plan.sourceQueries);
|
|
252
|
+
const executions = await kibanaClient.executeMany(plan.sourceQueries, options?.callerSignal);
|
|
250
253
|
return queryOutputSchema.parse(normalizeQueryResponse(plan, executions));
|
|
251
254
|
}
|
|
252
255
|
export function formatQueryResult(result) {
|
|
@@ -17,6 +17,7 @@ This matrix distinguishes guaranteed support from best-effort behavior. Each cla
|
|
|
17
17
|
| `elasticsearch_search` backend | Guaranteed | Verified in tests | Direct Elasticsearch-compatible search path. |
|
|
18
18
|
| Schema metadata endpoints | Best-effort | Known 404s in some deployments | `describe_fields` may fail if endpoints are blocked. |
|
|
19
19
|
| Nested query features | Best-effort | Depends on schema availability | Requires schema metadata for validation. |
|
|
20
|
-
|
|
|
20
|
+
| Connection-only package bootstrap | Guaranteed | Verified through CLI, rollback, packlist, and release-contract tests | Use `npx -y @havesomecode/kibana-mcp-server bootstrap --client codex`; configure an index only after the user names it. |
|
|
21
|
+
| Agent Skill installation | Guaranteed after publication | Repository discovery is verified with the `skills` CLI; skills.sh indexing follows the public branch | Install `kibana-log-investigation` from `Havesomecode/kibana-mcp-server`. |
|
|
21
22
|
|
|
22
23
|
Upgrade a row from best-effort to guaranteed only after verifying it in CI or in a reproducible environment test and documenting the evidence here.
|
|
@@ -9,12 +9,24 @@ updated: 2026-04-09
|
|
|
9
9
|
## Goals
|
|
10
10
|
|
|
11
11
|
- Make the MCP installable by AI agents without manual explanation.
|
|
12
|
-
-
|
|
12
|
+
- Make prompt-free package bootstrap and skills.sh discovery the guaranteed newcomer path.
|
|
13
|
+
- Preserve the repo-local Codex plugin workflow for contributors.
|
|
13
14
|
- Keep the public package path healthy and aligned with the repo-local workflow.
|
|
14
15
|
|
|
15
16
|
## Supported Distribution Paths
|
|
16
17
|
|
|
17
|
-
### 1.
|
|
18
|
+
### 1. Public package plus Agent Skill (guaranteed)
|
|
19
|
+
|
|
20
|
+
The newcomer path is:
|
|
21
|
+
|
|
22
|
+
1. Install `kibana-log-investigation` from `Havesomecode/kibana-mcp-server` with the `skills` CLI.
|
|
23
|
+
2. Run the package's prompt-free `bootstrap --client codex` command.
|
|
24
|
+
3. Let bootstrap verify only the Kibana connection, save an empty managed catalog, register through `codex mcp add`, and read the registration back.
|
|
25
|
+
4. In a fresh agent session, ask the user which exact index or pattern to configure before calling `configure_index`.
|
|
26
|
+
|
|
27
|
+
Bootstrap never lists, scans, infers, or configures indexes. Index validation begins only after the user explicitly supplies one.
|
|
28
|
+
|
|
29
|
+
### 2. Repo-local Codex plugin
|
|
18
30
|
|
|
19
31
|
This is the primary, always-supported path:
|
|
20
32
|
|
|
@@ -25,11 +37,11 @@ This is the primary, always-supported path:
|
|
|
25
37
|
|
|
26
38
|
This path is required for development and is the baseline for support.
|
|
27
39
|
|
|
28
|
-
###
|
|
40
|
+
### 3. Public package without the skill
|
|
29
41
|
|
|
30
42
|
The package is published for agent-friendly execution via:
|
|
31
43
|
|
|
32
|
-
- `npx -y @havesomecode/kibana-mcp-server`
|
|
44
|
+
- `npx -y @havesomecode/kibana-mcp-server bootstrap --client codex`
|
|
33
45
|
- MCP clients that invoke the published package binary instead of a repo-local build
|
|
34
46
|
|
|
35
47
|
This path depends on the following remaining true:
|
|
@@ -48,6 +60,7 @@ The release artifact must include:
|
|
|
48
60
|
|
|
49
61
|
- `dist/` runtime build output
|
|
50
62
|
- plugin metadata and MCP config
|
|
63
|
+
- `skills/kibana-log-investigation/SKILL.md`
|
|
51
64
|
- `README.md`, `LICENSE`
|
|
52
65
|
|
|
53
66
|
The release artifact must exclude:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@havesomecode/kibana-mcp-server",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Read-only MCP server for Kibana-backed log investigation and schema-aware querying",
|
|
6
6
|
"homepage": "https://havesomecode.github.io/kibana-mcp-server/",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dist/**",
|
|
22
22
|
"plugins/kibana-log-investigation/.mcp.json",
|
|
23
23
|
"plugins/kibana-log-investigation/.codex-plugin/plugin.json",
|
|
24
|
+
"skills/kibana-log-investigation/**",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE",
|
|
26
27
|
"config/sources.example.json",
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
"interface": {
|
|
16
16
|
"displayName": "Kibana Log Investigation",
|
|
17
17
|
"shortDescription": "Local Kibana log investigation MCP for Codex",
|
|
18
|
-
"longDescription": "
|
|
18
|
+
"longDescription": "Use the repo-local read-only Kibana investigation MCP in Codex. Run connection-only bootstrap to create an empty managed catalog without importing examples, inspecting indexes, or editing client configuration.",
|
|
19
19
|
"developerName": "Havesomecode",
|
|
20
20
|
"category": "Productivity",
|
|
21
|
-
"capabilities": ["Interactive", "Read"
|
|
21
|
+
"capabilities": ["Interactive", "Read"],
|
|
22
22
|
"websiteURL": "https://havesomecode.github.io/kibana-mcp-server/",
|
|
23
23
|
"privacyPolicyURL": "https://github.com/Havesomecode/kibana-mcp-server",
|
|
24
24
|
"termsOfServiceURL": "https://github.com/Havesomecode/kibana-mcp-server",
|
|
25
25
|
"defaultPrompt": [
|
|
26
|
-
"Install the Kibana Log Investigation plugin from this repo, bootstrap Node.js if needed, and
|
|
26
|
+
"Install the Kibana Log Investigation plugin from this repo, bootstrap Node.js if needed, and run connection-only bootstrap with --client none. Do not inspect indexes. After installation, ask me which exact index or pattern to configure before calling configure_index.",
|
|
27
27
|
"Use the Kibana Log Investigation plugin to inspect log sources and query a time window."
|
|
28
28
|
],
|
|
29
29
|
"brandColor": "#1F6FEB"
|