engine-dj-mcp 0.9.0 → 0.9.2

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.
@@ -307,6 +307,18 @@ export declare function decodeBeatgrid(buf: Buffer | null): BeatgridResult;
307
307
  * `duration_seconds` still comes from Track.length rather than from the
308
308
  * blob: the overview carries a sample *count* but no sample rate, so a
309
309
  * duration derived from it alone would be a guess.
310
+ *
311
+ * `buckets` has no caller today that passes anything but the default, so a
312
+ * bad value here is unreachable in practice -- guarded anyway so it stays
313
+ * that way for the next caller rather than becoming a trap. A non-finite or
314
+ * non-positive value (0, negative, NaN, +/-Infinity) falls back to the
315
+ * default instead of reaching the bucket-size arithmetic below: 0 or a
316
+ * negative value there would clamp the *denominator*, not the bucket count,
317
+ * which maximises bucket size and collapses the whole track into one giant
318
+ * bucket -- the opposite of "more buckets"; NaN propagates through to a
319
+ * single bucket reporting a peak of 0 regardless of the actual audio. Both
320
+ * still come back `status: "ok"` -- a confident, wrong answer, not a
321
+ * request this function visibly declined to honour.
310
322
  */
311
323
  export declare function summariseWaveform(buf: Buffer | null, buckets?: number, durationSeconds?: number | null): WaveformSummary;
312
324
  export interface PerformanceRow {
@@ -413,10 +413,23 @@ export function decodeBeatgrid(buf) {
413
413
  * `duration_seconds` still comes from Track.length rather than from the
414
414
  * blob: the overview carries a sample *count* but no sample rate, so a
415
415
  * duration derived from it alone would be a guess.
416
+ *
417
+ * `buckets` has no caller today that passes anything but the default, so a
418
+ * bad value here is unreachable in practice -- guarded anyway so it stays
419
+ * that way for the next caller rather than becoming a trap. A non-finite or
420
+ * non-positive value (0, negative, NaN, +/-Infinity) falls back to the
421
+ * default instead of reaching the bucket-size arithmetic below: 0 or a
422
+ * negative value there would clamp the *denominator*, not the bucket count,
423
+ * which maximises bucket size and collapses the whole track into one giant
424
+ * bucket -- the opposite of "more buckets"; NaN propagates through to a
425
+ * single bucket reporting a peak of 0 regardless of the actual audio. Both
426
+ * still come back `status: "ok"` -- a confident, wrong answer, not a
427
+ * request this function visibly declined to honour.
416
428
  */
417
429
  export function summariseWaveform(buf, buckets = 32, durationSeconds = null) {
418
430
  if (!buf || buf.length === 0)
419
431
  return { layout: LAYOUT_VERIFIED, status: "empty" };
432
+ const safeBuckets = Number.isFinite(buckets) && buckets > 0 ? buckets : 32;
420
433
  try {
421
434
  const data = qUncompress(buf);
422
435
  if (data.length === 0)
@@ -431,7 +444,7 @@ export function summariseWaveform(buf, buckets = 32, durationSeconds = null) {
431
444
  // bytes(): bounds-checked, so a count larger than the blob is refused
432
445
  // here rather than producing a silently short waveform.
433
446
  const points = r.bytes(entries * 3);
434
- const size = Math.max(1, Math.ceil(entries / Math.max(1, buckets)));
447
+ const size = Math.max(1, Math.ceil(entries / safeBuckets));
435
448
  const profile = [];
436
449
  for (let i = 0; i < entries; i += size) {
437
450
  let peak = 0;
package/dist/discovery.js CHANGED
@@ -2,13 +2,25 @@ import { existsSync, readdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
- import { err } from "./errors.js";
5
+ import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
6
+ import { hasHotJournal } from "./store/connections.js";
6
7
  import { libraryCandidates } from "./paths.js";
7
8
  export const SUPPORTED_SCHEMAS = ["3.0.0", "3.0.1", "3.0.2"];
8
9
  export function readLibraryInfo(mdbPath) {
9
10
  if (!existsSync(mdbPath)) {
10
11
  return err("library_not_found", `No Engine library database at ${mdbPath}`);
11
12
  }
13
+ if (hasHotJournal(mdbPath)) {
14
+ // Same check openQueryConnection makes before opening (store/connections.ts):
15
+ // recovering a hot journal needs a write, which this project never
16
+ // performs, even to probe a library. Caught here first so the specific,
17
+ // actionable library_needs_recovery reaches the caller instead of the
18
+ // SELECT below failing with the raw "attempt to write a readonly
19
+ // database" and landing in the generic library_unreadable catch --
20
+ // that conflation is exactly what made this condition hard to diagnose
21
+ // in practice.
22
+ return libraryNeedsRecovery();
23
+ }
12
24
  let db;
13
25
  try {
14
26
  // A plain path, never a hand-built "file:" URI. SQLite's URI syntax
@@ -39,8 +51,9 @@ export function readLibraryInfo(mdbPath) {
39
51
  // with Number(); nothing here forces an oversized column through it.
40
52
  stmt.setReadBigInts(true);
41
53
  const row = stmt.get();
54
+ // Not a version problem -- there is no row to read a version from.
42
55
  if (!row)
43
- return err("unsupported_schema", "Information table is empty");
56
+ return err("library_unreadable", "Information table is empty");
44
57
  const schema = [
45
58
  Number(row.schemaVersionMajor ?? 0),
46
59
  Number(row.schemaVersionMinor ?? 0),
@@ -57,7 +70,12 @@ export function readLibraryInfo(mdbPath) {
57
70
  return { path: mdbPath, uuid: String(row.uuid ?? ""), schema, supported, trackCount };
58
71
  }
59
72
  catch (e) {
60
- return err("unsupported_schema", "Could not read Information", {
73
+ // Whatever this is -- corruption, a permissions problem, a lock the
74
+ // open survived but the read did not -- it was never about the schema
75
+ // version: that is only known once this SELECT has actually returned a
76
+ // row, which it did not. The hot-journal case is carved out above, so
77
+ // this is the genuine remainder.
78
+ return err("library_unreadable", "Could not read Information", {
61
79
  detail: String(e.message),
62
80
  });
63
81
  }
@@ -91,7 +109,7 @@ export function probeLibraries(roots = defaultRoots()) {
91
109
  if (!existsSync(candidate))
92
110
  continue;
93
111
  const info = readLibraryInfo(candidate);
94
- out.push("error" in info
112
+ out.push(isEngineError(info)
95
113
  ? { path: candidate, info: null, error: info }
96
114
  : { path: candidate, info, error: null });
97
115
  }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
1
+ export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
package/dist/errors.js CHANGED
@@ -1,6 +1,17 @@
1
1
  export const ERROR_CODES = [
2
2
  "library_busy",
3
3
  "library_not_found",
4
+ // The library was found and opened, but a read against it failed for a
5
+ // reason that has nothing to do with schema version -- corruption, a
6
+ // permissions problem, an oversized column node:sqlite refuses to convert.
7
+ // Distinct from unsupported_schema (below), which is specifically "this
8
+ // version is outside the allowlist", and from library_needs_recovery,
9
+ // which is specifically a hot journal: discovery.ts checks for that first
10
+ // and reports it precisely, so this is only the remainder. Previously
11
+ // every one of these landed on unsupported_schema, which sent debugging
12
+ // toward "check the schema version" for a failure that was never about
13
+ // the schema at all.
14
+ "library_unreadable",
4
15
  "unsupported_schema",
5
16
  "query_timeout",
6
17
  "query_process_crashed",
package/dist/server.d.ts CHANGED
@@ -4,9 +4,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  * design (a permissions error on one candidate must not blank out every
5
5
  * other one). That means a hot journal on the *only* library on this
6
6
  * machine looks identical to no library existing at all -- both come back
7
- * as an empty list, verified: opening it raises "attempt to write a
8
- * readonly database", which readLibraryInfo currently folds into
9
- * unsupported_schema and then drops entirely.
7
+ * as an empty list, verified: readLibraryInfo (discovery.ts) does report a
8
+ * hot journal precisely, as library_needs_recovery, but discoverLibraries()
9
+ * still drops it along with every other unreadable candidate, by that same
10
+ * design.
10
11
  *
11
12
  * This walks the same candidate paths independently, purely to tell those
12
13
  * two cases apart, so `ready()` below can report library_needs_recovery
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/server.ts
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { join } from "node:path";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -18,6 +18,21 @@ import { listLibraries } from "./tools/libraries.js";
18
18
  import { refreshIndex } from "./tools/refresh.js";
19
19
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
20
20
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
21
+ /**
22
+ * name/version reported to every client on initialize. Read from
23
+ * package.json rather than typed here, so the two cannot re-diverge the way
24
+ * they already have once (this constructor shipped 0.1.0 while package.json
25
+ * said 0.9.0).
26
+ *
27
+ * Resolved via import.meta.url, one directory up from this module, not by
28
+ * relative path from cwd: this runs under `npx` from an arbitrary working
29
+ * directory, and package.json sits next to dist/ (this module's compiled
30
+ * location) in both the repo (src/../package.json) and the installed
31
+ * layout (dist/../package.json) -- package.json is always included in the
32
+ * published tarball regardless of the "files" field, so this path exists
33
+ * in both places even though "files" lists only "dist".
34
+ */
35
+ const PACKAGE_INFO = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
36
  /**
22
37
  * Appended to every tool description that takes a `library`. The argument's
23
38
  * own schema description (see library-select.ts) is the authoritative text;
@@ -39,9 +54,10 @@ function reply(value) {
39
54
  * design (a permissions error on one candidate must not blank out every
40
55
  * other one). That means a hot journal on the *only* library on this
41
56
  * machine looks identical to no library existing at all -- both come back
42
- * as an empty list, verified: opening it raises "attempt to write a
43
- * readonly database", which readLibraryInfo currently folds into
44
- * unsupported_schema and then drops entirely.
57
+ * as an empty list, verified: readLibraryInfo (discovery.ts) does report a
58
+ * hot journal precisely, as library_needs_recovery, but discoverLibraries()
59
+ * still drops it along with every other unreadable candidate, by that same
60
+ * design.
45
61
  *
46
62
  * This walks the same candidate paths independently, purely to tell those
47
63
  * two cases apart, so `ready()` below can report library_needs_recovery
@@ -59,7 +75,7 @@ export function findHotJournalCandidate(roots) {
59
75
  return null;
60
76
  }
61
77
  export async function createServer(opts = {}) {
62
- const server = new McpServer({ name: "engine-dj-mcp", version: "0.1.0" });
78
+ const server = new McpServer({ name: PACKAGE_INFO.name, version: PACKAGE_INFO.version });
63
79
  const libs = discoverLibraries(opts.roots);
64
80
  // Shared by every tool for the "no primary library" case, so refresh_index
65
81
  // cannot end up as the one call site that still flattens a hot journal
@@ -205,18 +221,30 @@ export async function createServer(opts = {}) {
205
221
  * null, not as "generation zero". A library nobody has queried yet has no
206
222
  * IndexManager at all and reports null for the same reason: listing the
207
223
  * libraries must not fork a query child per drive to fill in a number.
224
+ *
225
+ * Reads each known IndexManager's generation via peekGeneration(), not
226
+ * ensureFresh(): ensureFresh() also rebuilds when the library has
227
+ * changed, which is exactly right for a tool that is about to query the
228
+ * index and exactly wrong here. list_libraries is what a user reaches
229
+ * for when something looks broken, and list_libraries re-scans on every
230
+ * call (see below) -- so making it pay for a first, or renewed, index
231
+ * build on a big or currently-locked library would make the one
232
+ * diagnostic tool that must stay fast the one most likely to block.
233
+ * peekGeneration() only reads the sidecar already on disk, so this stays
234
+ * honest (a real, current generation number, never a fabricated one) and
235
+ * never forces work list_libraries does not itself need to answer.
208
236
  */
209
- const libraryReport = async (discovered) => {
237
+ const libraryReport = (discovered) => {
210
238
  const generations = new Map();
211
239
  for (const state of states.values()) {
212
- await state.mgr.ensureFresh(); // best effort: keeps a live library's generation accurate
213
- if (state.mgr.generation > 0)
214
- generations.set(state.lib.uuid, state.mgr.generation);
240
+ const generation = state.mgr.peekGeneration();
241
+ if (generation > 0)
242
+ generations.set(state.lib.uuid, generation);
215
243
  }
216
244
  return listLibraries(generations, discovered);
217
245
  };
218
246
  server.registerResource("schema", "engine://schema", { title: "Engine DJ schema and semantics", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: SCHEMA_NOTE }] }));
219
- server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await libraryReport(libs), null, 2) }] }));
247
+ server.registerResource("libraries", "engine://libraries", { title: "Discovered Engine DJ libraries", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(libraryReport(libs), null, 2) }] }));
220
248
  server.registerTool("search_tracks", {
221
249
  title: "Search tracks",
222
250
  description: "Search the Engine DJ library by text, tempo, key, rating, play history and analysis " +
@@ -320,7 +348,7 @@ export async function createServer(opts = {}) {
320
348
  "library; without it they use the supported library holding the most tracks.",
321
349
  inputSchema: {},
322
350
  annotations: RO,
323
- }, async () => reply(await libraryReport(rescanLibraries())));
351
+ }, async () => reply(libraryReport(rescanLibraries())));
324
352
  server.registerTool("refresh_index", {
325
353
  title: "Refresh the search index",
326
354
  description: "Rebuild the search index if the library has changed. " + LIBRARY_SELECTION_NOTE,
@@ -25,5 +25,17 @@ export declare class IndexManager {
25
25
  constructor(lib: LibraryInfo, qp: QueryProcess, baseDir?: string);
26
26
  get generation(): number;
27
27
  get path(): string;
28
+ /**
29
+ * The generation of whatever index currently exists on disk, read
30
+ * directly from the sidecar's own index_meta table -- never built,
31
+ * rebuilt or attached. This is the cheap half of ensureFresh() (the part
32
+ * that does not open the main library at all), used by list_libraries so
33
+ * that asking "what libraries do I have" is never the call that pays for
34
+ * a first (or renewed) index build: that cost belongs to whichever tool
35
+ * call actually needs a fresh index, through ensureFresh(). Returns 0,
36
+ * the same "not a real generation" value ensureFresh() reports before
37
+ * ever building one, when no usable sidecar exists yet.
38
+ */
39
+ peekGeneration(): number;
28
40
  ensureFresh(): Promise<FreshResult | EngineError>;
29
41
  }
@@ -33,6 +33,21 @@ export class IndexManager {
33
33
  get path() {
34
34
  return join(this.baseDir, this.lib.uuid, "index.db");
35
35
  }
36
+ /**
37
+ * The generation of whatever index currently exists on disk, read
38
+ * directly from the sidecar's own index_meta table -- never built,
39
+ * rebuilt or attached. This is the cheap half of ensureFresh() (the part
40
+ * that does not open the main library at all), used by list_libraries so
41
+ * that asking "what libraries do I have" is never the call that pays for
42
+ * a first (or renewed) index build: that cost belongs to whichever tool
43
+ * call actually needs a fresh index, through ensureFresh(). Returns 0,
44
+ * the same "not a real generation" value ensureFresh() reports before
45
+ * ever building one, when no usable sidecar exists yet.
46
+ */
47
+ peekGeneration() {
48
+ this.#storedCounter();
49
+ return this.#generation;
50
+ }
36
51
  /**
37
52
  * The change counter the sidecar on disk was built from, or null when
38
53
  * there is no usable sidecar — which forces a rebuild.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Read-only MCP server for searching and auditing an Engine DJ library. Not affiliated with inMusic or Denon DJ.",
5
5
  "keywords": [
6
6
  "mcp",