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,60 @@
1
+ import { z } from "zod";
2
+ import { type EngineError } from "../errors.js";
3
+ import type { QueryProcess } from "../proc/query-client.js";
4
+ export declare const DEFAULT_FIELDS: readonly ["id", "artist", "title", "bpm", "camelot", "rating"];
5
+ /**
6
+ * Every projected field goes through this allowlist rather than through
7
+ * caller text: the field list becomes part of the SQL (a column name isn't a
8
+ * bindable value), so anything not in here is rejected before it can reach
9
+ * the query. Exported directly (not re-exported later) so a second module
10
+ * validating fields can never drift from this one.
11
+ */
12
+ export declare const FIELD_SQL: Record<string, string>;
13
+ export declare const SearchInput: z.ZodObject<{
14
+ q: z.ZodOptional<z.ZodString>;
15
+ bpm: z.ZodOptional<z.ZodObject<{
16
+ min: z.ZodOptional<z.ZodNumber>;
17
+ max: z.ZodOptional<z.ZodNumber>;
18
+ around: z.ZodOptional<z.ZodNumber>;
19
+ tolerance_pct: z.ZodDefault<z.ZodNumber>;
20
+ }, z.core.$strip>>;
21
+ key: z.ZodOptional<z.ZodObject<{
22
+ camelot: z.ZodOptional<z.ZodArray<z.ZodString>>;
23
+ compatible_with: z.ZodOptional<z.ZodString>;
24
+ mode: z.ZodOptional<z.ZodEnum<{
25
+ major: "major";
26
+ minor: "minor";
27
+ }>>;
28
+ }, z.core.$strip>>;
29
+ rating: z.ZodOptional<z.ZodObject<{
30
+ min: z.ZodOptional<z.ZodNumber>;
31
+ max: z.ZodOptional<z.ZodNumber>;
32
+ }, z.core.$strip>>;
33
+ played: z.ZodOptional<z.ZodObject<{
34
+ never: z.ZodOptional<z.ZodBoolean>;
35
+ before: z.ZodOptional<z.ZodString>;
36
+ after: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strip>>;
38
+ added: z.ZodOptional<z.ZodObject<{
39
+ before: z.ZodOptional<z.ZodString>;
40
+ after: z.ZodOptional<z.ZodString>;
41
+ }, z.core.$strip>>;
42
+ flags: z.ZodOptional<z.ZodObject<{
43
+ analyzed: z.ZodOptional<z.ZodBoolean>;
44
+ has_cues: z.ZodOptional<z.ZodBoolean>;
45
+ has_beatgrid: z.ZodOptional<z.ZodBoolean>;
46
+ available: z.ZodOptional<z.ZodBoolean>;
47
+ }, z.core.$strip>>;
48
+ fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
49
+ limit: z.ZodDefault<z.ZodNumber>;
50
+ cursor: z.ZodOptional<z.ZodString>;
51
+ include_total: z.ZodDefault<z.ZodBoolean>;
52
+ redact_paths: z.ZodDefault<z.ZodBoolean>;
53
+ }, z.core.$strip>;
54
+ export type SearchInput = z.input<typeof SearchInput>;
55
+ export declare function searchTracks(qp: QueryProcess, raw: SearchInput): Promise<{
56
+ tracks: Record<string, unknown>[];
57
+ total?: number;
58
+ total_capped?: boolean;
59
+ next_cursor?: string;
60
+ } | EngineError>;
@@ -0,0 +1,328 @@
1
+ // src/tools/search.ts
2
+ import { z } from "zod";
3
+ import { createHash } from "node:crypto";
4
+ import { err, isEngineError } from "../errors.js";
5
+ import { camelotNeighbours } from "../semantics.js";
6
+ import { redactPath } from "../paths.js";
7
+ export const DEFAULT_FIELDS = ["id", "artist", "title", "bpm", "camelot", "rating"];
8
+ const MAX_LIMIT = 200;
9
+ /**
10
+ * include_total costs ~19x a page (measured: 3.3 ms vs 0.2 ms at 50k tracks),
11
+ * so it is opt-in, and capped rather than exact: a model needs the order of
12
+ * magnitude, not a precise count. Above the cap, total is reported as 1000
13
+ * with total_capped: true, meaning "at least this many" rather than an exact
14
+ * figure — a caller cannot otherwise tell a capped 1000 from a genuine one.
15
+ */
16
+ const TOTAL_CAP = 1000;
17
+ /**
18
+ * Every projected field goes through this allowlist rather than through
19
+ * caller text: the field list becomes part of the SQL (a column name isn't a
20
+ * bindable value), so anything not in here is rejected before it can reach
21
+ * the query. Exported directly (not re-exported later) so a second module
22
+ * validating fields can never drift from this one.
23
+ */
24
+ export const FIELD_SQL = {
25
+ id: "t.id",
26
+ artist: "t.artist",
27
+ title: "t.title",
28
+ album: "t.album",
29
+ genre: "t.genre",
30
+ comment: "t.comment",
31
+ label: "t.label",
32
+ year: "t.year",
33
+ rating: "t.rating",
34
+ length: "t.length",
35
+ path: "t.path",
36
+ filename: "t.filename",
37
+ bpm: "d.tempo",
38
+ camelot: "d.camelot",
39
+ // has_cues means "a hot cue is actually set": the quickCues blob is
40
+ // decoded when the sidecar is built, because Engine writes one to every
41
+ // analysed track whether or not a pad is used (see sidecar/build.ts).
42
+ // has_beatgrid means the beatData blob is present, which on every real
43
+ // track measured is the same thing as an analysed beatgrid.
44
+ has_cues: "d.has_cues",
45
+ has_beatgrid: "d.has_grid",
46
+ date_added: "t.dateAdded",
47
+ last_played: "t.timeLastPlayed",
48
+ is_analyzed: "t.isAnalyzed",
49
+ };
50
+ export const SearchInput = z.object({
51
+ q: z.string().optional(),
52
+ bpm: z
53
+ .object({
54
+ min: z.number().optional(),
55
+ max: z.number().optional(),
56
+ around: z.number().optional(),
57
+ tolerance_pct: z.number().default(3),
58
+ })
59
+ .optional(),
60
+ key: z
61
+ .object({
62
+ camelot: z.array(z.string()).optional(),
63
+ compatible_with: z.string().optional(),
64
+ mode: z.enum(["major", "minor"]).optional(),
65
+ })
66
+ .optional(),
67
+ rating: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
68
+ played: z
69
+ .object({
70
+ never: z.boolean().optional(),
71
+ before: z.string().optional(),
72
+ after: z.string().optional(),
73
+ })
74
+ .optional(),
75
+ added: z.object({ before: z.string().optional(), after: z.string().optional() }).optional(),
76
+ flags: z
77
+ .object({
78
+ analyzed: z.boolean().optional(),
79
+ has_cues: z.boolean().optional(),
80
+ has_beatgrid: z.boolean().optional(),
81
+ available: z.boolean().optional(),
82
+ })
83
+ .optional(),
84
+ fields: z.array(z.string()).optional(),
85
+ limit: z.number().int().positive().default(25),
86
+ cursor: z.string().optional(),
87
+ include_total: z.boolean().default(false),
88
+ redact_paths: z.boolean().default(true),
89
+ });
90
+ /** A date is either ISO-8601 or a SQLite relative modifier such as "-6 months". */
91
+ function epochExpr(value) {
92
+ const trimmed = value.trim();
93
+ return /^-?\d+\s+(second|minute|hour|day|month|year)s?$/i.test(trimmed)
94
+ ? { sql: "strftime('%s','now',?)", param: trimmed }
95
+ : { sql: "strftime('%s',?)", param: trimmed };
96
+ }
97
+ /**
98
+ * FTS5 has its own query grammar: AND/OR/NOT, NEAR(...), column filters
99
+ * (`col:term`), and unbalanced quotes are a hard syntax error, not a
100
+ * no-match. A person's search text is not an FTS5 query program, so each
101
+ * whitespace-separated token is wrapped as its own quoted phrase (embedded
102
+ * `"` doubled) before it reaches MATCH — that makes the operator words inert
103
+ * literal tokens instead of syntax, and keeps the query always well-formed
104
+ * (confirmed by execution: unquoted `Jean-Michel` and `D'Angelo` both raise
105
+ * real FTS5 syntax errors; quoted, both match).
106
+ *
107
+ * A trailing `*` must stay *outside* the closing quote: FTS5 only treats `*`
108
+ * as the prefix operator when it immediately follows an unquoted phrase
109
+ * boundary, so `"hypno*"` searches for the literal three-character string
110
+ * `hypno*` (almost always zero matches) while `"hypno"*` performs the
111
+ * intended prefix match. Confirmed by execution — see task-11-report.md.
112
+ */
113
+ function sanitizeFtsQuery(q) {
114
+ return q
115
+ .trim()
116
+ .split(/\s+/)
117
+ .filter(Boolean)
118
+ .map((token) => {
119
+ const isPrefix = token.endsWith("*") && token.length > 1;
120
+ const core = isPrefix ? token.slice(0, -1) : token;
121
+ const quoted = `"${core.replace(/"/g, '""')}"`;
122
+ return isPrefix ? `${quoted}*` : quoted;
123
+ })
124
+ .join(" ");
125
+ }
126
+ /**
127
+ * A cursor encodes a resume point in one specific ordering — (rank, rowid)
128
+ * under relevance order, or (id, id) otherwise. That tuple means nothing
129
+ * outside the query that produced it: applying it to a search with a
130
+ * different filter set, or crossing between FTS-ordered and id-ordered
131
+ * search, compares the wrong kind of value against the wrong column and
132
+ * pages silently wrong rather than erroring. The fingerprint is a hash over
133
+ * the normalised filter SQL, its bound values, and whether the search is
134
+ * FTS-ordered, so a cursor can be checked against the call it's used with
135
+ * and rejected outright on a mismatch instead of silently mispaging.
136
+ */
137
+ function queryFingerprint(useFts, filterWhere, filterParams) {
138
+ const shape = JSON.stringify({ fts: useFts, where: filterWhere, params: filterParams });
139
+ return createHash("sha256").update(shape).digest("base64url").slice(0, 16);
140
+ }
141
+ function encodeCursor(rank, rowid, fingerprint) {
142
+ return Buffer.from(JSON.stringify([rank, rowid, fingerprint])).toString("base64url");
143
+ }
144
+ function decodeCursor(cursor) {
145
+ try {
146
+ const v = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
147
+ if (!Array.isArray(v) || v.length !== 3)
148
+ return null;
149
+ const [rank, row, fingerprint] = v;
150
+ if (rank !== null && typeof rank !== "number")
151
+ return null;
152
+ if (typeof row !== "number")
153
+ return null;
154
+ if (typeof fingerprint !== "string")
155
+ return null;
156
+ return [rank, row, fingerprint];
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ export async function searchTracks(qp, raw) {
163
+ const input = SearchInput.parse(raw);
164
+ const requestedFields = input.fields ?? [...DEFAULT_FIELDS];
165
+ if (requestedFields.length === 0)
166
+ return err("invalid_argument", "No fields requested");
167
+ const unknownFields = requestedFields.filter((f) => !(f in FIELD_SQL));
168
+ if (unknownFields.length) {
169
+ return err("invalid_argument", `Unknown field(s): ${unknownFields.join(", ")}`, {
170
+ detail: `Recognised fields: ${Object.keys(FIELD_SQL).join(", ")}`,
171
+ });
172
+ }
173
+ const fields = requestedFields;
174
+ const limit = Math.min(input.limit, MAX_LIMIT);
175
+ const useFts = Boolean(input.q && input.q.trim());
176
+ // Filters that scope the result set as a whole (independent of where a
177
+ // page cursor currently sits), so include_total can reuse them without
178
+ // the count shrinking as a caller pages further in, and so the cursor
179
+ // fingerprint below is independent of pagination position too.
180
+ const filterWhere = [];
181
+ const filterParams = [];
182
+ if (useFts) {
183
+ filterWhere.push("f.fts_track MATCH ?");
184
+ filterParams.push(sanitizeFtsQuery(input.q));
185
+ }
186
+ if (input.bpm) {
187
+ // Key and tempo filters go through the indexed side.track_derived
188
+ // columns (d.tempo / d.camelot below), never through the camelot() or
189
+ // tempo() SQL functions: those run a JS callback per row and force a
190
+ // full scan (measured: 4.3 ms vs 0.06 ms at 50k rows).
191
+ const { min, max, around, tolerance_pct } = input.bpm;
192
+ if (around !== undefined) {
193
+ filterWhere.push("d.tempo BETWEEN ? AND ?");
194
+ filterParams.push(around * (1 - tolerance_pct / 100), around * (1 + tolerance_pct / 100));
195
+ }
196
+ if (min !== undefined) {
197
+ filterWhere.push("d.tempo >= ?");
198
+ filterParams.push(min);
199
+ }
200
+ if (max !== undefined) {
201
+ filterWhere.push("d.tempo <= ?");
202
+ filterParams.push(max);
203
+ }
204
+ }
205
+ if (input.key) {
206
+ const labels = new Set(input.key.camelot ?? []);
207
+ if (input.key.compatible_with) {
208
+ for (const n of camelotNeighbours(input.key.compatible_with))
209
+ labels.add(n);
210
+ }
211
+ if (labels.size) {
212
+ filterWhere.push(`d.camelot IN (${[...labels].map(() => "?").join(",")})`);
213
+ filterParams.push(...labels);
214
+ }
215
+ if (input.key.mode) {
216
+ filterWhere.push("d.camelot LIKE ?");
217
+ filterParams.push(input.key.mode === "minor" ? "%A" : "%B");
218
+ }
219
+ }
220
+ if (input.rating?.min !== undefined) {
221
+ filterWhere.push("t.rating >= ?");
222
+ filterParams.push(input.rating.min);
223
+ }
224
+ if (input.rating?.max !== undefined) {
225
+ filterWhere.push("t.rating <= ?");
226
+ filterParams.push(input.rating.max);
227
+ }
228
+ if (input.played?.never)
229
+ filterWhere.push("(t.timeLastPlayed IS NULL OR t.isPlayed = 0)");
230
+ if (input.played?.before) {
231
+ const e = epochExpr(input.played.before);
232
+ filterWhere.push(`(t.timeLastPlayed IS NULL OR t.timeLastPlayed < ${e.sql})`);
233
+ filterParams.push(e.param);
234
+ }
235
+ if (input.played?.after) {
236
+ const e = epochExpr(input.played.after);
237
+ filterWhere.push(`t.timeLastPlayed >= ${e.sql}`);
238
+ filterParams.push(e.param);
239
+ }
240
+ if (input.added?.after) {
241
+ const e = epochExpr(input.added.after);
242
+ filterWhere.push(`t.dateAdded >= ${e.sql}`);
243
+ filterParams.push(e.param);
244
+ }
245
+ if (input.added?.before) {
246
+ const e = epochExpr(input.added.before);
247
+ filterWhere.push(`t.dateAdded < ${e.sql}`);
248
+ filterParams.push(e.param);
249
+ }
250
+ if (input.flags?.analyzed !== undefined) {
251
+ filterWhere.push("t.isAnalyzed = ?");
252
+ filterParams.push(input.flags.analyzed ? 1 : 0);
253
+ }
254
+ if (input.flags?.available !== undefined) {
255
+ filterWhere.push("t.isAvailable = ?");
256
+ filterParams.push(input.flags.available ? 1 : 0);
257
+ }
258
+ if (input.flags?.has_cues !== undefined) {
259
+ filterWhere.push("d.has_cues = ?");
260
+ filterParams.push(input.flags.has_cues ? 1 : 0);
261
+ }
262
+ if (input.flags?.has_beatgrid !== undefined) {
263
+ filterWhere.push("d.has_grid = ?");
264
+ filterParams.push(input.flags.has_beatgrid ? 1 : 0);
265
+ }
266
+ const from = useFts
267
+ ? `FROM side.fts_track f
268
+ JOIN side.fts_map m ON m.rowid = f.rowid
269
+ JOIN main.Track t ON t.id = m.track_id
270
+ JOIN side.track_derived d ON d.track_id = t.id`
271
+ : `FROM main.Track t JOIN side.track_derived d ON d.track_id = t.id`;
272
+ // Relevance ordering makes ids non-monotonic (measured: 615, 1171, 1727),
273
+ // so a keyset on id alone silently drops or repeats rows between pages.
274
+ // The cursor is the composite (rank, rowid) that ORDER BY actually uses.
275
+ const orderKey = useFts ? "rank" : "t.id";
276
+ const rowKey = useFts ? "f.rowid" : "t.id";
277
+ const fingerprint = queryFingerprint(useFts, filterWhere, filterParams);
278
+ const pageWhere = [...filterWhere];
279
+ const pageParams = [...filterParams];
280
+ if (input.cursor) {
281
+ const cur = decodeCursor(input.cursor);
282
+ if (!cur)
283
+ return err("invalid_argument", "Malformed cursor");
284
+ if (cur[2] !== fingerprint) {
285
+ return err("invalid_argument", "This cursor belongs to a different search. Repeat the original search's " +
286
+ "q/bpm/key/rating/played/added/flags exactly, or start a new search without a cursor.");
287
+ }
288
+ pageWhere.push(`(${orderKey}, ${rowKey}) > (?, ?)`);
289
+ pageParams.push(cur[0], cur[1]);
290
+ }
291
+ const pageWhereSql = pageWhere.length ? `WHERE ${pageWhere.join(" AND ")}` : "";
292
+ const select = fields.map((f) => `${FIELD_SQL[f]} AS "${f}"`).join(", ");
293
+ const sql = `SELECT ${select}, ${orderKey} AS __rank, ${rowKey} AS __row
294
+ ${from} ${pageWhereSql}
295
+ ORDER BY ${orderKey}, ${rowKey} LIMIT ?`;
296
+ const res = await qp.run(sql, [...pageParams, limit]);
297
+ if (isEngineError(res))
298
+ return res;
299
+ const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
300
+ const tracks = res.rows.map((row) => Object.fromEntries(fields.map((f) => {
301
+ const value = row[idx[f]];
302
+ return [f, input.redact_paths && f === "path" && typeof value === "string"
303
+ ? redactPath(value)
304
+ : value];
305
+ })));
306
+ let next_cursor;
307
+ if (res.rows.length === limit) {
308
+ const last = res.rows[res.rows.length - 1];
309
+ next_cursor = encodeCursor(last[idx.__rank], Number(last[idx.__row]), fingerprint);
310
+ }
311
+ let total;
312
+ let total_capped;
313
+ if (input.include_total) {
314
+ const filterWhereSql = filterWhere.length ? `WHERE ${filterWhere.join(" AND ")}` : "";
315
+ const countSql = `SELECT COUNT(*) AS c FROM (SELECT 1 ${from} ${filterWhereSql} LIMIT ?)`;
316
+ const cres = await qp.run(countSql, [...filterParams, TOTAL_CAP + 1]);
317
+ if (!isEngineError(cres) && cres.rows.length) {
318
+ const raw = Number(cres.rows[0][0]);
319
+ total_capped = raw > TOTAL_CAP;
320
+ total = Math.min(raw, TOTAL_CAP);
321
+ }
322
+ }
323
+ return {
324
+ tracks,
325
+ ...(total !== undefined ? { total, total_capped } : {}),
326
+ ...(next_cursor ? { next_cursor } : {}),
327
+ };
328
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { type EngineError } from "../errors.js";
3
+ import type { QueryProcess } from "../proc/query-client.js";
4
+ export declare const RunSqlInput: z.ZodObject<{
5
+ sql: z.ZodString;
6
+ params: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodNull]>>>;
7
+ limit: z.ZodDefault<z.ZodNumber>;
8
+ }, z.core.$strip>;
9
+ export type RunSqlInput = z.input<typeof RunSqlInput>;
10
+ export declare function runSql(qp: QueryProcess, raw: RunSqlInput): Promise<{
11
+ columns: string[];
12
+ rows: unknown[][];
13
+ truncated: boolean;
14
+ } | EngineError>;
@@ -0,0 +1,21 @@
1
+ // src/tools/sql.ts
2
+ import { z } from "zod";
3
+ import { checkStatement, enforceLimit } from "../guard.js";
4
+ import { isEngineError } from "../errors.js";
5
+ export const RunSqlInput = z.object({
6
+ sql: z.string().min(1),
7
+ params: z.array(z.union([z.string(), z.number(), z.null()])).optional(),
8
+ limit: z.number().int().positive().max(500).default(200),
9
+ });
10
+ export async function runSql(qp, raw) {
11
+ const input = RunSqlInput.parse(raw);
12
+ const rejected = checkStatement(input.sql);
13
+ if (rejected)
14
+ return rejected;
15
+ // prepare() only, never exec(): exec() runs every chained statement and would
16
+ // let "SELECT 1; VACUUM INTO ..." slip past the guard above.
17
+ const res = await qp.run(enforceLimit(input.sql, input.limit), input.params ?? []);
18
+ if (isEngineError(res))
19
+ return res;
20
+ return { ...res, truncated: res.rows.length >= input.limit };
21
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+ import { type EngineError } from "../errors.js";
3
+ import type { QueryProcess } from "../proc/query-client.js";
4
+ export declare const GetTracksInput: z.ZodObject<{
5
+ ids: z.ZodArray<z.ZodNumber>;
6
+ fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
7
+ redact_paths: z.ZodDefault<z.ZodBoolean>;
8
+ }, z.core.$strip>;
9
+ export type GetTracksInput = z.input<typeof GetTracksInput>;
10
+ export declare function getTracks(qp: QueryProcess, raw: GetTracksInput): Promise<{
11
+ tracks: Record<string, unknown>[];
12
+ } | EngineError>;
@@ -0,0 +1,49 @@
1
+ // src/tools/tracks.ts
2
+ import { z } from "zod";
3
+ import { err, isEngineError } from "../errors.js";
4
+ import { DEFAULT_FIELDS, FIELD_SQL } from "./search.js";
5
+ import { redactPath } from "../paths.js";
6
+ export const GetTracksInput = z.object({
7
+ ids: z.array(z.number().int().positive()).min(1).max(200),
8
+ fields: z.array(z.string()).optional(),
9
+ redact_paths: z.boolean().default(true),
10
+ });
11
+ export async function getTracks(qp, raw) {
12
+ const parsed = GetTracksInput.safeParse(raw);
13
+ if (!parsed.success) {
14
+ return err("invalid_argument", "ids must contain between 1 and 200 track ids");
15
+ }
16
+ const { ids, redact_paths } = parsed.data;
17
+ const requestedFields = parsed.data.fields ?? [...DEFAULT_FIELDS];
18
+ // Matches search.ts: an empty projection builds "SELECT , t.id ..." and
19
+ // fails as a raw SQLite syntax error instead of a named argument problem.
20
+ // Omitting `fields` already means "the default projection", so an empty
21
+ // list has no second meaning to honour.
22
+ if (requestedFields.length === 0)
23
+ return err("invalid_argument", "No fields requested");
24
+ const unknownFields = requestedFields.filter((f) => !(f in FIELD_SQL));
25
+ if (unknownFields.length) {
26
+ return err("invalid_argument", `Unknown field(s): ${unknownFields.join(", ")}`, {
27
+ detail: `Recognised fields: ${Object.keys(FIELD_SQL).join(", ")}`,
28
+ });
29
+ }
30
+ const fields = requestedFields;
31
+ const select = fields.map((f) => `${FIELD_SQL[f]} AS "${f}"`).join(", ");
32
+ const sql = `SELECT ${select}, t.id AS __id
33
+ FROM main.Track t JOIN side.track_derived d ON d.track_id = t.id
34
+ WHERE t.id IN (${ids.map(() => "?").join(",")})`;
35
+ const res = await qp.run(sql, ids);
36
+ if (isEngineError(res))
37
+ return res;
38
+ const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
39
+ const byId = new Map();
40
+ for (const row of res.rows) {
41
+ const track = Object.fromEntries(fields.map((f) => {
42
+ const value = row[idx[f]];
43
+ return [f, redact_paths && f === "path" && typeof value === "string" ? redactPath(value) : value];
44
+ }));
45
+ byId.set(Number(row[idx.__id]), track);
46
+ }
47
+ // Preserve the caller's ordering; missing ids are simply absent.
48
+ return { tracks: ids.map((id) => byId.get(id)).filter(Boolean) };
49
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "engine-dj-mcp",
3
+ "version": "0.9.0",
4
+ "description": "Read-only MCP server for searching and auditing an Engine DJ library. Not affiliated with inMusic or Denon DJ.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "engine-dj",
9
+ "denon-dj",
10
+ "dj",
11
+ "sqlite",
12
+ "library-audit",
13
+ "claude",
14
+ "ai"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Mikhail Chereshnev <venuttv@gmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Venut-Labs/engine-dj-mcp.git"
21
+ },
22
+ "homepage": "https://github.com/Venut-Labs/engine-dj-mcp#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/Venut-Labs/engine-dj-mcp/issues"
25
+ },
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=22.13.0"
29
+ },
30
+ "bin": {
31
+ "engine-dj-mcp": "dist/index.js"
32
+ },
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "scripts": {
37
+ "prepare": "npm run build",
38
+ "pretest": "npm run build",
39
+ "build": "tsc",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.30.0",
45
+ "@cfworker/json-schema": "^4.1.1",
46
+ "zod": "^4.4.3"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^7.0.2",
50
+ "vitest": "^4.1.11",
51
+ "@types/node": "^22.0.0"
52
+ }
53
+ }