@havesomecode/kibana-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { Writable } from "node:stream";
3
+ import { startMcpServer } from "./mcp_runtime.js";
4
+ import { runSetupFlow } from "./setup_flow.js";
5
+ function renderHelp() {
6
+ return [
7
+ "Kibana Log Investigation",
8
+ "",
9
+ "Usage:",
10
+ " kibana-mcp-server [command]",
11
+ "",
12
+ "Commands:",
13
+ " setup Run guided machine setup",
14
+ " serve Start the stdio MCP server",
15
+ " help Show this help output",
16
+ ].join("\n");
17
+ }
18
+ export async function runCli(argv = process.argv.slice(2), io = {}, dependencies = {}) {
19
+ const stdout = io.stdout ?? ((text) => process.stdout.write(`${text}\n`));
20
+ const stderr = io.stderr ?? ((text) => process.stderr.write(`${text}\n`));
21
+ const stdin = io.stdin ?? process.stdin;
22
+ const stdoutStream = io.stdoutStream ?? process.stdout;
23
+ const [command, ...rest] = argv;
24
+ switch (command) {
25
+ case undefined:
26
+ case "setup": {
27
+ if (rest.length > 0) {
28
+ stderr(`Unknown arguments for setup: ${rest.join(" ")}`);
29
+ return 1;
30
+ }
31
+ const promptIo = await createPromptIo(stdout, {
32
+ stdin,
33
+ stdoutStream,
34
+ stdinIsTTY: io.stdinIsTTY,
35
+ });
36
+ try {
37
+ const result = await (dependencies.runSetupFlowFn ?? runSetupFlow)(promptIo);
38
+ stdout(`Saved ${result.profiles.length} environment${result.profiles.length === 1 ? "" : "s"}. Default environment: ${result.defaultProfileName}.`);
39
+ return 0;
40
+ }
41
+ finally {
42
+ await promptIo.close();
43
+ }
44
+ }
45
+ case "help":
46
+ case "--help":
47
+ case "-h": {
48
+ stdout(renderHelp());
49
+ return 0;
50
+ }
51
+ case "serve": {
52
+ if (rest.length > 0) {
53
+ stderr(`Unknown arguments for serve: ${rest.join(" ")}`);
54
+ return 1;
55
+ }
56
+ await (dependencies.startMcpServerFn ?? startMcpServer)();
57
+ return 0;
58
+ }
59
+ default: {
60
+ stderr(`Unknown command: ${command}`);
61
+ stderr(renderHelp());
62
+ return 1;
63
+ }
64
+ }
65
+ }
66
+ export async function createPromptIo(stdout, options = {}) {
67
+ const stdin = options.stdin ?? process.stdin;
68
+ const stdoutStream = options.stdoutStream ?? process.stdout;
69
+ const stdinIsTTY = options.stdinIsTTY ?? Boolean(stdin.isTTY);
70
+ if (!stdinIsTTY) {
71
+ return createQueuedPromptIo(stdout, await readQueuedAnswers(stdin));
72
+ }
73
+ const maskingOutput = new MaskingWritable(stdoutStream);
74
+ const readline = createInterface({
75
+ input: stdin,
76
+ output: maskingOutput,
77
+ });
78
+ return {
79
+ info(message) {
80
+ stdout(message);
81
+ },
82
+ async prompt(message, options = {}) {
83
+ const suffix = options.defaultValue ? ` [${options.defaultValue}]` : "";
84
+ if (options.secret) {
85
+ maskingOutput.muted = false;
86
+ stdoutStream.write(`${message}: `);
87
+ maskingOutput.muted = true;
88
+ const answer = await readline.question("");
89
+ maskingOutput.muted = false;
90
+ stdoutStream.write("\n");
91
+ return answer || options.defaultValue || "";
92
+ }
93
+ const answer = await readline.question(`${message}${suffix}: `);
94
+ return answer || options.defaultValue || "";
95
+ },
96
+ async confirm(message, defaultValue = false) {
97
+ const answer = await readline.question(`${message} ${defaultValue ? "[Y/n]" : "[y/N]"}: `);
98
+ const normalized = answer.trim().toLowerCase();
99
+ if (!normalized) {
100
+ return defaultValue;
101
+ }
102
+ return normalized === "y" || normalized === "yes";
103
+ },
104
+ async close() {
105
+ await readline.close();
106
+ },
107
+ };
108
+ }
109
+ function createQueuedPromptIo(stdout, answers) {
110
+ let answerIndex = 0;
111
+ function consumeAnswer(promptLabel) {
112
+ const answer = answers[answerIndex];
113
+ answerIndex += 1;
114
+ if (answer === undefined) {
115
+ throw new Error(`No stdin answer available for prompt '${promptLabel}'.`);
116
+ }
117
+ return answer;
118
+ }
119
+ return {
120
+ info(message) {
121
+ stdout(message);
122
+ },
123
+ async prompt(message, options = {}) {
124
+ const suffix = options.defaultValue ? ` [${options.defaultValue}]` : "";
125
+ stdout(`${message}${suffix}:`);
126
+ const answer = consumeAnswer(message);
127
+ return answer || options.defaultValue || "";
128
+ },
129
+ async confirm(message, defaultValue = false) {
130
+ stdout(`${message} ${defaultValue ? "[Y/n]" : "[y/N]"}:`);
131
+ const normalized = consumeAnswer(message).trim().toLowerCase();
132
+ if (!normalized) {
133
+ return defaultValue;
134
+ }
135
+ return normalized === "y" || normalized === "yes";
136
+ },
137
+ async close() { },
138
+ };
139
+ }
140
+ async function readQueuedAnswers(stdin) {
141
+ let rawInput = "";
142
+ for await (const chunk of stdin) {
143
+ rawInput += typeof chunk === "string" ? chunk : chunk.toString("utf8");
144
+ }
145
+ return rawInput.replaceAll("\r\n", "\n").split("\n");
146
+ }
147
+ class MaskingWritable extends Writable {
148
+ target;
149
+ muted = false;
150
+ constructor(target) {
151
+ super();
152
+ this.target = target;
153
+ }
154
+ _write(chunk, encoding, callback) {
155
+ if (!this.muted) {
156
+ this.target.write(chunk, encoding);
157
+ }
158
+ callback();
159
+ }
160
+ }
@@ -0,0 +1,204 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { ProfileStore } from "./profile_store.js";
5
+ import { SecretStoreError, createSecretStore } from "./secret_store.js";
6
+ export const DEFAULT_RUNTIME_SOURCE_CATALOG_PATH = "config/sources.runtime.json";
7
+ export const DEFAULT_STATIC_SOURCE_CATALOG_PATH = "config/sources.json";
8
+ export const PROFILE_NAME_ENV = "KIBANA_PROFILE";
9
+ export class NoSavedProfileError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "NoSavedProfileError";
13
+ }
14
+ }
15
+ const bootstrapEnvSchema = z.object({
16
+ KIBANA_BASE_URL: z.url(),
17
+ KIBANA_USERNAME: z.string().min(1),
18
+ KIBANA_PASSWORD: z.string().min(1),
19
+ KIBANA_TIMEOUT_MS: z
20
+ .string()
21
+ .optional()
22
+ .transform((value) => {
23
+ if (!value) {
24
+ return undefined;
25
+ }
26
+ const numericValue = Number(value);
27
+ if (!Number.isFinite(numericValue) || numericValue <= 0) {
28
+ throw new Error("KIBANA_TIMEOUT_MS must be a positive number");
29
+ }
30
+ return numericValue;
31
+ }),
32
+ KIBANA_SOURCE_CATALOG_PATH: z.string().optional(),
33
+ });
34
+ const startupOverrideSchema = z.object({
35
+ KIBANA_BASE_URL: z.string().optional(),
36
+ KIBANA_USERNAME: z.string().optional(),
37
+ KIBANA_PASSWORD: z.string().optional(),
38
+ KIBANA_TIMEOUT_MS: z.string().optional(),
39
+ KIBANA_SOURCE_CATALOG_PATH: z.string().optional(),
40
+ [PROFILE_NAME_ENV]: z.string().optional(),
41
+ });
42
+ const fieldHintSchema = z.object({
43
+ name: z.string().min(1),
44
+ type: z.string().optional(),
45
+ description: z.string().optional(),
46
+ aliases: z.array(z.string().min(1)).default([]),
47
+ });
48
+ const sourceBackendSchema = z.object({
49
+ kind: z.enum(["elasticsearch_search", "kibana_internal_search_es"]),
50
+ path: z.string().min(1),
51
+ index: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
52
+ });
53
+ const sourceSchemaBackendSchema = z.object({
54
+ kind: z.enum([
55
+ "elasticsearch_field_caps",
56
+ "kibana_data_views_fields",
57
+ "kibana_index_patterns_fields",
58
+ ]),
59
+ path: z.string().min(1).optional(),
60
+ index: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
61
+ });
62
+ export const sourceDefinitionSchema = z.object({
63
+ id: z.string().min(1),
64
+ name: z.string().min(1),
65
+ description: z.string().optional(),
66
+ tags: z.array(z.string().min(1)).default([]),
67
+ timeField: z.string().min(1),
68
+ backend: sourceBackendSchema,
69
+ schema: sourceSchemaBackendSchema.optional(),
70
+ fieldHints: z.array(fieldHintSchema).default([]),
71
+ defaultTextFields: z.array(z.string().min(1)).default([]),
72
+ evidenceFields: z.array(z.string().min(1)).default([]),
73
+ });
74
+ export const sourceCatalogSchema = z.object({
75
+ sources: z.array(sourceDefinitionSchema).min(1),
76
+ });
77
+ export function resolveSourceCatalogPath(envInput = process.env) {
78
+ const explicitPath = envInput.KIBANA_SOURCE_CATALOG_PATH?.trim();
79
+ if (explicitPath) {
80
+ return explicitPath;
81
+ }
82
+ return DEFAULT_RUNTIME_SOURCE_CATALOG_PATH;
83
+ }
84
+ export function parseAppConfig(envInput, sourceCatalogInput) {
85
+ const env = bootstrapEnvSchema.parse(envInput);
86
+ const sourceCatalog = sourceCatalogSchema.parse(sourceCatalogInput);
87
+ return {
88
+ kibana: {
89
+ baseUrl: env.KIBANA_BASE_URL.replace(/\/+$/, ""),
90
+ username: env.KIBANA_USERNAME,
91
+ password: env.KIBANA_PASSWORD,
92
+ timeoutMs: env.KIBANA_TIMEOUT_MS ?? 10000,
93
+ },
94
+ sources: sourceCatalog.sources,
95
+ };
96
+ }
97
+ export async function loadConfigFromEnvironment(envInput = process.env, dependencies = {}) {
98
+ const overrides = startupOverrideSchema.parse(envInput);
99
+ const readFileImpl = dependencies.readFile ?? readFile;
100
+ if (hasPartialBootstrapConfig(overrides)) {
101
+ throw new Error("Incomplete Kibana bootstrap environment. Provide KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD together.");
102
+ }
103
+ if (hasBootstrapConfig(overrides)) {
104
+ const preferredPath = resolveSourceCatalogPath(envInput);
105
+ const sourceCatalog = await loadSourceCatalog(preferredPath === DEFAULT_RUNTIME_SOURCE_CATALOG_PATH
106
+ ? [preferredPath, DEFAULT_STATIC_SOURCE_CATALOG_PATH]
107
+ : [preferredPath], readFileImpl);
108
+ const config = parseAppConfig(overrides, sourceCatalog);
109
+ return {
110
+ ...config,
111
+ sourceCatalogPath: preferredPath,
112
+ sourceCatalogOrigin: sourceCatalog.loadedFrom === DEFAULT_STATIC_SOURCE_CATALOG_PATH
113
+ ? "static_default"
114
+ : preferredPath === DEFAULT_RUNTIME_SOURCE_CATALOG_PATH
115
+ ? "runtime_default"
116
+ : "environment",
117
+ };
118
+ }
119
+ const profileStore = dependencies.profileStore ?? new ProfileStore();
120
+ const secretStore = dependencies.secretStore ?? createSecretStore();
121
+ const requestedProfileName = overrides[PROFILE_NAME_ENV]?.trim();
122
+ const profile = requestedProfileName
123
+ ? await profileStore.findProfile(requestedProfileName)
124
+ : await profileStore.getDefaultProfile();
125
+ if (!profile) {
126
+ if (!requestedProfileName) {
127
+ throw new NoSavedProfileError("No saved Kibana profile is available.");
128
+ }
129
+ throw new Error(requestedProfileName
130
+ ? `Saved Kibana profile '${requestedProfileName}' was not found.`
131
+ : "No saved Kibana profile is available.");
132
+ }
133
+ const savedSecret = await secretStore.load(profile.id).catch((error) => {
134
+ if (error instanceof SecretStoreError && error.code === "NOT_FOUND") {
135
+ throw new Error(`Saved Kibana credentials for profile '${profile.name}' are missing. Run setup again on this machine.`);
136
+ }
137
+ throw error;
138
+ });
139
+ const sourceCatalogPath = overrides.KIBANA_SOURCE_CATALOG_PATH?.trim() || profile.sourceCatalogPath;
140
+ const sourceCatalog = await loadSourceCatalog([sourceCatalogPath], readFileImpl);
141
+ return {
142
+ kibana: {
143
+ baseUrl: profile.baseUrl.replace(/\/+$/, ""),
144
+ username: savedSecret.username,
145
+ password: savedSecret.password,
146
+ timeoutMs: parseTimeoutOverride(overrides.KIBANA_TIMEOUT_MS) ?? profile.timeoutMs,
147
+ },
148
+ sources: sourceCatalogSchema.parse(sourceCatalog.value).sources,
149
+ profileName: profile.name,
150
+ sourceCatalogPath,
151
+ sourceCatalogOrigin: overrides.KIBANA_SOURCE_CATALOG_PATH?.trim() ? "environment" : "profile",
152
+ };
153
+ }
154
+ export async function persistSourceCatalog(sources, options = {}) {
155
+ const sourceCatalogPath = options.sourceCatalogPath ?? resolveSourceCatalogPath(options.envInput);
156
+ await mkdir(dirname(sourceCatalogPath), { recursive: true });
157
+ await writeFile(sourceCatalogPath, `${JSON.stringify({ sources }, null, 2)}\n`, "utf8");
158
+ return sourceCatalogPath;
159
+ }
160
+ function hasBootstrapConfig(envInput) {
161
+ return Boolean(envInput.KIBANA_BASE_URL?.trim() &&
162
+ envInput.KIBANA_USERNAME?.trim() &&
163
+ envInput.KIBANA_PASSWORD?.trim());
164
+ }
165
+ function hasPartialBootstrapConfig(envInput) {
166
+ const bootstrapValues = [
167
+ envInput.KIBANA_BASE_URL?.trim(),
168
+ envInput.KIBANA_USERNAME?.trim(),
169
+ envInput.KIBANA_PASSWORD?.trim(),
170
+ ].filter(Boolean);
171
+ return bootstrapValues.length > 0 && bootstrapValues.length < 3;
172
+ }
173
+ function parseTimeoutOverride(value) {
174
+ if (!value) {
175
+ return undefined;
176
+ }
177
+ const numericValue = Number(value);
178
+ if (!Number.isFinite(numericValue) || numericValue <= 0) {
179
+ throw new Error("KIBANA_TIMEOUT_MS must be a positive number");
180
+ }
181
+ return numericValue;
182
+ }
183
+ async function loadSourceCatalog(candidatePaths, readFileImpl) {
184
+ let sourceCatalogRaw;
185
+ let loadedFrom = candidatePaths[0];
186
+ let lastError;
187
+ for (const candidatePath of candidatePaths) {
188
+ try {
189
+ sourceCatalogRaw = await readFileImpl(candidatePath, "utf8");
190
+ loadedFrom = candidatePath;
191
+ break;
192
+ }
193
+ catch (error) {
194
+ lastError = error;
195
+ }
196
+ }
197
+ if (!sourceCatalogRaw) {
198
+ throw lastError instanceof Error ? lastError : new Error("Source catalog could not be loaded");
199
+ }
200
+ return {
201
+ loadedFrom,
202
+ value: JSON.parse(sourceCatalogRaw),
203
+ };
204
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ runCli()
4
+ .then((exitCode) => {
5
+ process.exitCode = exitCode;
6
+ })
7
+ .catch((error) => {
8
+ console.error(error);
9
+ process.exitCode = 1;
10
+ });