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.
- package/dist/blobs/index.d.ts +12 -0
- package/dist/blobs/index.js +14 -1
- package/dist/discovery.js +22 -4
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +11 -0
- package/dist/server.d.ts +4 -3
- package/dist/server.js +39 -11
- package/dist/store/index-manager.d.ts +12 -0
- package/dist/store/index-manager.js +15 -0
- package/package.json +1 -1
package/dist/blobs/index.d.ts
CHANGED
|
@@ -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 {
|
package/dist/blobs/index.js
CHANGED
|
@@ -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 /
|
|
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("
|
|
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
|
-
|
|
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(
|
|
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:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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:
|
|
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 =
|
|
237
|
+
const libraryReport = (discovered) => {
|
|
210
238
|
const generations = new Map();
|
|
211
239
|
for (const state of states.values()) {
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
generations.set(state.lib.uuid,
|
|
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(
|
|
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(
|
|
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.
|