engine-dj-mcp 0.9.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/blobs/index.d.ts +337 -0
  4. package/dist/blobs/index.js +483 -0
  5. package/dist/blobs/qcompress.d.ts +44 -0
  6. package/dist/blobs/qcompress.js +146 -0
  7. package/dist/discovery.d.ts +36 -0
  8. package/dist/discovery.js +111 -0
  9. package/dist/errors.d.ts +30 -0
  10. package/dist/errors.js +49 -0
  11. package/dist/guard.d.ts +31 -0
  12. package/dist/guard.js +236 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +13 -0
  15. package/dist/library-select.d.ts +63 -0
  16. package/dist/library-select.js +97 -0
  17. package/dist/paths.d.ts +18 -0
  18. package/dist/paths.js +34 -0
  19. package/dist/probe.d.ts +7 -0
  20. package/dist/probe.js +20 -0
  21. package/dist/proc/query-client.d.ts +36 -0
  22. package/dist/proc/query-client.js +249 -0
  23. package/dist/proc/query-worker.d.ts +1 -0
  24. package/dist/proc/query-worker.js +72 -0
  25. package/dist/semantics.d.ts +43 -0
  26. package/dist/semantics.js +95 -0
  27. package/dist/server.d.ts +31 -0
  28. package/dist/server.js +439 -0
  29. package/dist/sidecar/build.d.ts +19 -0
  30. package/dist/sidecar/build.js +85 -0
  31. package/dist/sidecar/schema.d.ts +25 -0
  32. package/dist/sidecar/schema.js +36 -0
  33. package/dist/store/connections.d.ts +28 -0
  34. package/dist/store/connections.js +116 -0
  35. package/dist/store/index-manager.d.ts +29 -0
  36. package/dist/store/index-manager.js +187 -0
  37. package/dist/tools/audit.d.ts +15 -0
  38. package/dist/tools/audit.js +148 -0
  39. package/dist/tools/libraries.d.ts +40 -0
  40. package/dist/tools/libraries.js +30 -0
  41. package/dist/tools/performance.d.ts +15 -0
  42. package/dist/tools/performance.js +47 -0
  43. package/dist/tools/refresh.d.ts +8 -0
  44. package/dist/tools/refresh.js +3 -0
  45. package/dist/tools/search.d.ts +60 -0
  46. package/dist/tools/search.js +328 -0
  47. package/dist/tools/sql.d.ts +14 -0
  48. package/dist/tools/sql.js +21 -0
  49. package/dist/tools/tracks.d.ts +12 -0
  50. package/dist/tools/tracks.js +49 -0
  51. package/package.json +53 -0
@@ -0,0 +1,36 @@
1
+ import { type EngineError } from "./errors.js";
2
+ export declare const SUPPORTED_SCHEMAS: readonly ["3.0.0", "3.0.1", "3.0.2"];
3
+ export interface LibraryInfo {
4
+ path: string;
5
+ uuid: string;
6
+ schema: [number, number, number];
7
+ supported: boolean;
8
+ trackCount: number | null;
9
+ }
10
+ export declare function readLibraryInfo(mdbPath: string): LibraryInfo | EngineError;
11
+ export declare function defaultRoots(): string[];
12
+ /** One candidate path's read outcome: either a readable library, or why it
13
+ * currently is not. `path` is always the candidate location, even on
14
+ * failure, so a caller can correlate this against a previous successful
15
+ * probe of the same path. */
16
+ export interface LibraryProbe {
17
+ path: string;
18
+ info: LibraryInfo | null;
19
+ error: EngineError | null;
20
+ }
21
+ /**
22
+ * Walks the same candidate paths as discoverLibraries(), but -- unlike it --
23
+ * reports every candidate that exists on disk, including ones readLibraryInfo
24
+ * could not read right now. discoverLibraries() drops those by design (see
25
+ * below); this is for a caller that needs to tell "not there" apart from
26
+ * "there but currently unreadable", e.g. to keep reporting a library that
27
+ * was seen before while Engine DJ holds a write lock on it.
28
+ */
29
+ export declare function probeLibraries(roots?: string[]): LibraryProbe[];
30
+ /**
31
+ * Reports only libraries it could actually read, by design: a permissions
32
+ * error (or a mid-write lock) on one candidate must not blank out every
33
+ * other one. Built on probeLibraries(); see that function for a version that
34
+ * also reports what could not be read and why.
35
+ */
36
+ export declare function discoverLibraries(roots?: string[]): LibraryInfo[];
@@ -0,0 +1,111 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { err } from "./errors.js";
6
+ import { libraryCandidates } from "./paths.js";
7
+ export const SUPPORTED_SCHEMAS = ["3.0.0", "3.0.1", "3.0.2"];
8
+ export function readLibraryInfo(mdbPath) {
9
+ if (!existsSync(mdbPath)) {
10
+ return err("library_not_found", `No Engine library database at ${mdbPath}`);
11
+ }
12
+ let db;
13
+ try {
14
+ // A plain path, never a hand-built "file:" URI. SQLite's URI syntax
15
+ // treats `#` and `?` as delimiters, so a library under a folder named
16
+ // `Rock 'n' Roll #1 Mix` truncated at the `#` and failed to open --
17
+ // discoverLibraries then dropped it and the server reported
18
+ // library_not_found, while openQueryConnection on the same file worked.
19
+ // The `readOnly` flag is the actual guarantee; the `?mode=ro` in the URI
20
+ // was redundant with it, and duplicated store/connections.ts's escaping
21
+ // rules badly enough to drift.
22
+ db = new DatabaseSync(mdbPath, { readOnly: true });
23
+ }
24
+ catch (e) {
25
+ return err("library_busy", "Could not open the Engine library", {
26
+ detail: String(e.message),
27
+ retry_after_ms: 5000,
28
+ });
29
+ }
30
+ try {
31
+ // SELECT * on purpose: the Information column set differs between versions.
32
+ const stmt = db.prepare("SELECT * FROM Information LIMIT 1");
33
+ // Information.currentPlayedIndiciator is a 64-bit value on a real
34
+ // library (measured: -8676408967926364917, far outside
35
+ // Number.MAX_SAFE_INTEGER), and node:sqlite throws instead of returning
36
+ // it unless a statement opts into BigInt reads. Without this, SELECT *
37
+ // threw on every real library and discoverLibraries() silently dropped
38
+ // all of them. Only the small schema/id fields below are ever converted
39
+ // with Number(); nothing here forces an oversized column through it.
40
+ stmt.setReadBigInts(true);
41
+ const row = stmt.get();
42
+ if (!row)
43
+ return err("unsupported_schema", "Information table is empty");
44
+ const schema = [
45
+ Number(row.schemaVersionMajor ?? 0),
46
+ Number(row.schemaVersionMinor ?? 0),
47
+ Number(row.schemaVersionPatch ?? 0),
48
+ ];
49
+ const supported = SUPPORTED_SCHEMAS.includes(schema.join("."));
50
+ let trackCount = null;
51
+ try {
52
+ trackCount = Number(db.prepare("SELECT COUNT(*) c FROM Track").get().c);
53
+ }
54
+ catch {
55
+ trackCount = null; // 1.x has no Track table; still listable.
56
+ }
57
+ return { path: mdbPath, uuid: String(row.uuid ?? ""), schema, supported, trackCount };
58
+ }
59
+ catch (e) {
60
+ return err("unsupported_schema", "Could not read Information", {
61
+ detail: String(e.message),
62
+ });
63
+ }
64
+ finally {
65
+ db.close();
66
+ }
67
+ }
68
+ export function defaultRoots() {
69
+ const roots = [join(homedir(), "Music")];
70
+ try {
71
+ for (const vol of readdirSync("/Volumes"))
72
+ roots.push(join("/Volumes", vol));
73
+ }
74
+ catch {
75
+ // /Volumes does not exist off macOS; ignore.
76
+ }
77
+ return roots;
78
+ }
79
+ /**
80
+ * Walks the same candidate paths as discoverLibraries(), but -- unlike it --
81
+ * reports every candidate that exists on disk, including ones readLibraryInfo
82
+ * could not read right now. discoverLibraries() drops those by design (see
83
+ * below); this is for a caller that needs to tell "not there" apart from
84
+ * "there but currently unreadable", e.g. to keep reporting a library that
85
+ * was seen before while Engine DJ holds a write lock on it.
86
+ */
87
+ export function probeLibraries(roots = defaultRoots()) {
88
+ const out = [];
89
+ for (const root of roots) {
90
+ for (const candidate of libraryCandidates(root)) {
91
+ if (!existsSync(candidate))
92
+ continue;
93
+ const info = readLibraryInfo(candidate);
94
+ out.push("error" in info
95
+ ? { path: candidate, info: null, error: info }
96
+ : { path: candidate, info, error: null });
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ /**
102
+ * Reports only libraries it could actually read, by design: a permissions
103
+ * error (or a mid-write lock) on one candidate must not blank out every
104
+ * other one. Built on probeLibraries(); see that function for a version that
105
+ * also reports what could not be read and why.
106
+ */
107
+ export function discoverLibraries(roots = defaultRoots()) {
108
+ return probeLibraries(roots)
109
+ .map((p) => p.info)
110
+ .filter((info) => info !== null);
111
+ }
@@ -0,0 +1,30 @@
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"];
2
+ export type ErrorCode = (typeof ERROR_CODES)[number];
3
+ export interface EngineError {
4
+ error: ErrorCode;
5
+ message: string;
6
+ detail?: string;
7
+ retry_after_ms?: number;
8
+ }
9
+ export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
10
+ /**
11
+ * The single source for this text. It was previously written out by hand in
12
+ * three files, and the one place that built a structured error (connections.ts)
13
+ * threw away everything but `.message` -- forcing query-client.ts to re-stat
14
+ * the disk to work out which condition it was looking at.
15
+ */
16
+ export declare const LIBRARY_NEEDS_RECOVERY_MESSAGE: string;
17
+ export declare function libraryNeedsRecovery(): EngineError;
18
+ /**
19
+ * An EngineError travelling as an exception, for the one place that has to
20
+ * throw: openQueryConnection runs inside the forked worker, where a return
21
+ * value has nowhere to go. The structured error rides along intact --
22
+ * across the IPC boundary too, since the worker forwards `engineError` in
23
+ * its startup-failure message -- so no caller has to re-derive the condition
24
+ * by string-matching a message or by going back to the filesystem.
25
+ */
26
+ export declare class EngineErrorException extends Error {
27
+ readonly engineError: EngineError;
28
+ constructor(engineError: EngineError);
29
+ }
30
+ export declare function isEngineError(value: unknown): value is EngineError;
package/dist/errors.js ADDED
@@ -0,0 +1,49 @@
1
+ export const ERROR_CODES = [
2
+ "library_busy",
3
+ "library_not_found",
4
+ "unsupported_schema",
5
+ "query_timeout",
6
+ "query_process_crashed",
7
+ "index_stale",
8
+ "decode_failed",
9
+ "invalid_argument",
10
+ "library_needs_recovery",
11
+ ];
12
+ export function err(error, message, extra = {}) {
13
+ return { error, message, ...extra };
14
+ }
15
+ /**
16
+ * The single source for this text. It was previously written out by hand in
17
+ * three files, and the one place that built a structured error (connections.ts)
18
+ * threw away everything but `.message` -- forcing query-client.ts to re-stat
19
+ * the disk to work out which condition it was looking at.
20
+ */
21
+ export const LIBRARY_NEEDS_RECOVERY_MESSAGE = "The Engine library was closed uncleanly and has an unrecovered journal. " +
22
+ "Launch Engine DJ once so it can recover the library, then retry.";
23
+ export function libraryNeedsRecovery() {
24
+ return err("library_needs_recovery", LIBRARY_NEEDS_RECOVERY_MESSAGE);
25
+ }
26
+ /**
27
+ * An EngineError travelling as an exception, for the one place that has to
28
+ * throw: openQueryConnection runs inside the forked worker, where a return
29
+ * value has nowhere to go. The structured error rides along intact --
30
+ * across the IPC boundary too, since the worker forwards `engineError` in
31
+ * its startup-failure message -- so no caller has to re-derive the condition
32
+ * by string-matching a message or by going back to the filesystem.
33
+ */
34
+ export class EngineErrorException extends Error {
35
+ engineError;
36
+ constructor(engineError) {
37
+ super(engineError.message);
38
+ this.engineError = engineError;
39
+ this.name = "EngineErrorException";
40
+ }
41
+ }
42
+ export function isEngineError(value) {
43
+ if (typeof value !== "object" || value === null)
44
+ return false;
45
+ const v = value;
46
+ return (typeof v.message === "string" &&
47
+ typeof v.error === "string" &&
48
+ ERROR_CODES.includes(v.error));
49
+ }
@@ -0,0 +1,31 @@
1
+ import { type EngineError } from "./errors.js";
2
+ /**
3
+ * run_sql executes through prepare(), which ignores everything after the first
4
+ * semicolon. exec() runs every statement and would let "SELECT 1; VACUUM INTO"
5
+ * slip past a leading-statement check, so run_sql must never use it. We still
6
+ * reject chained statements outright, because a query that relies on the tail
7
+ * being dropped is a query whose author misunderstood what will run.
8
+ */
9
+ export declare function checkStatement(sql: string): EngineError | null;
10
+ /**
11
+ * Wraps rather than negotiates with the inner query. Appending a LIMIT only
12
+ * when the scanner found none at the top level let any caller-supplied
13
+ * LIMIT satisfy the check regardless of its size -- including one nested
14
+ * inside a subquery, which this scanner cannot distinguish from a
15
+ * top-level LIMIT at all (it has no parenthesis-depth tracking, by design,
16
+ * since it only needs to find keywords and quoted spans). For example,
17
+ * "SELECT * FROM Track WHERE id IN (SELECT id FROM Track LIMIT 1)" reads
18
+ * as "already limited" and was left untouched, however many rows the outer
19
+ * WHERE actually matched.
20
+ *
21
+ * Composing "SELECT * FROM (<sql>) LIMIT n" bounds the result no matter
22
+ * what the inner statement contains, because the wrapper is the outermost
23
+ * statement executed. A genuinely smaller inner LIMIT still wins: SQLite
24
+ * applies it to the subquery first, so fewer than n rows ever reach the
25
+ * wrapper's own LIMIT.
26
+ *
27
+ * Only SELECT and WITH can be wrapped this way -- PRAGMA (the only other
28
+ * statement checkStatement allows through) would become invalid SQL if
29
+ * wrapped, which is why the leading keyword is still checked here at all.
30
+ */
31
+ export declare function enforceLimit(sql: string, limit: number): string;
package/dist/guard.js ADDED
@@ -0,0 +1,236 @@
1
+ import { err } from "./errors.js";
2
+ /**
3
+ * Scans a SQL statement character by character, tracking string literals,
4
+ * identifiers, and comments. Returns analysis needed for validation.
5
+ */
6
+ function scanStatement(sql) {
7
+ let i = 0;
8
+ let firstTokenIndex = -1;
9
+ let hasChainedStatement = false;
10
+ let semicolonCount = 0;
11
+ let lastSemicolonIndex = -1;
12
+ while (i < sql.length) {
13
+ const ch = sql[i];
14
+ // Skip whitespace
15
+ if (/\s/.test(ch)) {
16
+ i++;
17
+ continue;
18
+ }
19
+ // Line comment: -- to newline
20
+ if (ch === "-" && sql[i + 1] === "-") {
21
+ i += 2;
22
+ while (i < sql.length && sql[i] !== "\n")
23
+ i++;
24
+ if (i < sql.length)
25
+ i++; // skip newline
26
+ continue;
27
+ }
28
+ // Block comment: /* to */
29
+ if (ch === "/" && sql[i + 1] === "*") {
30
+ i += 2;
31
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/"))
32
+ i++;
33
+ if (i < sql.length)
34
+ i += 2; // skip */
35
+ continue;
36
+ }
37
+ // Single-quoted string: '...' with '' as escaped quote
38
+ if (ch === "'") {
39
+ i++;
40
+ while (i < sql.length) {
41
+ if (sql[i] === "'") {
42
+ if (sql[i + 1] === "'") {
43
+ i += 2; // escaped quote ''
44
+ }
45
+ else {
46
+ i++; // end of string
47
+ break;
48
+ }
49
+ }
50
+ else {
51
+ i++;
52
+ }
53
+ }
54
+ continue;
55
+ }
56
+ // Double-quoted identifier: "..." with "" as escaped quote
57
+ if (ch === '"') {
58
+ i++;
59
+ while (i < sql.length) {
60
+ if (sql[i] === '"') {
61
+ if (sql[i + 1] === '"') {
62
+ i += 2; // escaped quote ""
63
+ }
64
+ else {
65
+ i++; // end of identifier
66
+ break;
67
+ }
68
+ }
69
+ else {
70
+ i++;
71
+ }
72
+ }
73
+ continue;
74
+ }
75
+ // Backtick-quoted identifier: `...`
76
+ if (ch === "`") {
77
+ i++;
78
+ while (i < sql.length && sql[i] !== "`")
79
+ i++;
80
+ if (i < sql.length)
81
+ i++; // skip closing backtick
82
+ continue;
83
+ }
84
+ // Bracket-quoted identifier: [...]
85
+ if (ch === "[") {
86
+ i++;
87
+ while (i < sql.length && sql[i] !== "]")
88
+ i++;
89
+ if (i < sql.length)
90
+ i++; // skip closing bracket
91
+ continue;
92
+ }
93
+ // Semicolon - statement separator (top-level, outside quotes/comments)
94
+ if (ch === ";") {
95
+ semicolonCount++;
96
+ lastSemicolonIndex = i;
97
+ i++;
98
+ continue;
99
+ }
100
+ // Real token found
101
+ if (firstTokenIndex === -1) {
102
+ firstTokenIndex = i;
103
+ }
104
+ const restOfStatement = sql.substring(i);
105
+ const wordMatch = restOfStatement.match(/^([a-zA-Z_]\w*)/);
106
+ // Skip this token
107
+ if (wordMatch) {
108
+ i += wordMatch[1].length;
109
+ }
110
+ else {
111
+ i++;
112
+ }
113
+ }
114
+ // Check for statement chaining: more than one semicolon, or real content after semicolon (besides whitespace/comments)
115
+ // We allow one optional trailing semicolon
116
+ if (semicolonCount > 1) {
117
+ hasChainedStatement = true;
118
+ }
119
+ else if (semicolonCount === 1 && lastSemicolonIndex !== -1) {
120
+ // Check if there's real content after the top-level semicolon (besides whitespace/comments)
121
+ const afterSemicolon = sql.substring(lastSemicolonIndex + 1);
122
+ let j = 0;
123
+ while (j < afterSemicolon.length) {
124
+ const c = afterSemicolon[j];
125
+ if (/\s/.test(c)) {
126
+ j++;
127
+ continue;
128
+ }
129
+ if (c === "-" && afterSemicolon[j + 1] === "-") {
130
+ j += 2;
131
+ while (j < afterSemicolon.length && afterSemicolon[j] !== "\n")
132
+ j++;
133
+ continue;
134
+ }
135
+ if (c === "/" && afterSemicolon[j + 1] === "*") {
136
+ j += 2;
137
+ while (j < afterSemicolon.length && !(afterSemicolon[j] === "*" && afterSemicolon[j + 1] === "/"))
138
+ j++;
139
+ j += 2;
140
+ continue;
141
+ }
142
+ // Found real content after semicolon
143
+ hasChainedStatement = true;
144
+ break;
145
+ }
146
+ }
147
+ return { firstTokenIndex, hasChainedStatement, lastSemicolonIndex };
148
+ }
149
+ /**
150
+ * run_sql executes through prepare(), which ignores everything after the first
151
+ * semicolon. exec() runs every statement and would let "SELECT 1; VACUUM INTO"
152
+ * slip past a leading-statement check, so run_sql must never use it. We still
153
+ * reject chained statements outright, because a query that relies on the tail
154
+ * being dropped is a query whose author misunderstood what will run.
155
+ */
156
+ export function checkStatement(sql) {
157
+ const scan = scanStatement(sql);
158
+ // Reject chained statements
159
+ if (scan.hasChainedStatement) {
160
+ return err("invalid_argument", "Only a single SQL statement is allowed", { detail: sql });
161
+ }
162
+ // Extract first keyword
163
+ if (scan.firstTokenIndex === -1) {
164
+ return null; // Empty or only comments/whitespace - allow
165
+ }
166
+ const rest = sql.substring(scan.firstTokenIndex);
167
+ const keywordMatch = rest.match(/^([a-zA-Z_]\w*)/);
168
+ if (!keywordMatch)
169
+ return null;
170
+ const keyword = keywordMatch[1].toUpperCase();
171
+ // Reject VACUUM, ATTACH, DETACH
172
+ if (keyword === "VACUUM" || keyword === "ATTACH" || keyword === "DETACH") {
173
+ return err("invalid_argument", "VACUUM, ATTACH and DETACH are not permitted", { detail: sql });
174
+ }
175
+ // Check PRAGMA statements
176
+ if (keyword === "PRAGMA") {
177
+ // Find the pragma name - skip past PRAGMA keyword and whitespace
178
+ let pragmaStart = scan.firstTokenIndex + 6; // length of "PRAGMA"
179
+ while (pragmaStart < sql.length && /\s/.test(sql[pragmaStart]))
180
+ pragmaStart++;
181
+ // Skip optional schema qualifier (main., temp., side., engine.)
182
+ if (pragmaStart < sql.length) {
183
+ const schemaMatch = sql.substring(pragmaStart).match(/^(main|temp|side|engine)\./i);
184
+ if (schemaMatch) {
185
+ pragmaStart += schemaMatch[0].length;
186
+ }
187
+ }
188
+ // Extract pragma name
189
+ const pragmaNameMatch = sql.substring(pragmaStart).match(/^([a-zA-Z_]\w*)/);
190
+ if (!pragmaNameMatch)
191
+ return null;
192
+ const pragmaName = pragmaNameMatch[1].toUpperCase();
193
+ const allowedPragmas = ["TABLE_INFO", "TABLE_LIST", "INDEX_LIST", "INDEX_INFO", "FOREIGN_KEY_LIST"];
194
+ if (!allowedPragmas.includes(pragmaName)) {
195
+ return err("invalid_argument", "Only read-only PRAGMA introspection is permitted", { detail: sql });
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ /**
201
+ * Wraps rather than negotiates with the inner query. Appending a LIMIT only
202
+ * when the scanner found none at the top level let any caller-supplied
203
+ * LIMIT satisfy the check regardless of its size -- including one nested
204
+ * inside a subquery, which this scanner cannot distinguish from a
205
+ * top-level LIMIT at all (it has no parenthesis-depth tracking, by design,
206
+ * since it only needs to find keywords and quoted spans). For example,
207
+ * "SELECT * FROM Track WHERE id IN (SELECT id FROM Track LIMIT 1)" reads
208
+ * as "already limited" and was left untouched, however many rows the outer
209
+ * WHERE actually matched.
210
+ *
211
+ * Composing "SELECT * FROM (<sql>) LIMIT n" bounds the result no matter
212
+ * what the inner statement contains, because the wrapper is the outermost
213
+ * statement executed. A genuinely smaller inner LIMIT still wins: SQLite
214
+ * applies it to the subquery first, so fewer than n rows ever reach the
215
+ * wrapper's own LIMIT.
216
+ *
217
+ * Only SELECT and WITH can be wrapped this way -- PRAGMA (the only other
218
+ * statement checkStatement allows through) would become invalid SQL if
219
+ * wrapped, which is why the leading keyword is still checked here at all.
220
+ */
221
+ export function enforceLimit(sql, limit) {
222
+ const scan = scanStatement(sql);
223
+ // Extract first keyword
224
+ if (scan.firstTokenIndex === -1) {
225
+ return sql; // Empty or only comments/whitespace - return unchanged
226
+ }
227
+ const rest = sql.substring(scan.firstTokenIndex);
228
+ const keywordMatch = rest.match(/^([a-zA-Z_]\w*)/);
229
+ if (!keywordMatch)
230
+ return sql;
231
+ const keyword = keywordMatch[1].toUpperCase();
232
+ if (keyword !== "SELECT" && keyword !== "WITH")
233
+ return sql;
234
+ const trimmed = sql.trim().replace(/;\s*$/, "");
235
+ return `SELECT * FROM (${trimmed}) LIMIT ${limit}`;
236
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // src/index.ts
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createServer } from "./server.js";
5
+ async function main() {
6
+ const server = await createServer();
7
+ await server.connect(new StdioServerTransport());
8
+ }
9
+ main().catch((e) => {
10
+ // stderr is not the protocol channel, so this is safe for stdio transport.
11
+ console.error("engine-dj-mcp failed to start:", e);
12
+ process.exit(1);
13
+ });
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import { type EngineError } from "./errors.js";
3
+ import type { LibraryInfo } from "./discovery.js";
4
+ /**
5
+ * The `library` argument every library-touching tool accepts. Optional
6
+ * everywhere: a DJ with one library must never have to name it.
7
+ *
8
+ * Both a uuid and a path are accepted because `list_libraries` reports both,
9
+ * and neither a person nor a model can be expected to know which of the two
10
+ * fields is the "real" identifier. The description says so explicitly --
11
+ * a model reads this string and nothing else before choosing what to pass.
12
+ */
13
+ export declare const LIBRARY_ARG_DESCRIPTION: string;
14
+ export declare const LibraryArg: z.ZodOptional<z.ZodString>;
15
+ /**
16
+ * The default when no `library` was given: the supported library with the
17
+ * most tracks.
18
+ *
19
+ * Root-scan order -- the previous rule -- puts ~/Music ahead of /Volumes,
20
+ * so a DJ whose real collection lives on a USB drive got the near-empty
21
+ * local library that Engine DJ creates on install, with no way to ask for
22
+ * the other one. Track count is the one signal available at discovery time
23
+ * that actually tracks "the library this person works in".
24
+ *
25
+ * Ties break on root-scan order, so the choice is deterministic rather than
26
+ * dependent on Map or filesystem iteration order. `trackCount` is null only
27
+ * when the Track table could not be read at all (a 1.x library); such a
28
+ * library is never `supported`, but treat null as "fewer than zero tracks"
29
+ * anyway so a known count always beats an unknown one.
30
+ *
31
+ * Falls back to the first library of any kind -- including an unsupported
32
+ * one -- so that ensureFresh's specific, actionable `unsupported_schema`
33
+ * reaches the caller instead of the generic `library_not_found` that "no
34
+ * supported library" would otherwise collapse into.
35
+ */
36
+ export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
37
+ /**
38
+ * Resolves a caller-supplied `library` value: uuid first, then filesystem
39
+ * path. Returns null when it matches neither -- the caller decides what
40
+ * kind of error that is, since it also knows what else is (or is not) on
41
+ * this machine.
42
+ *
43
+ * uuid comparison is case- and whitespace-insensitive: Engine writes uuids
44
+ * in one case and a caller may well retype or re-case one. Path comparison
45
+ * goes through expandHome + resolve, so `~/Music/...` (the form
46
+ * list_libraries prints), the absolute form, and a path with a redundant
47
+ * `.` or trailing separator all name the same library.
48
+ *
49
+ * A path match is exact on the m.db file, not a prefix: a value that merely
50
+ * *contains* a library path must not select it.
51
+ */
52
+ export declare function findLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | null;
53
+ /**
54
+ * The error for a `library` value that matched nothing. It names what was
55
+ * passed and lists what is actually selectable, because the two ways to get
56
+ * here -- a typo, and a drive that is no longer mounted -- are told apart by
57
+ * seeing the list, not by being told "not found".
58
+ *
59
+ * Deliberately reuses `library_not_found` rather than introducing a code:
60
+ * the taxonomy is closed, and this is the same condition ("the library you
61
+ * are asking about is not here") arrived at from a different direction.
62
+ */
63
+ export declare function libraryNotFound(requested: string, libs: readonly LibraryInfo[]): EngineError;
@@ -0,0 +1,97 @@
1
+ // src/library-select.ts
2
+ import { resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { err } from "./errors.js";
5
+ import { expandHome, redactPath } from "./paths.js";
6
+ /**
7
+ * The `library` argument every library-touching tool accepts. Optional
8
+ * everywhere: a DJ with one library must never have to name it.
9
+ *
10
+ * Both a uuid and a path are accepted because `list_libraries` reports both,
11
+ * and neither a person nor a model can be expected to know which of the two
12
+ * fields is the "real" identifier. The description says so explicitly --
13
+ * a model reads this string and nothing else before choosing what to pass.
14
+ */
15
+ export const LIBRARY_ARG_DESCRIPTION = "Which library to use: either the uuid or the path reported by list_libraries " +
16
+ "(the reported ~/... form is accepted, as is the absolute path). Omit it to use " +
17
+ "the supported library holding the most tracks.";
18
+ export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
19
+ /**
20
+ * The default when no `library` was given: the supported library with the
21
+ * most tracks.
22
+ *
23
+ * Root-scan order -- the previous rule -- puts ~/Music ahead of /Volumes,
24
+ * so a DJ whose real collection lives on a USB drive got the near-empty
25
+ * local library that Engine DJ creates on install, with no way to ask for
26
+ * the other one. Track count is the one signal available at discovery time
27
+ * that actually tracks "the library this person works in".
28
+ *
29
+ * Ties break on root-scan order, so the choice is deterministic rather than
30
+ * dependent on Map or filesystem iteration order. `trackCount` is null only
31
+ * when the Track table could not be read at all (a 1.x library); such a
32
+ * library is never `supported`, but treat null as "fewer than zero tracks"
33
+ * anyway so a known count always beats an unknown one.
34
+ *
35
+ * Falls back to the first library of any kind -- including an unsupported
36
+ * one -- so that ensureFresh's specific, actionable `unsupported_schema`
37
+ * reaches the caller instead of the generic `library_not_found` that "no
38
+ * supported library" would otherwise collapse into.
39
+ */
40
+ export function pickDefaultLibrary(libs) {
41
+ let best = null;
42
+ for (const lib of libs) {
43
+ if (!lib.supported)
44
+ continue;
45
+ if (best === null || (lib.trackCount ?? -1) > (best.trackCount ?? -1))
46
+ best = lib;
47
+ }
48
+ return best ?? libs[0] ?? null;
49
+ }
50
+ /**
51
+ * Resolves a caller-supplied `library` value: uuid first, then filesystem
52
+ * path. Returns null when it matches neither -- the caller decides what
53
+ * kind of error that is, since it also knows what else is (or is not) on
54
+ * this machine.
55
+ *
56
+ * uuid comparison is case- and whitespace-insensitive: Engine writes uuids
57
+ * in one case and a caller may well retype or re-case one. Path comparison
58
+ * goes through expandHome + resolve, so `~/Music/...` (the form
59
+ * list_libraries prints), the absolute form, and a path with a redundant
60
+ * `.` or trailing separator all name the same library.
61
+ *
62
+ * A path match is exact on the m.db file, not a prefix: a value that merely
63
+ * *contains* a library path must not select it.
64
+ */
65
+ export function findLibrary(libs, requested) {
66
+ const wanted = requested.trim();
67
+ if (!wanted)
68
+ return null;
69
+ const byUuid = libs.find((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
70
+ if (byUuid)
71
+ return byUuid;
72
+ // resolve() turns a relative value into something rooted at the process
73
+ // cwd, which matches no library path -- exactly the intended outcome for
74
+ // a value that is neither a uuid nor a real path.
75
+ const wantedPath = resolve(expandHome(wanted));
76
+ return libs.find((l) => resolve(l.path) === wantedPath) ?? null;
77
+ }
78
+ /**
79
+ * The error for a `library` value that matched nothing. It names what was
80
+ * passed and lists what is actually selectable, because the two ways to get
81
+ * here -- a typo, and a drive that is no longer mounted -- are told apart by
82
+ * seeing the list, not by being told "not found".
83
+ *
84
+ * Deliberately reuses `library_not_found` rather than introducing a code:
85
+ * the taxonomy is closed, and this is the same condition ("the library you
86
+ * are asking about is not here") arrived at from a different direction.
87
+ */
88
+ export function libraryNotFound(requested, libs) {
89
+ const known = libs.filter((l) => l.uuid);
90
+ return err("library_not_found", `No Engine DJ library matches "${requested}"`, {
91
+ detail: known.length
92
+ ? `Known libraries (uuid -- path): ${known
93
+ .map((l) => `${l.uuid} -- ${redactPath(l.path)}`)
94
+ .join("; ")}. Pass a uuid or a path exactly as list_libraries reports it.`
95
+ : "No Engine DJ library was discovered on this machine; call list_libraries to see what is visible.",
96
+ });
97
+ }