@yugabytedb/meko-mcp 2.3.0 → 2.5.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.
Files changed (42) hide show
  1. package/README.md +88 -35
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +735 -74
  3. package/assets/hooks-handlers/lib/capture-notices.js +94 -1
  4. package/assets/hooks-handlers/lib/capture-state.js +111 -0
  5. package/assets/hooks-handlers/lib/capture.js +130 -4
  6. package/assets/hooks-handlers/lib/checkpoint-timer.js +394 -8
  7. package/assets/hooks-handlers/lib/opencode-handler.js +29 -6
  8. package/assets/scripts/meko-statusline.sh +17 -1
  9. package/assets/skills/meko-mcp-tools/SKILL.md +6 -5
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +194 -10
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -3
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +11 -6
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +4 -4
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +19 -14
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +69 -8
  17. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +3 -1
  18. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -7
  19. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  20. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  21. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +194 -10
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +10 -5
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +19 -14
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +68 -8
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -1
  27. package/assets/skills/meko-select-datapack-desktop/SKILL.md +91 -12
  28. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +39 -7
  29. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +8 -6
  30. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +13 -1
  31. package/bin/create-meko-setup.mjs +152 -35
  32. package/lib/autofix.mjs +100 -0
  33. package/lib/browser-login.mjs +495 -0
  34. package/lib/clients/codex.mjs +1 -24
  35. package/lib/clients/common.mjs +25 -0
  36. package/lib/clients/opencode.mjs +6 -1
  37. package/lib/migrate/datapack.mjs +254 -16
  38. package/lib/migrate/mcp-client.mjs +34 -0
  39. package/lib/migrate/orchestrator.mjs +1 -1
  40. package/lib/migrate.mjs +2 -6
  41. package/lib/validate.mjs +111 -22
  42. package/package.json +5 -5
@@ -8,11 +8,19 @@
8
8
  * confirmation of the default-fallback case. This module owns the lookup.
9
9
  *
10
10
  * Wire model: the MCP server routes by `datapack_id` parameter, not URL host.
11
- * `datapack_list` returns the user's visible packs; we resolve here, then the
12
- * orchestrator threads `datapack_id` into every subsequent `tools/call`.
11
+ * We resolve here, then the orchestrator threads `datapack_id` into every
12
+ * subsequent `tools/call`.
13
+ *
14
+ * Lookups go through `datapack_search(name=...)` and `datapack_describe`, so
15
+ * they cost the same for an account with 1M datapacks as for one with two.
16
+ * A server whose tools/list has no `datapack_search` predates it; there we
17
+ * fall back to scanning `datapack_list`, which is fine for the account sizes
18
+ * such a server can serve.
13
19
  */
14
20
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
21
  const DEFAULT_DATAPACK_NAME = "meko_default_datapack";
22
+ const NIL_CONVERSATION_ID = "00000000-0000-0000-0000-000000000000";
23
+ const SUGGESTION_LIMIT = 10;
16
24
 
17
25
  export function isUuid(value) {
18
26
  return typeof value === "string" && UUID_RE.test(value);
@@ -50,7 +58,8 @@ export function parseDatapackList(text) {
50
58
  * Resolve the destination datapack for a migration.
51
59
  *
52
60
  * @param {object} opts
53
- * @param {{callTool: Function}} opts.client Connected McpClient.
61
+ * @param {{callTool: Function, listTools?: Function}} opts.client
62
+ * Connected McpClient.
54
63
  * @param {string|null} opts.requested Raw `--datapack` value (UUID or
55
64
  * name) or null for default.
56
65
  * @returns {Promise<{
@@ -68,20 +77,239 @@ export function parseDatapackList(text) {
68
77
  * - `server-fallback`: requested=null and no default row found in the list;
69
78
  * we let the server's own fallback chain handle it (id stays null).
70
79
  *
71
- * `defaultId` is the id of the `meko_default_datapack` row if visible — surfaced
72
- * even when `requested` is non-null so the caller can detect "user named the
73
- * default explicitly" and reuse the same on-disk state file.
80
+ * `defaultId` is the id of the `meko_default_datapack` row if visible, so the
81
+ * caller can detect "user named the default explicitly" and reuse the same
82
+ * on-disk state file. With `datapack_search`, an explicit `requested` whose
83
+ * datapack has another name gets `defaultId: null` without a lookup: its id
84
+ * can't match the default's, and skipping the lookup keeps a failure there
85
+ * from failing the request.
74
86
  *
75
87
  * Failure cases (`ok: false`):
76
- * - `datapack_list` call failed (auth, network, server error).
77
- * - response body wasn't a recognizable datapack list.
78
- * - explicit override didn't match any row.
88
+ * - the lookup call failed (auth, network, server error).
89
+ * - response body wasn't recognizable.
90
+ * - explicit override didn't match any visible datapack.
79
91
  */
80
92
  export async function resolveDestinationDatapack(opts) {
81
93
  const { client, requested } = opts;
94
+ if (await hasTool(client, "datapack_search")) {
95
+ return resolveWithSearch(client, requested);
96
+ }
97
+ return resolveWithListScan(client, requested);
98
+ }
99
+
100
+ /**
101
+ * Whether the server lists `name`. A client without `listTools` (a test
102
+ * double) or a failed tools/list reads as "no", which selects the
103
+ * `datapack_list` scan every server supports.
104
+ */
105
+ async function hasTool(client, name) {
106
+ if (typeof client.listTools !== "function") return false;
107
+ try {
108
+ const names = await client.listTools();
109
+ return Array.isArray(names) && names.includes(name);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Parse a `datapack_search` response body into its rows, or return
117
+ * `{error}` naming what went wrong. Rows are summary rows (no credentials),
118
+ * but the body is still never echoed.
119
+ *
120
+ * @param {{ok: boolean, text?: string, error?: object}} res
121
+ * @returns {{rows: Array} | {error: string}}
122
+ */
123
+ export function parseDatapackSearch(res) {
124
+ if (!res.ok) return { error: res.error?.message ?? "unknown" };
125
+ let body;
126
+ try {
127
+ body = JSON.parse(res.text ?? "");
128
+ } catch {
129
+ return { error: "unrecognized response shape" };
130
+ }
131
+ if (body && typeof body === "object" && typeof body.error === "string") {
132
+ const detail = typeof body.detail === "string" ? ` (${body.detail})` : "";
133
+ return { error: `${body.error}${detail}` };
134
+ }
135
+ if (!body || !Array.isArray(body.datapacks)) {
136
+ return { error: "unrecognized response shape" };
137
+ }
138
+ const rows = body.datapacks.filter(
139
+ (e) => e && typeof e === "object" && typeof e.datapack_id === "string",
140
+ );
141
+ return { rows };
142
+ }
143
+
144
+ function search(client, args) {
145
+ return client.callTool("datapack_search", {
146
+ conversation_id: NIL_CONVERSATION_ID,
147
+ ...args,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * The row named exactly `name`. An owned row is requested first, with
153
+ * `grant: "owner"`, so a page of newer shared copies of the same name
154
+ * cannot hide it. If the caller owns none, the first exact match of any
155
+ * grant is used.
156
+ */
157
+ async function findByName(client, name) {
158
+ const owned = parseDatapackSearch(
159
+ await search(client, { name, grant: "owner", limit: 100 }),
160
+ );
161
+ if (owned.error) return owned;
162
+ const ownedHit = owned.rows.find(
163
+ (d) => d.datapack_name === name && d.grant === "owner",
164
+ );
165
+ if (ownedHit) return { row: ownedHit };
166
+
167
+ const found = parseDatapackSearch(await search(client, { name, limit: 100 }));
168
+ if (found.error) return found;
169
+ const exact = found.rows.filter((d) => d.datapack_name === name);
170
+ return { row: exact[0] ?? null };
171
+ }
172
+
173
+ async function resolveWithSearch(client, requested) {
174
+ if (!requested) {
175
+ const def = await findByName(client, DEFAULT_DATAPACK_NAME);
176
+ if (def.error) {
177
+ return {
178
+ ok: false,
179
+ message: `datapack_search failed: ${def.error}. Cannot resolve --datapack.`,
180
+ };
181
+ }
182
+ const defaultRow = def.row;
183
+ if (defaultRow) {
184
+ return {
185
+ ok: true,
186
+ id: defaultRow.datapack_id,
187
+ name: defaultRow.datapack_name,
188
+ source: "default",
189
+ defaultId: defaultRow.datapack_id,
190
+ };
191
+ }
192
+ return { ok: true, id: null, name: null, source: "server-fallback", defaultId: null };
193
+ }
194
+
195
+ const found = await resolveRequested(client, requested);
196
+ if (!found.ok) return found;
197
+ const def = await defaultIdFor(client, requested, found.row);
198
+ if (def.error) {
199
+ return {
200
+ ok: false,
201
+ message:
202
+ `datapack_search failed: ${def.error}. Cannot tell whether --datapack ` +
203
+ `${requested} is your default datapack.`,
204
+ };
205
+ }
206
+ return {
207
+ ok: true,
208
+ id: found.row.datapack_id,
209
+ name: found.row.datapack_name ?? null,
210
+ source: found.source,
211
+ defaultId: def.id,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * The id of the caller's `meko_default_datapack`, or null, for comparing with
217
+ * an explicitly requested datapack. A row with any other name can't be the
218
+ * default, so the lookup runs only when the requested datapack carries the
219
+ * default's name or its name is unknown. That keeps a failure in a lookup the
220
+ * request doesn't need from failing the request.
221
+ *
222
+ * @returns {Promise<{id: string|null} | {error: string}>}
223
+ */
224
+ async function defaultIdFor(client, requested, row) {
225
+ const name = row.datapack_name;
226
+ if (typeof name === "string" && name !== DEFAULT_DATAPACK_NAME) {
227
+ return { id: null };
228
+ }
229
+ // findByName already resolved the default's name, owner first.
230
+ if (requested === DEFAULT_DATAPACK_NAME) return { id: row.datapack_id };
231
+ const def = await findByName(client, DEFAULT_DATAPACK_NAME);
232
+ if (def.error) return def;
233
+ return { id: def.row?.datapack_id ?? null };
234
+ }
235
+
236
+ /**
237
+ * Resolve an explicit `--datapack` to `{ok: true, row, source}`, or to the
238
+ * `{ok: false, message}` the caller returns as is.
239
+ */
240
+ async function resolveRequested(client, requested) {
241
+ if (isUuid(requested)) {
242
+ const res = await client.callTool("datapack_describe", {
243
+ datapack_id: requested,
244
+ conversation_id: NIL_CONVERSATION_ID,
245
+ });
246
+ const hit = parseDescribe(res);
247
+ if (hit.error) {
248
+ return {
249
+ ok: false,
250
+ message:
251
+ `--datapack ${requested}: no datapack with that id is visible to ` +
252
+ `this API key (${hit.error}).`,
253
+ };
254
+ }
255
+ return { ok: true, row: hit.row, source: "flag-id" };
256
+ }
257
+
258
+ const byName = await findByName(client, requested);
259
+ if (byName.error) {
260
+ return {
261
+ ok: false,
262
+ message: `datapack_search failed: ${byName.error}. Cannot resolve --datapack.`,
263
+ };
264
+ }
265
+ if (!byName.row) {
266
+ const similar = parseDatapackSearch(
267
+ await search(client, { query: requested, limit: SUGGESTION_LIMIT }),
268
+ );
269
+ if (similar.error) {
270
+ return {
271
+ ok: false,
272
+ message: `datapack_search failed: ${similar.error}. Cannot resolve --datapack.`,
273
+ };
274
+ }
275
+ const hint = similar.rows.length
276
+ ? `Similar names: ${formatNames(similar.rows)}.`
277
+ : "No datapack name contains that text.";
278
+ return {
279
+ ok: false,
280
+ message:
281
+ `--datapack ${JSON.stringify(requested)}: no datapack with that name. ` +
282
+ hint,
283
+ };
284
+ }
285
+ return { ok: true, row: byName.row, source: "flag-name" };
286
+ }
287
+
288
+ /**
289
+ * Parse a `datapack_describe` body into `{row}` or `{error}`. Only the id
290
+ * and name are read; the body is never echoed.
291
+ */
292
+ function parseDescribe(res) {
293
+ if (!res.ok) return { error: res.error?.message ?? "unknown" };
294
+ let body;
295
+ try {
296
+ body = JSON.parse(res.text ?? "");
297
+ } catch {
298
+ return { error: "unrecognized response shape" };
299
+ }
300
+ if (body && typeof body === "object" && typeof body.error === "string") {
301
+ return { error: body.error };
302
+ }
303
+ if (!body || typeof body.datapack_id !== "string") {
304
+ return { error: "unrecognized response shape" };
305
+ }
306
+ return { row: body };
307
+ }
308
+
309
+ async function resolveWithListScan(client, requested) {
82
310
  const res = await client.callTool("datapack_list", {
83
311
  scope: "read",
84
- conversation_id: "00000000-0000-0000-0000-000000000000",
312
+ conversation_id: NIL_CONVERSATION_ID,
85
313
  });
86
314
  if (!res.ok) {
87
315
  return {
@@ -101,7 +329,7 @@ export async function resolveDestinationDatapack(opts) {
101
329
  };
102
330
  }
103
331
 
104
- const defaultRow = list.find((d) => d.datapack_name === DEFAULT_DATAPACK_NAME);
332
+ const defaultRow = namedRow(list, DEFAULT_DATAPACK_NAME);
105
333
  const defaultId = defaultRow?.datapack_id ?? null;
106
334
 
107
335
  if (!requested) {
@@ -136,7 +364,7 @@ export async function resolveDestinationDatapack(opts) {
136
364
  };
137
365
  }
138
366
 
139
- const hit = list.find((d) => d.datapack_name === requested);
367
+ const hit = namedRow(list, requested);
140
368
  if (!hit) {
141
369
  return {
142
370
  ok: false,
@@ -154,14 +382,24 @@ export async function resolveDestinationDatapack(opts) {
154
382
  };
155
383
  }
156
384
 
385
+ function namedRow(list, name) {
386
+ const exact = list.filter((d) => d.datapack_name === name);
387
+ return exact.find((d) => d.grant === "owner") ?? exact[0] ?? null;
388
+ }
389
+
157
390
  /**
158
391
  * Format a one-line "Available: a, b, c" hint. Names only — never ids,
159
392
  * because the underlying response can carry credentials.
160
393
  */
161
394
  function formatAvailable(list) {
162
- const names = list
395
+ const names = formatNames(list);
396
+ if (!names) return "(no datapacks visible to this API key.)";
397
+ return `Available: ${names}.`;
398
+ }
399
+
400
+ function formatNames(list) {
401
+ return list
163
402
  .map((d) => d.datapack_name)
164
- .filter((n) => typeof n === "string" && n.length > 0);
165
- if (names.length === 0) return "(no datapacks visible to this API key.)";
166
- return `Available: ${names.join(", ")}.`;
403
+ .filter((n) => typeof n === "string" && n.length > 0)
404
+ .join(", ");
167
405
  }
@@ -79,6 +79,40 @@ export class McpClient {
79
79
  }
80
80
  }
81
81
 
82
+ /**
83
+ * Names of the tools the server lists over the open session. Throws when
84
+ * the request fails, so callers can decide how to degrade.
85
+ *
86
+ * @param {{timeoutMs?: number}} [opts]
87
+ * @returns {Promise<string[]>}
88
+ */
89
+ async listTools(opts = {}) {
90
+ await this._acquire();
91
+ try {
92
+ const res = await jsonRpcRequest(
93
+ this.url,
94
+ { jsonrpc: "2.0", id: this._reqId++, method: "tools/list", params: {} },
95
+ this.apiKey,
96
+ this.sessionId,
97
+ { timeoutMs: opts.timeoutMs ?? this.timeoutMs },
98
+ );
99
+ if (res.statusCode < 200 || res.statusCode >= 300) {
100
+ const e = new Error(`tools/list returned HTTP ${res.statusCode}`);
101
+ e.statusCode = res.statusCode;
102
+ throw e;
103
+ }
104
+ const parsed = JSON.parse(res.body);
105
+ if (parsed.error) {
106
+ throw new Error(`tools/list error: ${parsed.error.message ?? "unknown"}`);
107
+ }
108
+ const tools = parsed.result?.tools;
109
+ if (!Array.isArray(tools)) throw new Error("tools/list returned no tools array");
110
+ return tools.map((t) => t?.name).filter((n) => typeof n === "string");
111
+ } finally {
112
+ this._release();
113
+ }
114
+ }
115
+
82
116
  async _callWithRetry(name, args, opts) {
83
117
  const maxRetries = opts.maxRetries ?? 3;
84
118
  let lastErr = null;
@@ -287,7 +287,7 @@ export async function runMigration(opts) {
287
287
  return { ok: false, summary: { error: "mcp_connect_failed", detail: err.message }, counts: prog.counts };
288
288
  }
289
289
 
290
- // Resolve --datapack against the live datapack_list so every subsequent
290
+ // Resolve --datapack against the live server so every subsequent
291
291
  // tools/call carries an explicit datapack_id. Without this we'd be silently
292
292
  // relying on the server's tenant-context default — fine when it's correct,
293
293
  // invisible when it isn't.
package/lib/migrate.mjs CHANGED
@@ -20,7 +20,7 @@ import * as progress from "./migrate/progress.mjs";
20
20
  import { datapackKeyFromUrl } from "./migrate/id.mjs";
21
21
  import { resolveDestinationDatapack } from "./migrate/datapack.mjs";
22
22
  import { McpClient } from "./migrate/mcp-client.mjs";
23
- import { selfInvocation } from "./clients/common.mjs";
23
+ import { isLocalUrl, selfInvocation } from "./clients/common.mjs";
24
24
  import { askYesNo } from "./prompt.mjs";
25
25
 
26
26
  function home() {
@@ -302,7 +302,7 @@ export async function startMigration(opts) {
302
302
  // Pre-flight: resolve the destination datapack *before* taking the lock or
303
303
  // forking the child. A typo in --datapack should fail fast with a useful
304
304
  // error, not after the child has spun up. We open a short-lived MCP client
305
- // here purely for the datapack_list call; the child opens its own session.
305
+ // here purely for the datapack lookup; the child opens its own session.
306
306
  // The resolved id isn't persisted into passthrough — the child re-resolves,
307
307
  // which costs one extra cheap read but keeps the wire-level routing
308
308
  // logic in a single place.
@@ -764,7 +764,3 @@ export async function stopMigration() {
764
764
  message: `Force-killed pid=${holder.pid} (run-id=${holder.runId}).`,
765
765
  };
766
766
  }
767
-
768
- function isLocalUrl(url) {
769
- return /localhost|127\.0\.0\.1/.test(url ?? "");
770
- }
package/lib/validate.mjs CHANGED
@@ -38,8 +38,13 @@ const CORE_TOOLS = [
38
38
  /** Nil UUID used as the conversation_id placeholder for the canary call. */
39
39
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
40
40
 
41
- /** Tool the canary invokes. */
42
- const CANARY_TOOL = "datapack_list";
41
+ /**
42
+ * Tool the canary invokes. `datapack_search(limit=1)` reads one page whatever
43
+ * the account size; a server that predates it gets `datapack_list`, which
44
+ * reads the whole account.
45
+ */
46
+ const CANARY_TOOL = "datapack_search";
47
+ const LEGACY_CANARY_TOOL = "datapack_list";
43
48
 
44
49
  // ---------------------------------------------------------------------------
45
50
  // 1. Config validation
@@ -139,9 +144,10 @@ export async function validateTools(url, apiKey) {
139
144
  * The canary's job is to prove the part of the stack that connectivity +
140
145
  * tools/list don't cover: auth was accepted as a real Meko identity, the
141
146
  * tenant resolver mapped it to an account, and the datapack routing wired
142
- * up the user's actual workspace. We invoke `datapack_list` for this — it
143
- * is a single read-only call, runs a short SQL query against the system
144
- * schema, and returns deterministically (every authenticated user has at
147
+ * up the user's actual workspace. We invoke `datapack_search(limit=1)` for
148
+ * this, or `datapack_list` on a server whose tools/list has no
149
+ * `datapack_search`. Either is a single read-only call against the system
150
+ * schema and returns deterministically (every authenticated user has at
145
151
  * least one datapack: their default).
146
152
  *
147
153
  * Why not memory_add? The previous canary did a write/read/delete round-trip
@@ -166,11 +172,13 @@ export async function validateTools(url, apiKey) {
166
172
  * initialize handshake is performed.
167
173
  * @param {number} [options.timeoutMs] - Override the canary call's transport timeout.
168
174
  * Defaults to DEFAULT_REQUEST_TIMEOUT_MS.
175
+ * @param {string[]} [options.toolNames] - Tool names from an earlier tools/list.
176
+ * If omitted, the canary lists tools itself.
169
177
  * @returns {Promise<{
170
178
  * ok: boolean,
171
179
  * message: string,
172
180
  * datapack_count: number|null,
173
- * step?: "initialize"|"datapack_list",
181
+ * step?: "initialize"|"datapack_search"|"datapack_list",
174
182
  * }>}
175
183
  */
176
184
  export async function validateCanary(url, apiKey, options = {}) {
@@ -190,11 +198,77 @@ export async function validateCanary(url, apiKey, options = {}) {
190
198
  sessionId = init.sessionId;
191
199
  }
192
200
 
201
+ let toolNames = options.toolNames;
202
+ if (!Array.isArray(toolNames)) {
203
+ // A failed listing just means we cannot tell; datapack_list works on
204
+ // every server.
205
+ const listed = await sendToolsList(url, apiKey, sessionId);
206
+ toolNames = listed.tools;
207
+ }
208
+ if (toolNames.includes(CANARY_TOOL)) {
209
+ return searchCanary(url, apiKey, sessionId, timeoutMs);
210
+ }
211
+ return listCanary(url, apiKey, sessionId, timeoutMs);
212
+ }
213
+
214
+ async function searchCanary(url, apiKey, sessionId, timeoutMs) {
193
215
  const callRes = await mcpCallTool(
194
216
  url,
195
217
  apiKey,
196
218
  sessionId,
197
219
  CANARY_TOOL,
220
+ { conversation_id: NIL_UUID, limit: 1 },
221
+ { timeoutMs },
222
+ );
223
+ if (!callRes.ok) {
224
+ return {
225
+ ok: false,
226
+ datapack_count: null,
227
+ step: CANARY_TOOL,
228
+ message: `Canary: ${callRes.message}`,
229
+ };
230
+ }
231
+
232
+ const datapacks = parseDatapackSearchPage(callRes.text);
233
+ if (!datapacks) {
234
+ return {
235
+ ok: false,
236
+ datapack_count: null,
237
+ step: CANARY_TOOL,
238
+ message:
239
+ `Canary: ${CANARY_TOOL} returned an unexpected response shape ` +
240
+ `(expected an object with a datapacks array). Check the server logs.`,
241
+ };
242
+ }
243
+ if (datapacks.length === 0) {
244
+ return zeroDatapacks(CANARY_TOOL);
245
+ }
246
+ return {
247
+ ok: true,
248
+ datapack_count: datapacks.length,
249
+ message: `Canary OK — ${CANARY_TOOL} found a datapack.`,
250
+ };
251
+ }
252
+
253
+ function zeroDatapacks(tool) {
254
+ return {
255
+ ok: false,
256
+ datapack_count: 0,
257
+ step: tool,
258
+ message:
259
+ `Canary: ${tool} returned zero datapacks. Every authenticated ` +
260
+ `Meko user should have at least their default datapack — this likely ` +
261
+ `means the API token authenticated but resolved to the wrong tenant. ` +
262
+ `Verify the API key matches the tenant the URL points at.`,
263
+ };
264
+ }
265
+
266
+ async function listCanary(url, apiKey, sessionId, timeoutMs) {
267
+ const callRes = await mcpCallTool(
268
+ url,
269
+ apiKey,
270
+ sessionId,
271
+ LEGACY_CANARY_TOOL,
198
272
  {
199
273
  scope: "read",
200
274
  conversation_id: NIL_UUID,
@@ -210,7 +284,7 @@ export async function validateCanary(url, apiKey, options = {}) {
210
284
  return {
211
285
  ok: false,
212
286
  datapack_count: null,
213
- step: CANARY_TOOL,
287
+ step: LEGACY_CANARY_TOOL,
214
288
  message: `Canary: ${callRes.message}`,
215
289
  };
216
290
  }
@@ -224,30 +298,21 @@ export async function validateCanary(url, apiKey, options = {}) {
224
298
  return {
225
299
  ok: false,
226
300
  datapack_count: null,
227
- step: CANARY_TOOL,
301
+ step: LEGACY_CANARY_TOOL,
228
302
  message:
229
- `Canary: ${CANARY_TOOL} returned an unexpected response shape ` +
303
+ `Canary: ${LEGACY_CANARY_TOOL} returned an unexpected response shape ` +
230
304
  `(expected a JSON array of datapack objects). Check the server logs.`,
231
305
  };
232
306
  }
233
307
 
234
308
  if (datapacks.length === 0) {
235
- return {
236
- ok: false,
237
- datapack_count: 0,
238
- step: CANARY_TOOL,
239
- message:
240
- `Canary: ${CANARY_TOOL} returned zero datapacks. Every authenticated ` +
241
- `Meko user should have at least their default datapack — this likely ` +
242
- `means the API token authenticated but resolved to the wrong tenant. ` +
243
- `Verify the API key matches the tenant the URL points at.`,
244
- };
309
+ return zeroDatapacks(LEGACY_CANARY_TOOL);
245
310
  }
246
311
 
247
312
  return {
248
313
  ok: true,
249
314
  datapack_count: datapacks.length,
250
- message: `Canary OK — ${CANARY_TOOL} returned ${datapacks.length} datapack(s).`,
315
+ message: `Canary OK — ${LEGACY_CANARY_TOOL} returned ${datapacks.length} datapack(s).`,
251
316
  };
252
317
  }
253
318
 
@@ -263,8 +328,8 @@ export async function validateCanary(url, apiKey, options = {}) {
263
328
  * canary reuses that same session too.
264
329
  *
265
330
  * The canary is skipped (not failed) when tools/list didn't pass — the
266
- * canary depends on `datapack_list` being present, and a tools failure
267
- * already surfaces that.
331
+ * canary depends on `datapack_list` (a core tool) or `datapack_search`
332
+ * being present, and a tools failure already surfaces that.
268
333
  *
269
334
  * @param {string} url - Full MCP endpoint URL.
270
335
  * @param {string|null} apiKey - Optional API key.
@@ -292,6 +357,7 @@ export async function runValidation(url, apiKey, serverName = "meko", options =
292
357
  if (tools.ok && connectivity.ok) {
293
358
  canary = await validateCanary(url, apiKey, {
294
359
  sessionId: connectivity.sessionId,
360
+ toolNames: tools.tools,
295
361
  });
296
362
  } else {
297
363
  canary = {
@@ -508,6 +574,29 @@ function extractFirstText(result) {
508
574
  return null;
509
575
  }
510
576
 
577
+ /**
578
+ * Parse a `datapack_search` response body and return its `datapacks` rows,
579
+ * or `null` if the body is not a recognizable page.
580
+ *
581
+ * @param {string|null} text
582
+ * @returns {Array|null}
583
+ */
584
+ function parseDatapackSearchPage(text) {
585
+ if (!text) return null;
586
+ let body;
587
+ try {
588
+ body = JSON.parse(text);
589
+ } catch {
590
+ return null;
591
+ }
592
+ if (!body || typeof body !== "object" || !Array.isArray(body.datapacks)) return null;
593
+ const rows = body.datapacks;
594
+ const allLookLikeDatapacks = rows.every(
595
+ (entry) => entry && typeof entry === "object" && typeof entry.datapack_id === "string",
596
+ );
597
+ return allLookLikeDatapacks ? rows : null;
598
+ }
599
+
511
600
  /**
512
601
  * Parse a `datapack_list` response body and return the array of datapacks,
513
602
  * or `null` if the body is not a recognizable datapack list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yugabytedb/meko-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.5.1",
4
4
  "description": "Installer for Meko across Claude, Cursor, Codex, and Kiro clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,11 +18,11 @@
18
18
  "prepack": "node scripts/bundle-assets.mjs",
19
19
  "postpack": "node scripts/bundle-assets.mjs --clean",
20
20
  "bundle-assets": "node scripts/bundle-assets.mjs",
21
- "test": "node --test \"test/*.mjs\" && node --test \"test/serial/*.mjs\"",
21
+ "test": "node --import ./test/helpers/hermetic-env.mjs --test \"test/*.mjs\" && node --import ./test/helpers/hermetic-env.mjs --test \"test/serial/*.mjs\"",
22
22
  "smoke": "./scripts/prepublish-smoke.sh",
23
- "test:unit": "node --test test/config.test.mjs test/validate.test.mjs test/clients.test.mjs",
24
- "test:integration": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs",
25
- "test:integration:cloud": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs"
23
+ "test:unit": "node --import ./test/helpers/hermetic-env.mjs --test test/config.test.mjs test/validate.test.mjs test/clients.test.mjs",
24
+ "test:integration": "RUN_INSTALLER_INTEGRATION=1 node --import ./test/helpers/hermetic-env.mjs --test test/installer.test.mjs",
25
+ "test:integration:cloud": "RUN_INSTALLER_INTEGRATION=1 node --import ./test/helpers/hermetic-env.mjs --test test/installer.test.mjs"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=20.18.1"