@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,441 @@
1
+ const DEFAULT_META_FIELDS = ["_source", "_id", "_index", "_score"];
2
+ class KibanaHttpError extends Error {
3
+ status;
4
+ responseBody;
5
+ constructor(status, responseBody) {
6
+ super(`Kibana request failed with status ${status}: ${responseBody}`);
7
+ this.status = status;
8
+ this.responseBody = responseBody;
9
+ this.name = "KibanaHttpError";
10
+ }
11
+ }
12
+ function joinUrl(baseUrl, path) {
13
+ return path.startsWith("http://") || path.startsWith("https://")
14
+ ? path
15
+ : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
16
+ }
17
+ function encodeBasicAuth(username, password) {
18
+ return Buffer.from(`${username}:${password}`).toString("base64");
19
+ }
20
+ function unwrapSearchResponse(raw) {
21
+ if (!raw || typeof raw !== "object") {
22
+ throw new Error("Unexpected Kibana search response shape");
23
+ }
24
+ const record = raw;
25
+ if (record.rawResponse && typeof record.rawResponse === "object") {
26
+ return record.rawResponse;
27
+ }
28
+ if (record.response && typeof record.response === "object") {
29
+ const responseRecord = record.response;
30
+ if (responseRecord.rawResponse && typeof responseRecord.rawResponse === "object") {
31
+ return responseRecord.rawResponse;
32
+ }
33
+ if (responseRecord.hits || responseRecord.aggregations || responseRecord.aggs) {
34
+ return responseRecord;
35
+ }
36
+ }
37
+ if (record.hits || record.aggregations || record.aggs) {
38
+ return record;
39
+ }
40
+ throw new Error("Unexpected Kibana search response shape");
41
+ }
42
+ function asRecord(value) {
43
+ return value && typeof value === "object" ? value : {};
44
+ }
45
+ function asArray(value) {
46
+ return Array.isArray(value) ? value : [];
47
+ }
48
+ function normalizeFieldCapsResponse(raw) {
49
+ const fields = asRecord(asRecord(raw).fields);
50
+ return Object.entries(fields)
51
+ .map(([name, typeEntries]) => {
52
+ const typeRecord = asRecord(typeEntries);
53
+ const firstEntry = Object.values(typeRecord)[0];
54
+ const fieldCaps = asRecord(firstEntry);
55
+ return {
56
+ name,
57
+ type: typeof fieldCaps.type === "string" ? fieldCaps.type : Object.keys(typeRecord)[0],
58
+ searchable: typeof fieldCaps.searchable === "boolean" ? fieldCaps.searchable : undefined,
59
+ aggregatable: typeof fieldCaps.aggregatable === "boolean" ? fieldCaps.aggregatable : undefined,
60
+ subfields: [],
61
+ };
62
+ })
63
+ .sort((left, right) => left.name.localeCompare(right.name));
64
+ }
65
+ function normalizeKibanaFieldsResponse(raw) {
66
+ const rawFields = Array.isArray(raw) ? raw : asArray(asRecord(raw).fields);
67
+ return rawFields
68
+ .map((field) => {
69
+ const fieldRecord = asRecord(field);
70
+ const name = fieldRecord.name;
71
+ if (typeof name !== "string") {
72
+ return null;
73
+ }
74
+ const subType = asRecord(fieldRecord.subType);
75
+ const nested = asRecord(subType.nested);
76
+ const multi = asRecord(subType.multi);
77
+ return {
78
+ name,
79
+ type: typeof fieldRecord.type === "string" ? fieldRecord.type : undefined,
80
+ searchable: typeof fieldRecord.searchable === "boolean" ? fieldRecord.searchable : undefined,
81
+ aggregatable: typeof fieldRecord.aggregatable === "boolean" ? fieldRecord.aggregatable : undefined,
82
+ nested_path: typeof nested.path === "string" ? nested.path : undefined,
83
+ multi_field_parent: typeof multi.parent === "string" ? multi.parent : undefined,
84
+ subfields: [],
85
+ };
86
+ })
87
+ .filter((field) => field !== null)
88
+ .sort((left, right) => left.name.localeCompare(right.name));
89
+ }
90
+ function resolveSchemaIndexPatterns(source, schemaBackend) {
91
+ const schemaIndex = schemaBackend.index ?? source.backend.index;
92
+ if (!schemaIndex) {
93
+ throw new Error(`Source '${source.id}' does not declare schema index patterns. Configure source.schema.index or backend.index.`);
94
+ }
95
+ return Array.isArray(schemaIndex) ? schemaIndex.join(",") : schemaIndex;
96
+ }
97
+ function dedupePaths(paths) {
98
+ return [...new Set(paths.filter((path) => Boolean(path?.trim())))];
99
+ }
100
+ function resolveSchemaPaths(source, schemaBackend, patterns) {
101
+ if (schemaBackend.kind === "elasticsearch_field_caps") {
102
+ return [schemaBackend.path ?? `/${patterns}/_field_caps`];
103
+ }
104
+ if (schemaBackend.kind === "kibana_data_views_fields") {
105
+ return dedupePaths([
106
+ schemaBackend.path,
107
+ "/internal/data_views/_fields_for_wildcard",
108
+ "/api/data_views/fields_for_wildcard",
109
+ "/api/index_patterns/_fields_for_wildcard",
110
+ ]);
111
+ }
112
+ return dedupePaths([
113
+ schemaBackend.path,
114
+ "/api/index_patterns/_fields_for_wildcard",
115
+ "/internal/data_views/_fields_for_wildcard",
116
+ "/api/data_views/fields_for_wildcard",
117
+ ]);
118
+ }
119
+ function buildKibanaSchemaUrl(baseUrl, schemaPath, patterns) {
120
+ const url = new URL(joinUrl(baseUrl, schemaPath));
121
+ url.searchParams.set("pattern", patterns);
122
+ url.searchParams.set("allow_no_index", "true");
123
+ for (const metaField of DEFAULT_META_FIELDS) {
124
+ url.searchParams.append("meta_fields", metaField);
125
+ }
126
+ return url.toString();
127
+ }
128
+ function inferPrimitiveFieldType(value, fieldName) {
129
+ if (typeof value === "boolean") {
130
+ return "boolean";
131
+ }
132
+ if (typeof value === "number") {
133
+ return Number.isInteger(value) ? "long" : "double";
134
+ }
135
+ if (typeof value === "string") {
136
+ if (fieldName?.endsWith(".keyword")) {
137
+ return "keyword";
138
+ }
139
+ return "text";
140
+ }
141
+ return undefined;
142
+ }
143
+ function upsertFieldDescriptor(fieldMap, field) {
144
+ const existing = fieldMap.get(field.name);
145
+ if (!existing) {
146
+ fieldMap.set(field.name, {
147
+ ...field,
148
+ subfields: [...new Set(field.subfields)],
149
+ });
150
+ return;
151
+ }
152
+ fieldMap.set(field.name, {
153
+ ...existing,
154
+ type: existing.type ?? field.type,
155
+ description: existing.description ?? field.description,
156
+ searchable: existing.searchable ?? field.searchable,
157
+ aggregatable: existing.aggregatable ?? field.aggregatable,
158
+ nested_path: existing.nested_path ?? field.nested_path,
159
+ object_array_path: existing.object_array_path ?? field.object_array_path,
160
+ multi_field_parent: existing.multi_field_parent ?? field.multi_field_parent,
161
+ preferred_exact_field: existing.preferred_exact_field ?? field.preferred_exact_field,
162
+ subfields: [...new Set([...(existing.subfields ?? []), ...(field.subfields ?? [])])],
163
+ });
164
+ }
165
+ function normalizeInferredNestedPath(nestedPath, objectArrayPath) {
166
+ // Sampled hits cannot prove Elasticsearch nested mappings. Once a field is inferred under an
167
+ // array-of-objects path, keep that shape explicit and never mark it as nested.
168
+ return objectArrayPath ? undefined : nestedPath;
169
+ }
170
+ function collectFieldsFromSourceDocument(value, fieldMap, path, nestedPath, objectArrayPath) {
171
+ if (value === null || value === undefined) {
172
+ return;
173
+ }
174
+ if (Array.isArray(value)) {
175
+ const objectValues = value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
176
+ if (path && objectValues.length > 0) {
177
+ for (const entry of objectValues) {
178
+ for (const [key, childValue] of Object.entries(entry)) {
179
+ collectFieldsFromSourceDocument(childValue, fieldMap, `${path}.${key}`, undefined, path);
180
+ }
181
+ }
182
+ return;
183
+ }
184
+ const primitiveSample = value.find((entry) => entry !== null && entry !== undefined && typeof entry !== "object");
185
+ if (path && primitiveSample !== undefined) {
186
+ upsertFieldDescriptor(fieldMap, {
187
+ name: path,
188
+ type: inferPrimitiveFieldType(primitiveSample, path),
189
+ searchable: true,
190
+ aggregatable: typeof primitiveSample !== "string",
191
+ nested_path: normalizeInferredNestedPath(nestedPath, objectArrayPath),
192
+ object_array_path: objectArrayPath,
193
+ subfields: [],
194
+ });
195
+ }
196
+ return;
197
+ }
198
+ if (typeof value === "object") {
199
+ for (const [key, childValue] of Object.entries(value)) {
200
+ const childPath = path ? `${path}.${key}` : key;
201
+ collectFieldsFromSourceDocument(childValue, fieldMap, childPath, nestedPath, objectArrayPath);
202
+ }
203
+ return;
204
+ }
205
+ if (!path) {
206
+ return;
207
+ }
208
+ upsertFieldDescriptor(fieldMap, {
209
+ name: path,
210
+ type: inferPrimitiveFieldType(value, path),
211
+ searchable: true,
212
+ aggregatable: typeof value !== "string",
213
+ nested_path: normalizeInferredNestedPath(nestedPath, objectArrayPath),
214
+ object_array_path: objectArrayPath,
215
+ subfields: [],
216
+ });
217
+ }
218
+ function collectFieldsFromSearchHit(hit, fieldMap) {
219
+ collectFieldsFromSourceDocument(asRecord(hit._source), fieldMap);
220
+ const fieldValues = asRecord(hit.fields);
221
+ for (const [fieldName, values] of Object.entries(fieldValues)) {
222
+ const firstValue = Array.isArray(values) ? values.find((value) => value !== undefined) : values;
223
+ const multiFieldParent = fieldName.includes(".")
224
+ ? fieldName.slice(0, fieldName.lastIndexOf("."))
225
+ : undefined;
226
+ const inferredType = inferPrimitiveFieldType(firstValue, fieldName);
227
+ upsertFieldDescriptor(fieldMap, {
228
+ name: fieldName,
229
+ type: inferredType,
230
+ searchable: true,
231
+ aggregatable: true,
232
+ multi_field_parent: multiFieldParent,
233
+ preferred_exact_field: inferredType === "keyword" && multiFieldParent ? fieldName : undefined,
234
+ subfields: [],
235
+ });
236
+ if (inferredType === "keyword" && multiFieldParent) {
237
+ upsertFieldDescriptor(fieldMap, {
238
+ name: multiFieldParent,
239
+ type: fieldMap.get(multiFieldParent)?.type ?? "text",
240
+ searchable: true,
241
+ aggregatable: false,
242
+ preferred_exact_field: fieldName,
243
+ subfields: [fieldName],
244
+ });
245
+ }
246
+ }
247
+ }
248
+ function normalizeSearchSampleFields(raw) {
249
+ const hitsRecord = asRecord(asRecord(raw).hits);
250
+ const hits = asArray(hitsRecord.hits);
251
+ const fieldMap = new Map();
252
+ for (const hit of hits) {
253
+ collectFieldsFromSearchHit(asRecord(hit), fieldMap);
254
+ }
255
+ return [...fieldMap.values()].sort((left, right) => left.name.localeCompare(right.name));
256
+ }
257
+ export class KibanaClient {
258
+ config;
259
+ constructor(config) {
260
+ this.config = config;
261
+ }
262
+ async requestJson(url, options = {}) {
263
+ const controller = new AbortController();
264
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
265
+ try {
266
+ const response = await fetch(url, {
267
+ method: options.method ?? "GET",
268
+ signal: controller.signal,
269
+ headers: {
270
+ Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
271
+ "Content-Type": "application/json",
272
+ ...options.headers,
273
+ },
274
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
275
+ });
276
+ if (!response.ok) {
277
+ const errorBody = await response.text();
278
+ throw new KibanaHttpError(response.status, errorBody);
279
+ }
280
+ return response.json();
281
+ }
282
+ finally {
283
+ clearTimeout(timeout);
284
+ }
285
+ }
286
+ async execute(compiledQuery) {
287
+ const source = compiledQuery.source;
288
+ const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
289
+ const controller = new AbortController();
290
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
291
+ try {
292
+ const body = source.backend.kind === "kibana_internal_search_es"
293
+ ? {
294
+ params: {
295
+ ...(source.backend.index ? { index: source.backend.index } : {}),
296
+ body: compiledQuery.request.body,
297
+ },
298
+ }
299
+ : compiledQuery.request.body;
300
+ const response = await fetch(endpoint, {
301
+ method: "POST",
302
+ signal: controller.signal,
303
+ headers: {
304
+ Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
305
+ "Content-Type": "application/json",
306
+ ...(source.backend.kind === "kibana_internal_search_es"
307
+ ? { "kbn-xsrf": "kibana-mcp-server" }
308
+ : {}),
309
+ },
310
+ body: JSON.stringify(body),
311
+ });
312
+ if (!response.ok) {
313
+ const errorBody = await response.text();
314
+ throw new Error(`Kibana request failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
315
+ }
316
+ const responseJson = (await response.json());
317
+ return {
318
+ source,
319
+ rawResponse: unwrapSearchResponse(responseJson),
320
+ };
321
+ }
322
+ finally {
323
+ clearTimeout(timeout);
324
+ }
325
+ }
326
+ async executeMany(sourceQueries) {
327
+ return Promise.all(sourceQueries.map((sourceQuery) => this.execute(sourceQuery)));
328
+ }
329
+ async describeFieldsViaSearchBackend(source) {
330
+ const endpoint = joinUrl(this.config.baseUrl, source.backend.path);
331
+ const controller = new AbortController();
332
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
333
+ try {
334
+ const body = source.backend.kind === "kibana_internal_search_es"
335
+ ? {
336
+ params: {
337
+ ...(source.backend.index ? { index: source.backend.index } : {}),
338
+ body: {
339
+ size: 20,
340
+ sort: [{ [source.timeField]: { order: "desc" } }],
341
+ fields: ["*"],
342
+ _source: true,
343
+ track_total_hits: false,
344
+ },
345
+ },
346
+ }
347
+ : {
348
+ size: 20,
349
+ sort: [{ [source.timeField]: { order: "desc" } }],
350
+ fields: ["*"],
351
+ _source: true,
352
+ track_total_hits: false,
353
+ };
354
+ const response = await fetch(endpoint, {
355
+ method: "POST",
356
+ signal: controller.signal,
357
+ headers: {
358
+ Authorization: `Basic ${encodeBasicAuth(this.config.username, this.config.password)}`,
359
+ "Content-Type": "application/json",
360
+ ...(source.backend.kind === "kibana_internal_search_es"
361
+ ? { "kbn-xsrf": "kibana-mcp-server" }
362
+ : {}),
363
+ },
364
+ body: JSON.stringify(body),
365
+ });
366
+ if (!response.ok) {
367
+ const errorBody = await response.text();
368
+ throw new Error(`Search transport fallback failed for source '${source.id}' with status ${response.status}: ${errorBody}`);
369
+ }
370
+ const responseJson = (await response.json());
371
+ const rawResponse = unwrapSearchResponse(responseJson);
372
+ const fields = normalizeSearchSampleFields(rawResponse);
373
+ if (fields.length === 0) {
374
+ throw new Error(`Search transport fallback returned no fields for source '${source.id}'. Sample hits may be empty for the requested index pattern.`);
375
+ }
376
+ return fields;
377
+ }
378
+ finally {
379
+ clearTimeout(timeout);
380
+ }
381
+ }
382
+ async describeFields(source) {
383
+ const schemaBackend = source.schema;
384
+ if (!schemaBackend) {
385
+ throw new Error(`Source '${source.id}' does not configure a schema backend. Add source.schema before using describe_fields or schema-dependent query features.`);
386
+ }
387
+ const patterns = resolveSchemaIndexPatterns(source, schemaBackend);
388
+ const schemaPaths = resolveSchemaPaths(source, schemaBackend, patterns);
389
+ if (schemaBackend.kind === "elasticsearch_field_caps") {
390
+ const schemaPath = schemaPaths[0];
391
+ if (!schemaPath) {
392
+ throw new Error(`Schema backend '${schemaBackend.kind}' does not have a usable path for source '${source.id}'`);
393
+ }
394
+ const separator = schemaPath.includes("?") ? "&" : "?";
395
+ const url = joinUrl(this.config.baseUrl, `${schemaPath}${separator}fields=*`);
396
+ let responseJson;
397
+ try {
398
+ responseJson = await this.requestJson(url, {
399
+ method: "GET",
400
+ });
401
+ }
402
+ catch (error) {
403
+ throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
404
+ }
405
+ try {
406
+ return normalizeFieldCapsResponse(responseJson);
407
+ }
408
+ catch (error) {
409
+ throw new Error(`Schema backend '${schemaBackend.kind}' returned an unexpected field capabilities payload for source '${source.id}': ${error instanceof Error ? error.message : String(error)}`);
410
+ }
411
+ }
412
+ const attempts = [];
413
+ for (const schemaPath of schemaPaths) {
414
+ const url = buildKibanaSchemaUrl(this.config.baseUrl, schemaPath, patterns);
415
+ try {
416
+ const responseJson = await this.requestJson(url, {
417
+ method: "GET",
418
+ headers: {
419
+ "kbn-xsrf": "kibana-mcp-server",
420
+ },
421
+ });
422
+ try {
423
+ return normalizeKibanaFieldsResponse(responseJson);
424
+ }
425
+ catch (error) {
426
+ throw new Error(`Schema backend '${schemaBackend.kind}' returned an unexpected Kibana field payload for source '${source.id}' from '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
427
+ }
428
+ }
429
+ catch (error) {
430
+ if (error instanceof KibanaHttpError && error.status === 404) {
431
+ attempts.push(`${schemaPath} -> 404`);
432
+ continue;
433
+ }
434
+ throw new Error(`Schema backend '${schemaBackend.kind}' request failed for source '${source.id}' at '${schemaPath}': ${error instanceof Error ? error.message : String(error)}`);
435
+ }
436
+ }
437
+ return this.describeFieldsViaSearchBackend(source).catch((fallbackError) => {
438
+ throw new Error(`Schema backend '${schemaBackend.kind}' returned 404 for source '${source.id}' on every known Kibana field-discovery path (${attempts.join(", ")}). Search transport fallback also failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
439
+ });
440
+ }
441
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { startMcpServer } from "./mcp_runtime.js";
3
+ startMcpServer().catch((error) => {
4
+ console.error(error);
5
+ process.exitCode = 1;
6
+ });
@@ -0,0 +1,23 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/server";
2
+ import { NoSavedProfileError, PROFILE_NAME_ENV, loadConfigFromEnvironment } from "./config.js";
3
+ import { createApplication } from "./server.js";
4
+ function hasBootstrapConfig(env) {
5
+ return Boolean(env.KIBANA_BASE_URL && env.KIBANA_USERNAME && env.KIBANA_PASSWORD);
6
+ }
7
+ export async function startMcpServer(envInput = process.env) {
8
+ let config = undefined;
9
+ try {
10
+ config = await loadConfigFromEnvironment(envInput);
11
+ }
12
+ catch (error) {
13
+ if (hasBootstrapConfig(envInput) || envInput[PROFILE_NAME_ENV]?.trim()) {
14
+ throw error;
15
+ }
16
+ if (!(error instanceof NoSavedProfileError)) {
17
+ throw error;
18
+ }
19
+ }
20
+ const { server } = createApplication(config);
21
+ const transport = new StdioServerTransport();
22
+ await server.connect(transport);
23
+ }
@@ -0,0 +1,36 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export const MACHINE_STATE_DIR_ENV = "KIBANA_STATE_DIR";
4
+ export const APP_STATE_DIRECTORY_NAME = "kibana-mcp-server";
5
+ export function resolveMachineStateRoot(envInput = process.env, platform = process.platform, homeDirectory = homedir()) {
6
+ const explicitRoot = envInput[MACHINE_STATE_DIR_ENV]?.trim();
7
+ if (explicitRoot) {
8
+ return explicitRoot;
9
+ }
10
+ switch (platform) {
11
+ case "darwin":
12
+ return join(homeDirectory, "Library", "Application Support", APP_STATE_DIRECTORY_NAME);
13
+ case "win32": {
14
+ const appData = envInput.APPDATA?.trim();
15
+ if (appData) {
16
+ return join(appData, APP_STATE_DIRECTORY_NAME);
17
+ }
18
+ return join(homeDirectory, "AppData", "Roaming", APP_STATE_DIRECTORY_NAME);
19
+ }
20
+ default: {
21
+ const xdgConfigHome = envInput.XDG_CONFIG_HOME?.trim();
22
+ if (xdgConfigHome) {
23
+ return join(xdgConfigHome, APP_STATE_DIRECTORY_NAME);
24
+ }
25
+ return join(homeDirectory, ".config", APP_STATE_DIRECTORY_NAME);
26
+ }
27
+ }
28
+ }
29
+ export function resolveProfilePaths(envInput = process.env, platform = process.platform, homeDirectory = homedir()) {
30
+ const stateRoot = resolveMachineStateRoot(envInput, platform, homeDirectory);
31
+ return {
32
+ stateRoot,
33
+ profilesPath: join(stateRoot, "profiles.json"),
34
+ sourceCatalogsDir: join(stateRoot, "source-catalogs"),
35
+ };
36
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { resolveProfilePaths } from "./profile_paths.js";
5
+ const PROFILE_STORE_VERSION = 1;
6
+ const savedProfileSchema = z.object({
7
+ id: z.string().min(1),
8
+ name: z.string().min(1),
9
+ baseUrl: z.url(),
10
+ timeoutMs: z.number().int().positive().max(120000),
11
+ sourceCatalogPath: z.string().min(1),
12
+ createdAt: z.string().min(1),
13
+ updatedAt: z.string().min(1),
14
+ });
15
+ const savedProfileStoreStateSchema = z.object({
16
+ version: z.literal(PROFILE_STORE_VERSION),
17
+ defaultProfileId: z.string().min(1).nullable(),
18
+ profiles: z.array(savedProfileSchema),
19
+ });
20
+ const EMPTY_PROFILE_STORE = {
21
+ version: PROFILE_STORE_VERSION,
22
+ defaultProfileId: null,
23
+ profiles: [],
24
+ };
25
+ export class ProfileStore {
26
+ paths;
27
+ io;
28
+ constructor(paths = resolveProfilePaths(), io = {}) {
29
+ this.paths = paths;
30
+ this.io = io;
31
+ }
32
+ async load() {
33
+ try {
34
+ const raw = await (this.io.readFile ?? readFile)(this.paths.profilesPath, "utf8");
35
+ return savedProfileStoreStateSchema.parse(JSON.parse(raw));
36
+ }
37
+ catch (error) {
38
+ if (isMissingFileError(error)) {
39
+ return EMPTY_PROFILE_STORE;
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ async listProfiles() {
45
+ return (await this.load()).profiles;
46
+ }
47
+ async getDefaultProfile() {
48
+ const state = await this.load();
49
+ if (!state.defaultProfileId) {
50
+ return null;
51
+ }
52
+ return state.profiles.find((profile) => profile.id === state.defaultProfileId) ?? null;
53
+ }
54
+ async findProfile(query) {
55
+ const normalizedQuery = query.trim().toLowerCase();
56
+ if (!normalizedQuery) {
57
+ return null;
58
+ }
59
+ const state = await this.load();
60
+ return (state.profiles.find((profile) => profile.id.toLowerCase() === normalizedQuery ||
61
+ profile.name.toLowerCase() === normalizedQuery) ?? null);
62
+ }
63
+ async upsertProfile(input, options = {}) {
64
+ const state = await this.load();
65
+ const now = (options.now ?? new Date()).toISOString();
66
+ const existing = state.profiles.find((profile) => profile.name.toLowerCase() === input.name.trim().toLowerCase());
67
+ const profile = existing
68
+ ? {
69
+ ...existing,
70
+ name: input.name.trim(),
71
+ baseUrl: input.baseUrl,
72
+ timeoutMs: input.timeoutMs,
73
+ sourceCatalogPath: input.sourceCatalogPath,
74
+ updatedAt: now,
75
+ }
76
+ : {
77
+ id: input.id ??
78
+ deriveProfileId(input.name, state.profiles.map((profile) => profile.id)),
79
+ name: input.name.trim(),
80
+ baseUrl: input.baseUrl,
81
+ timeoutMs: input.timeoutMs,
82
+ sourceCatalogPath: input.sourceCatalogPath,
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ };
86
+ const nextProfiles = existing
87
+ ? state.profiles.map((candidate) => (candidate.id === profile.id ? profile : candidate))
88
+ : [...state.profiles, profile];
89
+ const nextState = {
90
+ version: PROFILE_STORE_VERSION,
91
+ defaultProfileId: options.makeDefault || !state.defaultProfileId ? profile.id : state.defaultProfileId,
92
+ profiles: nextProfiles,
93
+ };
94
+ await this.save(nextState);
95
+ return profile;
96
+ }
97
+ async setDefaultProfile(profileId) {
98
+ const state = await this.load();
99
+ const profileExists = state.profiles.some((profile) => profile.id === profileId);
100
+ if (!profileExists) {
101
+ throw new Error(`Cannot set default profile. Unknown profile id '${profileId}'.`);
102
+ }
103
+ await this.save({
104
+ ...state,
105
+ defaultProfileId: profileId,
106
+ });
107
+ }
108
+ async save(state) {
109
+ const mkdirImpl = this.io.mkdir ?? mkdir;
110
+ const writeFileImpl = this.io.writeFile ?? writeFile;
111
+ await mkdirImpl(dirname(this.paths.profilesPath), { recursive: true });
112
+ await writeFileImpl(this.paths.profilesPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
113
+ }
114
+ }
115
+ export function normalizeProfileId(name) {
116
+ const normalized = name
117
+ .trim()
118
+ .toLowerCase()
119
+ .replace(/[^a-z0-9]+/g, "-")
120
+ .replace(/^-+|-+$/g, "");
121
+ return normalized || "default";
122
+ }
123
+ export function deriveProfileId(name, existingIds) {
124
+ const baseId = normalizeProfileId(name);
125
+ if (!existingIds.includes(baseId)) {
126
+ return baseId;
127
+ }
128
+ let index = 2;
129
+ while (existingIds.includes(`${baseId}-${index}`)) {
130
+ index += 1;
131
+ }
132
+ return `${baseId}-${index}`;
133
+ }
134
+ function isMissingFileError(error) {
135
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
136
+ }