@havesomecode/kibana-mcp-server 1.3.1 → 1.4.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 +58 -487
- package/dist/src/bin/kibana-mcp-server.d.ts +2 -0
- package/dist/src/bin/run_cli.d.ts +4 -0
- package/dist/src/bin/run_cli.js +1 -1
- package/dist/src/commands/context.d.ts +18 -0
- package/dist/src/commands/serve.d.ts +4 -0
- package/dist/src/commands/serve.js +30 -6
- package/dist/src/commands/setup.d.ts +3 -0
- package/dist/src/commands/setup.js +2 -2
- package/dist/src/config/config_file.d.ts +59 -0
- package/dist/src/config/config_file.js +76 -0
- package/dist/src/config/environment.d.ts +100 -0
- package/dist/src/{config.js → config/environment.js} +26 -24
- package/dist/src/config/startup.d.ts +31 -0
- package/dist/src/config/startup.js +86 -0
- package/dist/src/kibana/client.d.ts +28 -0
- package/dist/src/kibana/schema_catalog.d.ts +11 -0
- package/dist/src/{schema_catalog.js → kibana/schema_catalog.js} +33 -11
- package/dist/src/kibana/source_catalog.d.ts +10 -0
- package/dist/src/mcp_entry.d.ts +2 -0
- package/dist/src/mcp_entry.js +2 -5
- package/dist/src/profiles/context.d.ts +4 -0
- package/dist/src/profiles/context.js +15 -0
- package/dist/src/profiles/names.d.ts +10 -0
- package/dist/src/profiles/names.js +35 -0
- package/dist/src/profiles/paths.d.ts +9 -0
- package/dist/src/profiles/runtime.d.ts +43 -0
- package/dist/src/profiles/runtime.js +150 -0
- package/dist/src/profiles/secret_store.d.ts +20 -0
- package/dist/src/profiles/state_lock.d.ts +13 -0
- package/dist/src/{state_lock.js → profiles/state_lock.js} +14 -5
- package/dist/src/profiles/store.d.ts +34 -0
- package/dist/src/{profile_store.js → profiles/store.js} +7 -5
- package/dist/src/query/compiler.d.ts +9 -0
- package/dist/src/query/cursor.d.ts +8 -0
- package/dist/src/query/normalize.d.ts +2 -0
- package/dist/src/server.d.ts +29 -0
- package/dist/src/server.js +40 -50
- package/dist/src/setup/bootstrap.d.ts +50 -0
- package/dist/src/{bootstrap.js → setup/bootstrap.js} +10 -36
- package/dist/src/setup/client_registration.d.ts +38 -0
- package/dist/src/setup/setup_flow.d.ts +26 -0
- package/dist/src/{setup_flow.js → setup/setup_flow.js} +6 -6
- package/dist/src/stores/catalog_store.d.ts +38 -0
- package/dist/src/stores/catalog_store.js +22 -0
- package/dist/src/stores/file.d.ts +5 -0
- package/dist/src/stores/file.js +48 -0
- package/dist/src/stores/ioredis.d.ts +21 -0
- package/dist/src/stores/ioredis.js +33 -0
- package/dist/src/stores/mongodb.d.ts +42 -0
- package/dist/src/stores/mongodb.js +57 -0
- package/dist/src/stores/postgres.d.ts +24 -0
- package/dist/src/stores/postgres.js +65 -0
- package/dist/src/stores/redis.d.ts +24 -0
- package/dist/src/stores/redis.js +31 -0
- package/dist/src/stores/shared.d.ts +25 -0
- package/dist/src/stores/shared.js +88 -0
- package/dist/src/stores/sqlite.d.ts +26 -0
- package/dist/src/stores/sqlite.js +63 -0
- package/dist/src/tools/configure.d.ts +59 -0
- package/dist/src/tools/configure.js +1 -1
- package/dist/src/tools/configure_index.d.ts +35 -0
- package/dist/src/tools/configure_index.js +75 -66
- package/dist/src/tools/describe_fields.d.ts +29 -0
- package/dist/src/tools/discover.d.ts +27 -0
- package/dist/src/tools/filter.d.ts +180 -0
- package/dist/src/tools/query.d.ts +181 -0
- package/dist/src/transports/http.d.ts +15 -0
- package/dist/src/transports/http.js +291 -0
- package/dist/src/transports/stdio.d.ts +1 -0
- package/dist/src/transports/stdio.js +46 -0
- package/dist/src/types.d.ts +254 -0
- package/dist/src/utils/cli_io.d.ts +11 -0
- package/dist/src/utils/cli_prompts.d.ts +9 -0
- package/dist/src/utils/cli_ui.d.ts +6 -0
- package/dist/src/utils/fs.d.ts +3 -0
- package/dist/src/utils/fs.js +30 -0
- package/package.json +63 -2
- package/plugins/kibana-log-investigation/.mcp.json +3 -2
- package/plugins/kibana-log-investigation/dist/LICENSE +21 -0
- package/plugins/kibana-log-investigation/dist/THIRD_PARTY_NOTICES.md +617 -0
- package/plugins/kibana-log-investigation/dist/mcp_entry.mjs +32046 -0
- package/plugins/kibana-log-investigation/dist/mcp_entry.mjs.LEGAL.txt +8 -0
- package/plugins/kibana-log-investigation/mcp-launcher.mjs +26 -0
- package/plugins/kibana-log-investigation/skills/ensure-node-runtime/SKILL.md +118 -0
- package/plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md +55 -0
- package/skills/kibana-log-investigation/SKILL.md +48 -5
- package/dist/src/mcp_runtime.js +0 -23
- /package/dist/src/{kibana_client.js → kibana/client.js} +0 -0
- /package/dist/src/{source_catalog.js → kibana/source_catalog.js} +0 -0
- /package/dist/src/{profile_paths.js → profiles/paths.js} +0 -0
- /package/dist/src/{secret_store.js → profiles/secret_store.js} +0 -0
- /package/dist/src/{client_registration.js → setup/client_registration.js} +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const profileRuntimeStorage = new AsyncLocalStorage();
|
|
3
|
+
export function runWithProfileRuntime(runtime, operation) {
|
|
4
|
+
return profileRuntimeStorage.run(runtime, operation);
|
|
5
|
+
}
|
|
6
|
+
export function getProfileRuntime() {
|
|
7
|
+
return profileRuntimeStorage.getStore();
|
|
8
|
+
}
|
|
9
|
+
export function requireProfileRuntime() {
|
|
10
|
+
const runtime = profileRuntimeStorage.getStore();
|
|
11
|
+
if (!runtime) {
|
|
12
|
+
throw new Error("No profile is bound to the current tool call.");
|
|
13
|
+
}
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes a profile name supplied by the transport identity or the startup configuration.
|
|
3
|
+
*/
|
|
4
|
+
export declare function normalizeProfileName(value: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Derives a filesystem-safe key from a profile name. Names that are not already a plain slug get a
|
|
7
|
+
* digest suffix. Literal names ending in that suffix are hashed too, reserving the generated
|
|
8
|
+
* namespace so that they cannot impersonate another identity's key.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toProfileKey(profileName: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const MAX_PROFILE_NAME_LENGTH = 256;
|
|
3
|
+
const MAX_PROFILE_SLUG_LENGTH = 64;
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes a profile name supplied by the transport identity or the startup configuration.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeProfileName(value) {
|
|
8
|
+
const name = value.trim();
|
|
9
|
+
if (!name) {
|
|
10
|
+
throw new Error("Profile name must not be empty.");
|
|
11
|
+
}
|
|
12
|
+
if (name.length > MAX_PROFILE_NAME_LENGTH) {
|
|
13
|
+
throw new Error(`Profile name must not exceed ${MAX_PROFILE_NAME_LENGTH} characters.`);
|
|
14
|
+
}
|
|
15
|
+
return name;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Derives a filesystem-safe key from a profile name. Names that are not already a plain slug get a
|
|
19
|
+
* digest suffix. Literal names ending in that suffix are hashed too, reserving the generated
|
|
20
|
+
* namespace so that they cannot impersonate another identity's key.
|
|
21
|
+
*/
|
|
22
|
+
export function toProfileKey(profileName) {
|
|
23
|
+
const name = normalizeProfileName(profileName);
|
|
24
|
+
const slug = name
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.slice(0, MAX_PROFILE_SLUG_LENGTH)
|
|
29
|
+
.replace(/-+$/g, "");
|
|
30
|
+
if (slug === name && !/-[a-f0-9]{12}$/.test(name)) {
|
|
31
|
+
return slug;
|
|
32
|
+
}
|
|
33
|
+
const digest = createHash("sha256").update(name).digest("hex").slice(0, 12);
|
|
34
|
+
return `${slug || "profile"}-${digest}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const MACHINE_STATE_DIR_ENV = "KIBANA_STATE_DIR";
|
|
2
|
+
export declare const APP_STATE_DIRECTORY_NAME = "kibana-mcp-server";
|
|
3
|
+
export interface ProfilePaths {
|
|
4
|
+
stateRoot: string;
|
|
5
|
+
profilesPath: string;
|
|
6
|
+
sourceCatalogsDir: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function resolveMachineStateRoot(envInput?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDirectory?: string): string;
|
|
9
|
+
export declare function resolveProfilePaths(envInput?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDirectory?: string): ProfilePaths;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { KibanaClient } from "../kibana/client.js";
|
|
2
|
+
import { SchemaCatalog } from "../kibana/schema_catalog.js";
|
|
3
|
+
import { SourceCatalog } from "../kibana/source_catalog.js";
|
|
4
|
+
import type { AppConfig, ResolvedAppConfig, SourceDefinition } from "../types.js";
|
|
5
|
+
export type ProfileConfigLoader = (profileName: string) => Promise<ResolvedAppConfig>;
|
|
6
|
+
export interface ProfileRuntime {
|
|
7
|
+
config: ResolvedAppConfig;
|
|
8
|
+
sourceCatalog: SourceCatalog;
|
|
9
|
+
kibanaClient: KibanaClient;
|
|
10
|
+
schemaCatalog: SchemaCatalog;
|
|
11
|
+
/** Content of the store-backed catalog the sources were last built from. */
|
|
12
|
+
catalogContent?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProfileRuntimeRegistryOptions {
|
|
15
|
+
defaultConfig?: ResolvedAppConfig;
|
|
16
|
+
defaultKibanaClient?: KibanaClient;
|
|
17
|
+
loadProfileConfig?: ProfileConfigLoader;
|
|
18
|
+
kibanaClientFactory?: (config: AppConfig["kibana"]) => KibanaClient;
|
|
19
|
+
maxProfiles?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class ProfileRuntimeRegistry {
|
|
22
|
+
private readonly kibanaClientFactory;
|
|
23
|
+
private readonly loadProfileConfig?;
|
|
24
|
+
private readonly maxProfiles;
|
|
25
|
+
private readonly runtimes;
|
|
26
|
+
private readonly inFlight;
|
|
27
|
+
private readonly clients;
|
|
28
|
+
private readonly refreshes;
|
|
29
|
+
private defaultRuntime?;
|
|
30
|
+
constructor(options?: ProfileRuntimeRegistryOptions);
|
|
31
|
+
get hasDefault(): boolean;
|
|
32
|
+
get supportsNamedProfiles(): boolean;
|
|
33
|
+
/** False only for a server started without any configuration, waiting for the legacy 'configure' tool. */
|
|
34
|
+
get isConfigured(): boolean;
|
|
35
|
+
getDefault(): ProfileRuntime | undefined;
|
|
36
|
+
setDefault(config: ResolvedAppConfig): ProfileRuntime;
|
|
37
|
+
resolve(profileName?: string): Promise<ProfileRuntime>;
|
|
38
|
+
private refresh;
|
|
39
|
+
replaceSources(runtime: ProfileRuntime, sources: SourceDefinition[], catalogContent?: string | undefined): void;
|
|
40
|
+
private createRuntime;
|
|
41
|
+
private clientFor;
|
|
42
|
+
private evictLeastRecentlyUsed;
|
|
43
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { sourceCatalogSchema } from "../config/environment.js";
|
|
3
|
+
import { KibanaClient } from "../kibana/client.js";
|
|
4
|
+
import { SchemaCatalog } from "../kibana/schema_catalog.js";
|
|
5
|
+
import { SourceCatalog } from "../kibana/source_catalog.js";
|
|
6
|
+
import { normalizeProfileName } from "./names.js";
|
|
7
|
+
const DEFAULT_MAX_PROFILES = 200;
|
|
8
|
+
export class ProfileRuntimeRegistry {
|
|
9
|
+
kibanaClientFactory;
|
|
10
|
+
loadProfileConfig;
|
|
11
|
+
maxProfiles;
|
|
12
|
+
runtimes = new Map();
|
|
13
|
+
inFlight = new Map();
|
|
14
|
+
clients = new Map();
|
|
15
|
+
refreshes = new WeakMap();
|
|
16
|
+
defaultRuntime;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.kibanaClientFactory =
|
|
19
|
+
options.kibanaClientFactory ?? ((config) => new KibanaClient(config));
|
|
20
|
+
this.loadProfileConfig = options.loadProfileConfig;
|
|
21
|
+
this.maxProfiles = options.maxProfiles ?? DEFAULT_MAX_PROFILES;
|
|
22
|
+
if (options.defaultConfig) {
|
|
23
|
+
this.defaultRuntime = this.createRuntime(options.defaultConfig, options.defaultKibanaClient);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
get hasDefault() {
|
|
27
|
+
return this.defaultRuntime !== undefined;
|
|
28
|
+
}
|
|
29
|
+
get supportsNamedProfiles() {
|
|
30
|
+
return this.loadProfileConfig !== undefined;
|
|
31
|
+
}
|
|
32
|
+
/** False only for a server started without any configuration, waiting for the legacy 'configure' tool. */
|
|
33
|
+
get isConfigured() {
|
|
34
|
+
return this.hasDefault || this.supportsNamedProfiles;
|
|
35
|
+
}
|
|
36
|
+
getDefault() {
|
|
37
|
+
return this.defaultRuntime;
|
|
38
|
+
}
|
|
39
|
+
setDefault(config) {
|
|
40
|
+
this.defaultRuntime = this.createRuntime(config);
|
|
41
|
+
return this.defaultRuntime;
|
|
42
|
+
}
|
|
43
|
+
async resolve(profileName) {
|
|
44
|
+
if (profileName === undefined) {
|
|
45
|
+
if (this.defaultRuntime) {
|
|
46
|
+
return this.refresh(this.defaultRuntime);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(this.loadProfileConfig
|
|
49
|
+
? "No profile was supplied for this call and no default profile is configured."
|
|
50
|
+
: "Server is not configured. Call the 'configure' tool first.");
|
|
51
|
+
}
|
|
52
|
+
// Names are compared exactly: two identities differing only by case are two profiles, each
|
|
53
|
+
// with its own catalog.
|
|
54
|
+
const key = normalizeProfileName(profileName);
|
|
55
|
+
if (this.defaultRuntime?.config.profileName === key) {
|
|
56
|
+
return this.refresh(this.defaultRuntime);
|
|
57
|
+
}
|
|
58
|
+
if (!this.loadProfileConfig) {
|
|
59
|
+
throw new Error("Profile selection is not available on this server.");
|
|
60
|
+
}
|
|
61
|
+
const cached = this.runtimes.get(key);
|
|
62
|
+
if (cached) {
|
|
63
|
+
// Refresh recency for the least-recently-used eviction.
|
|
64
|
+
this.runtimes.delete(key);
|
|
65
|
+
this.runtimes.set(key, cached);
|
|
66
|
+
return this.refresh(cached);
|
|
67
|
+
}
|
|
68
|
+
const pending = this.inFlight.get(key);
|
|
69
|
+
if (pending) {
|
|
70
|
+
return this.refresh(await pending);
|
|
71
|
+
}
|
|
72
|
+
const loading = this.loadProfileConfig(key).then((config) => {
|
|
73
|
+
const runtime = this.createRuntime(config);
|
|
74
|
+
this.runtimes.set(key, runtime);
|
|
75
|
+
this.evictLeastRecentlyUsed();
|
|
76
|
+
return runtime;
|
|
77
|
+
});
|
|
78
|
+
this.inFlight.set(key, loading);
|
|
79
|
+
try {
|
|
80
|
+
return await this.refresh(await loading);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
this.inFlight.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Store-backed catalogs are re-read on every call, so that every process sharing the store
|
|
87
|
+
// serves the same sources; the parsed catalog and schema cache are kept while it is unchanged.
|
|
88
|
+
async refresh(runtime) {
|
|
89
|
+
const catalog = runtime.config.catalog;
|
|
90
|
+
if (!catalog) {
|
|
91
|
+
return runtime;
|
|
92
|
+
}
|
|
93
|
+
const refresh = this.refreshes.get(runtime) ?? { started: 0, applied: 0 };
|
|
94
|
+
this.refreshes.set(runtime, refresh);
|
|
95
|
+
const generation = ++refresh.started;
|
|
96
|
+
const content = await catalog.read();
|
|
97
|
+
// Only an applied newer read or configure_index replacement supersedes this snapshot.
|
|
98
|
+
if (this.refreshes.get(runtime) !== refresh || generation < refresh.applied) {
|
|
99
|
+
return runtime;
|
|
100
|
+
}
|
|
101
|
+
if (content !== runtime.catalogContent) {
|
|
102
|
+
this.replaceSources(runtime, content === undefined
|
|
103
|
+
? []
|
|
104
|
+
: sourceCatalogSchema.parse(JSON.parse(content)).sources, content);
|
|
105
|
+
// A refresh retains its generation; explicit replacements fence all pending reads.
|
|
106
|
+
this.refreshes.set(runtime, refresh);
|
|
107
|
+
}
|
|
108
|
+
refresh.applied = generation;
|
|
109
|
+
return runtime;
|
|
110
|
+
}
|
|
111
|
+
replaceSources(runtime, sources, catalogContent = runtime.catalogContent) {
|
|
112
|
+
this.refreshes.delete(runtime);
|
|
113
|
+
runtime.config = { ...runtime.config, sources };
|
|
114
|
+
runtime.catalogContent = catalogContent;
|
|
115
|
+
runtime.sourceCatalog = new SourceCatalog(sources);
|
|
116
|
+
runtime.schemaCatalog = new SchemaCatalog(runtime.kibanaClient);
|
|
117
|
+
}
|
|
118
|
+
createRuntime(config, kibanaClient) {
|
|
119
|
+
const client = kibanaClient ?? this.clientFor(config.kibana);
|
|
120
|
+
return {
|
|
121
|
+
config,
|
|
122
|
+
sourceCatalog: new SourceCatalog(config.sources),
|
|
123
|
+
kibanaClient: client,
|
|
124
|
+
schemaCatalog: new SchemaCatalog(client),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Profiles that share one connection share one client, hence one backend concurrency budget.
|
|
128
|
+
clientFor(config) {
|
|
129
|
+
// Hashed so that the map does not keep credentials alive as keys.
|
|
130
|
+
const key = createHash("sha256")
|
|
131
|
+
.update(JSON.stringify([config.baseUrl, config.username, config.password, config.timeoutMs]))
|
|
132
|
+
.digest("hex");
|
|
133
|
+
const existing = this.clients.get(key);
|
|
134
|
+
if (existing) {
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
const client = this.kibanaClientFactory(config);
|
|
138
|
+
this.clients.set(key, client);
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
evictLeastRecentlyUsed() {
|
|
142
|
+
while (this.runtimes.size > this.maxProfiles) {
|
|
143
|
+
const oldest = this.runtimes.keys().next().value;
|
|
144
|
+
if (oldest === undefined) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.runtimes.delete(oldest);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SavedSecret } from "../types.js";
|
|
2
|
+
export declare class SecretStoreError extends Error {
|
|
3
|
+
readonly code: "UNAVAILABLE" | "NOT_FOUND" | "OPERATION_FAILED";
|
|
4
|
+
constructor(message: string, code: "UNAVAILABLE" | "NOT_FOUND" | "OPERATION_FAILED");
|
|
5
|
+
}
|
|
6
|
+
export interface SecretStore {
|
|
7
|
+
load(profileId: string): Promise<SavedSecret>;
|
|
8
|
+
save(profileId: string, secret: SavedSecret): Promise<void>;
|
|
9
|
+
delete(profileId: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
interface CommandResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
}
|
|
16
|
+
type CommandRunner = (command: string, args: string[], options?: {
|
|
17
|
+
stdin?: string;
|
|
18
|
+
}) => Promise<CommandResult>;
|
|
19
|
+
export declare function createSecretStore(platform?: NodeJS.Platform, runner?: CommandRunner, serviceName?: string): SecretStore;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface StateLockOptions {
|
|
2
|
+
signal?: AbortSignal;
|
|
3
|
+
/** Override the directory name for a catalog-specific lock in the same parent directory. */
|
|
4
|
+
lockName?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
staleMs?: number;
|
|
7
|
+
retryMs?: number;
|
|
8
|
+
reclaimGraceMs?: number;
|
|
9
|
+
releaseGraceMs?: number;
|
|
10
|
+
releaseTimeoutMs?: number;
|
|
11
|
+
heartbeatMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function acquireStateLock(stateRoot: string, options?: StateLockOptions): Promise<() => Promise<void>>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, open, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
5
6
|
const DEFAULT_STALE_MS = 5 * 60 * 1000;
|
|
6
7
|
const DEFAULT_RETRY_MS = 50;
|
|
7
8
|
const DEFAULT_RECLAIM_GRACE_MS = 25;
|
|
8
9
|
const LOCK_DIRECTORY_NAME = ".state.lock";
|
|
9
10
|
export async function acquireStateLock(stateRoot, options = {}) {
|
|
11
|
+
options.signal?.throwIfAborted();
|
|
10
12
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
11
13
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
12
14
|
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
@@ -14,10 +16,11 @@ export async function acquireStateLock(stateRoot, options = {}) {
|
|
|
14
16
|
const releaseGraceMs = options.releaseGraceMs ?? 0;
|
|
15
17
|
const releaseTimeoutMs = options.releaseTimeoutMs ?? Math.max(1000, reclaimGraceMs * 4);
|
|
16
18
|
const startedAt = Date.now();
|
|
17
|
-
const
|
|
19
|
+
const lockName = options.lockName ?? LOCK_DIRECTORY_NAME;
|
|
20
|
+
const lockPath = join(stateRoot, lockName);
|
|
18
21
|
const ownerPath = join(lockPath, "owner.json");
|
|
19
22
|
const token = randomUUID();
|
|
20
|
-
const pendingPath = join(stateRoot, `${
|
|
23
|
+
const pendingPath = join(stateRoot, `${lockName}.${token}.pending`);
|
|
21
24
|
const pendingOwnerPath = join(pendingPath, "owner.json");
|
|
22
25
|
const owner = {
|
|
23
26
|
token,
|
|
@@ -32,6 +35,7 @@ export async function acquireStateLock(stateRoot, options = {}) {
|
|
|
32
35
|
mode: 0o600,
|
|
33
36
|
});
|
|
34
37
|
while (true) {
|
|
38
|
+
options.signal?.throwIfAborted();
|
|
35
39
|
try {
|
|
36
40
|
await rename(pendingPath, lockPath);
|
|
37
41
|
}
|
|
@@ -41,7 +45,7 @@ export async function acquireStateLock(stateRoot, options = {}) {
|
|
|
41
45
|
if (!(await lockExists(lockPath))) {
|
|
42
46
|
if (Date.now() - startedAt >= timeoutMs)
|
|
43
47
|
throw error;
|
|
44
|
-
await
|
|
48
|
+
await delay(retryMs, undefined, { signal: options.signal });
|
|
45
49
|
continue;
|
|
46
50
|
}
|
|
47
51
|
if (await tryReclaimStaleLock(lockPath, ownerPath, staleMs, token, reclaimGraceMs)) {
|
|
@@ -50,7 +54,7 @@ export async function acquireStateLock(stateRoot, options = {}) {
|
|
|
50
54
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
51
55
|
throw new Error(`Timed out waiting for another Kibana MCP setup process to release '${lockPath}'.`);
|
|
52
56
|
}
|
|
53
|
-
await
|
|
57
|
+
await delay(retryMs, undefined, { signal: options.signal });
|
|
54
58
|
continue;
|
|
55
59
|
}
|
|
56
60
|
const heartbeatMs = options.heartbeatMs ?? Math.min(30000, Math.max(1000, Math.floor(staleMs / 3)));
|
|
@@ -62,13 +66,18 @@ export async function acquireStateLock(stateRoot, options = {}) {
|
|
|
62
66
|
.catch(() => { });
|
|
63
67
|
}, heartbeatMs);
|
|
64
68
|
heartbeat.unref();
|
|
65
|
-
|
|
69
|
+
const release = async () => {
|
|
66
70
|
clearInterval(heartbeat);
|
|
67
71
|
await heartbeatInFlight;
|
|
68
72
|
// The critical section has already committed or rolled back. Release is best effort;
|
|
69
73
|
// lease recovery handles any remaining lock without changing that outcome.
|
|
70
74
|
await releaseOwnedLock(lockPath, ownerPath, token, staleMs, retryMs, reclaimGraceMs, releaseGraceMs, releaseTimeoutMs).catch(() => { });
|
|
71
75
|
};
|
|
76
|
+
if (options.signal?.aborted) {
|
|
77
|
+
await release();
|
|
78
|
+
options.signal.throwIfAborted();
|
|
79
|
+
}
|
|
80
|
+
return release;
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
catch (error) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import type { SavedProfile, SavedProfileStoreState } from "../types.js";
|
|
3
|
+
import { type ProfilePaths } from "./paths.js";
|
|
4
|
+
export interface UpsertProfileInput {
|
|
5
|
+
id?: string;
|
|
6
|
+
name: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
sourceCatalogPath: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class ProfileStore {
|
|
12
|
+
private readonly paths;
|
|
13
|
+
private readonly io;
|
|
14
|
+
constructor(paths?: ProfilePaths, io?: {
|
|
15
|
+
readFile?: typeof readFile;
|
|
16
|
+
writeFile?: typeof writeFile;
|
|
17
|
+
mkdir?: typeof mkdir;
|
|
18
|
+
});
|
|
19
|
+
load(): Promise<SavedProfileStoreState>;
|
|
20
|
+
listProfiles(): Promise<SavedProfile[]>;
|
|
21
|
+
getDefaultProfile(): Promise<SavedProfile | null>;
|
|
22
|
+
findProfile(query: string, options?: {
|
|
23
|
+
exactName?: boolean;
|
|
24
|
+
}): Promise<SavedProfile | null>;
|
|
25
|
+
upsertProfile(input: UpsertProfileInput, options?: {
|
|
26
|
+
makeDefault?: boolean;
|
|
27
|
+
now?: Date;
|
|
28
|
+
}): Promise<SavedProfile>;
|
|
29
|
+
setDefaultProfile(profileId: string): Promise<void>;
|
|
30
|
+
replaceState(state: SavedProfileStoreState): Promise<void>;
|
|
31
|
+
private save;
|
|
32
|
+
}
|
|
33
|
+
export declare function normalizeProfileId(name: string): string;
|
|
34
|
+
export declare function deriveProfileId(name: string, existingIds: string[]): string;
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { resolveProfilePaths } from "./
|
|
5
|
+
import { resolveProfilePaths } from "./paths.js";
|
|
6
6
|
const PROFILE_STORE_VERSION = 1;
|
|
7
7
|
const savedProfileSchema = z.object({
|
|
8
8
|
id: z.string().min(1),
|
|
@@ -52,14 +52,16 @@ export class ProfileStore {
|
|
|
52
52
|
}
|
|
53
53
|
return state.profiles.find((profile) => profile.id === state.defaultProfileId) ?? null;
|
|
54
54
|
}
|
|
55
|
-
async findProfile(query) {
|
|
56
|
-
const normalizedQuery = query.trim().toLowerCase();
|
|
55
|
+
async findProfile(query, options = {}) {
|
|
56
|
+
const normalizedQuery = options.exactName ? query.trim() : query.trim().toLowerCase();
|
|
57
57
|
if (!normalizedQuery) {
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
60
|
const state = await this.load();
|
|
61
|
-
return (state.profiles.find((profile) =>
|
|
62
|
-
profile.name
|
|
61
|
+
return (state.profiles.find((profile) => options.exactName
|
|
62
|
+
? profile.name === normalizedQuery
|
|
63
|
+
: profile.id.toLowerCase() === normalizedQuery ||
|
|
64
|
+
profile.name.toLowerCase() === normalizedQuery) ?? null);
|
|
63
65
|
}
|
|
64
66
|
async upsertProfile(input, options = {}) {
|
|
65
67
|
const state = await this.load();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { QueryPlan, QueryRequest, SourceDefinition, SourceFieldDescriptor } from "../types.js";
|
|
2
|
+
interface CompileQueryOptions {
|
|
3
|
+
resolveFieldAliases?: boolean;
|
|
4
|
+
resolvePreferredExactFields?: boolean;
|
|
5
|
+
sourceSchemas?: Map<string, SourceFieldDescriptor[]>;
|
|
6
|
+
sourceSchemaErrors?: Map<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export declare function compileQueryPlan(request: QueryRequest, sources: SourceDefinition[], options?: CompileQueryOptions): QueryPlan;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface QueryCursorPayload {
|
|
2
|
+
source_id: string;
|
|
3
|
+
sort: "asc" | "desc";
|
|
4
|
+
sort_by: string;
|
|
5
|
+
values: unknown[];
|
|
6
|
+
}
|
|
7
|
+
export declare function encodeQueryCursor(payload: QueryCursorPayload): string;
|
|
8
|
+
export declare function decodeQueryCursor(cursor: string): QueryCursorPayload;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { type KibanaClient } from "./kibana/client.js";
|
|
3
|
+
import { ProfileRuntimeRegistry } from "./profiles/runtime.js";
|
|
4
|
+
import { executeConfigure } from "./tools/configure.js";
|
|
5
|
+
import { executeConfigureIndex } from "./tools/configure_index.js";
|
|
6
|
+
import { executeDescribeFields } from "./tools/describe_fields.js";
|
|
7
|
+
import { executeDiscover } from "./tools/discover.js";
|
|
8
|
+
import { executeFilter } from "./tools/filter.js";
|
|
9
|
+
import { executeQuery } from "./tools/query.js";
|
|
10
|
+
import type { AppConfig } from "./types.js";
|
|
11
|
+
import type { ResolvedAppConfig } from "./types.js";
|
|
12
|
+
export interface Application {
|
|
13
|
+
server: McpServer;
|
|
14
|
+
handlers: {
|
|
15
|
+
configure: (input: unknown) => Promise<Awaited<ReturnType<typeof executeConfigure>>["result"]>;
|
|
16
|
+
configure_index: (input: unknown, callerSignal?: AbortSignal) => Promise<Awaited<ReturnType<typeof executeConfigureIndex>>["result"]>;
|
|
17
|
+
describe_fields: (input: unknown, callerSignal?: AbortSignal) => Promise<Awaited<ReturnType<typeof executeDescribeFields>>>;
|
|
18
|
+
discover: (input: unknown) => Promise<ReturnType<typeof executeDiscover>>;
|
|
19
|
+
filter: (input: unknown, callerSignal?: AbortSignal) => ReturnType<typeof executeFilter>;
|
|
20
|
+
query: (input: unknown, callerSignal?: AbortSignal) => ReturnType<typeof executeQuery>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare function createApplication(initialConfig?: ResolvedAppConfig, dependencies?: {
|
|
24
|
+
kibanaClient?: KibanaClient;
|
|
25
|
+
kibanaClientFactory?: (config: AppConfig["kibana"]) => KibanaClient;
|
|
26
|
+
configureIndexFn?: typeof executeConfigureIndex;
|
|
27
|
+
profileRegistry?: ProfileRuntimeRegistry;
|
|
28
|
+
requestProfile?: string;
|
|
29
|
+
}): Application;
|
package/dist/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { KibanaRequestError } from "./kibana/client.js";
|
|
3
|
+
import { requireProfileRuntime, runWithProfileRuntime } from "./profiles/context.js";
|
|
4
|
+
import { ProfileRuntimeRegistry } from "./profiles/runtime.js";
|
|
5
5
|
import { configureInputSchema, configureOutputSchema, createConfigureCallToolResult, executeConfigure, } from "./tools/configure.js";
|
|
6
6
|
import { configureIndexInputSchema, configureIndexOutputSchema, createConfigureIndexCallToolResult, executeConfigureIndex, } from "./tools/configure_index.js";
|
|
7
7
|
import { createDescribeFieldsCallToolResult, describeFieldsInputSchema, describeFieldsOutputSchema, executeDescribeFields, } from "./tools/describe_fields.js";
|
|
@@ -37,19 +37,19 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
37
37
|
name: "kibana-log-investigation",
|
|
38
38
|
version: "0.1.0",
|
|
39
39
|
});
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
const registry = dependencies?.profileRegistry ??
|
|
41
|
+
new ProfileRuntimeRegistry({
|
|
42
|
+
defaultConfig: initialConfig,
|
|
43
|
+
defaultKibanaClient: dependencies?.kibanaClient,
|
|
44
|
+
kibanaClientFactory: dependencies?.kibanaClientFactory,
|
|
45
|
+
});
|
|
46
|
+
// The profile is resolved once per tool call, then read back through the async context.
|
|
47
|
+
async function withProfile(operation) {
|
|
48
|
+
const runtime = await registry.resolve(dependencies?.requestProfile);
|
|
49
|
+
return runWithProfileRuntime(runtime, () => operation(runtime));
|
|
50
|
+
}
|
|
48
51
|
async function withKibanaToolDeadline(callerSignal, operation) {
|
|
49
|
-
const timeoutMs =
|
|
50
|
-
if (timeoutMs === undefined) {
|
|
51
|
-
throw new Error("Server is not configured. Call the 'configure' tool first.");
|
|
52
|
-
}
|
|
52
|
+
const timeoutMs = requireProfileRuntime().config.kibana.timeoutMs;
|
|
53
53
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
54
54
|
const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
55
55
|
try {
|
|
@@ -63,55 +63,45 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
63
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
64
|
}
|
|
65
65
|
}
|
|
66
|
-
function requireConfigured() {
|
|
67
|
-
if (!sourceCatalog || !kibanaClient || !schemaCatalog) {
|
|
68
|
-
throw new Error("Server is not configured. Call the 'configure' tool first.");
|
|
69
|
-
}
|
|
70
|
-
return { sourceCatalog, kibanaClient, schemaCatalog };
|
|
71
|
-
}
|
|
72
66
|
const configureHandler = async (input) => {
|
|
67
|
+
const current = registry.getDefault()?.config;
|
|
73
68
|
const { nextConfig, result } = await executeConfigure(configureInputSchema.parse(input), {
|
|
74
|
-
sourceCatalogPath:
|
|
69
|
+
sourceCatalogPath: current?.sourceCatalogPath,
|
|
75
70
|
});
|
|
76
|
-
|
|
71
|
+
registry.setDefault({
|
|
77
72
|
...nextConfig,
|
|
78
73
|
sourceCatalogPath: result.source_catalog_path,
|
|
79
|
-
profileName:
|
|
80
|
-
sourceCatalogOrigin:
|
|
81
|
-
};
|
|
82
|
-
sourceCatalog = new SourceCatalog(nextConfig.sources);
|
|
83
|
-
kibanaClient = kibanaClientFactory(nextConfig.kibana);
|
|
84
|
-
schemaCatalog = new SchemaCatalog(kibanaClient);
|
|
74
|
+
profileName: current?.profileName,
|
|
75
|
+
sourceCatalogOrigin: current?.sourceCatalogOrigin,
|
|
76
|
+
});
|
|
85
77
|
return result;
|
|
86
78
|
};
|
|
87
79
|
const configureIndexHandler = async (input, callerSignal) => {
|
|
88
|
-
if (!
|
|
80
|
+
if (!registry.isConfigured) {
|
|
89
81
|
throw new Error("No saved Kibana connection is available. Run connection bootstrap first.");
|
|
90
82
|
}
|
|
91
|
-
const configured = activeConfig;
|
|
92
|
-
const client = kibanaClient;
|
|
93
83
|
const configureIndexFn = dependencies?.configureIndexFn ?? executeConfigureIndex;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
84
|
+
return withProfile((runtime) => withKibanaToolDeadline(callerSignal, async (signal) => {
|
|
85
|
+
const { sources, result, catalogContent } = await configureIndexFn(configureIndexInputSchema.parse(input), {
|
|
86
|
+
config: runtime.config,
|
|
87
|
+
kibanaClient: runtime.kibanaClient,
|
|
88
|
+
callerSignal: signal,
|
|
89
|
+
});
|
|
90
|
+
registry.replaceSources(runtime, sources, catalogContent);
|
|
91
|
+
return result;
|
|
98
92
|
}));
|
|
99
|
-
activeConfig = { ...activeConfig, sources };
|
|
100
|
-
sourceCatalog = new SourceCatalog(sources);
|
|
101
|
-
schemaCatalog = new SchemaCatalog(client);
|
|
102
|
-
return result;
|
|
103
93
|
};
|
|
104
|
-
const describeFieldsHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeDescribeFields(describeFieldsInputSchema.parse(input),
|
|
105
|
-
const discoverHandler = (input) => executeDiscover(discoverInputSchema.parse(input),
|
|
106
|
-
const filterHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeFilter(filterInputSchema.parse(input),
|
|
107
|
-
schemaCatalog:
|
|
94
|
+
const describeFieldsHandler = (input, callerSignal) => withProfile((runtime) => withKibanaToolDeadline(callerSignal, (signal) => executeDescribeFields(describeFieldsInputSchema.parse(input), runtime.sourceCatalog, runtime.schemaCatalog, signal)));
|
|
95
|
+
const discoverHandler = (input) => withProfile((runtime) => executeDiscover(discoverInputSchema.parse(input), runtime.sourceCatalog));
|
|
96
|
+
const filterHandler = (input, callerSignal) => withProfile((runtime) => withKibanaToolDeadline(callerSignal, (signal) => executeFilter(filterInputSchema.parse(input), runtime.sourceCatalog, runtime.kibanaClient, {
|
|
97
|
+
schemaCatalog: runtime.schemaCatalog,
|
|
108
98
|
callerSignal: signal,
|
|
109
|
-
}));
|
|
110
|
-
const queryHandler = (input, callerSignal) => withKibanaToolDeadline(callerSignal, (signal) => executeQuery(queryInputSchema.parse(input),
|
|
111
|
-
schemaCatalog:
|
|
99
|
+
})));
|
|
100
|
+
const queryHandler = (input, callerSignal) => withProfile((runtime) => withKibanaToolDeadline(callerSignal, (signal) => executeQuery(queryInputSchema.parse(input), runtime.sourceCatalog, runtime.kibanaClient, {
|
|
101
|
+
schemaCatalog: runtime.schemaCatalog,
|
|
112
102
|
callerSignal: signal,
|
|
113
|
-
}));
|
|
114
|
-
if (!
|
|
103
|
+
})));
|
|
104
|
+
if (!registry.isConfigured) {
|
|
115
105
|
server.registerTool("configure", {
|
|
116
106
|
description: "Advanced legacy setup for a server started without a saved profile. Replaces the Kibana connection and full source catalog.",
|
|
117
107
|
inputSchema: configureInputSchema,
|
|
@@ -134,7 +124,7 @@ export function createApplication(initialConfig, dependencies) {
|
|
|
134
124
|
description: "List configured logical log sources and field hints for investigation work.",
|
|
135
125
|
inputSchema: discoverInputSchema,
|
|
136
126
|
outputSchema: discoverOutputSchema,
|
|
137
|
-
}, async (input) => createDiscoverCallToolResult(discoverHandler(input)));
|
|
127
|
+
}, async (input) => createDiscoverCallToolResult(await discoverHandler(input)));
|
|
138
128
|
server.registerTool("filter", {
|
|
139
129
|
description: "Run an exact-field filter when the field name is already known, bypassing alias resolution.",
|
|
140
130
|
inputSchema: filterInputSchema,
|