engine-dj-mcp 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # engine-dj-mcp
2
2
 
3
- [![CI](https://github.com/Venut-Labs/engine-dj-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Venut-Labs/engine-dj-mcp/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/Venut-Technologies/engine-dj-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Venut-Technologies/engine-dj-mcp/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/engine-dj-mcp)](https://www.npmjs.com/package/engine-dj-mcp)
5
5
  [![licence](https://img.shields.io/npm/l/engine-dj-mcp)](./LICENSE)
6
6
 
@@ -54,7 +54,7 @@ the configuration you are reading:
54
54
  ```json
55
55
  {
56
56
  "mcpServers": {
57
- "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.17.0", "--allow-writes"] }
57
+ "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.17.1", "--allow-writes"] }
58
58
  }
59
59
  }
60
60
  ```
@@ -407,7 +407,7 @@ own checks.
407
407
  | --- | --- | --- |
408
408
  | `invalid_argument` | The arguments do not make sense — both `playlist_id` and `playlist_name`, an empty list where one is required, or a `playlist_name` that matches several playlists (every candidate is listed). | yes |
409
409
  | `library_not_found` | `library` names nothing connected — the refusal lists what is — or the library's header could not be read. | yes |
410
- | `ambiguous_library` | No `library` given, and more than one supported library is connected. Lists them — see [Choosing a library](#choosing-a-library). | yes |
410
+ | `ambiguous_library` | No `library` given, and more than one supported library is connected — or the uuid given is shared by copies on different drives. Lists them — see [Choosing a library](#choosing-a-library). | yes |
411
411
  | `unsupported_schema` | The library's version is outside what this server supports. | yes |
412
412
  | `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
413
413
  | `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
@@ -448,7 +448,7 @@ scanned first and is often empty, so "the first one found" would hide the
448
448
  drive you actually work from.
449
449
 
450
450
  That rule is enough for a read, which changes nothing: with two libraries
451
- connected a read picks one, and the `library` field in the result says which.
451
+ connected a read picks one. Pass `library` when it matters which.
452
452
 
453
453
  **A write refuses instead**, as soon as more than one supported library is
454
454
  connected — whatever their track counts. `ambiguous_library` lists every
@@ -464,6 +464,17 @@ work.
464
464
 
465
465
  With a single library nothing changes: you never have to name it.
466
466
 
467
+ **Copies share a uuid.** Copy an `Engine Library` folder onto another drive —
468
+ a spare stick for a gig — and the copy keeps the original's uuid, so with both
469
+ connected one uuid names two libraries. A write naming that uuid is refused the
470
+ same way, with `ambiguous_library` listing both paths, rather than landing on
471
+ whichever drive was scanned first. Pass the path instead — it tells the copies
472
+ apart — and re-read from that path anything the write depends on, since a read
473
+ naming the uuid may have come from the other copy. Reads naming a shared uuid
474
+ are not refused: they answer from one of the copies. Before every write the
475
+ drives are scanned again, so a copy plugged in after the server started is
476
+ counted — as long as its library can be read.
477
+
467
478
  The refusal tells the assistant to **ask you** rather than choose. Otherwise
468
479
  "pass `library`, here are the two" is an invitation to take the first one,
469
480
  which puts the write back on an arbitrary disk and makes the refusal
package/dist/errors.js CHANGED
@@ -33,11 +33,12 @@ export const ERROR_CODES = [
33
33
  "playlist_chain_damaged",
34
34
  "playlist_not_found",
35
35
  "invalid_position",
36
- // No `library` was passed and the default rule names no single winner --
37
- // two supported libraries hold the same, highest track count. Its own code
38
- // rather than invalid_argument because the useful client response is
39
- // specific: ask which drive, then retry with `library` set. Only writes
40
- // raise it; see library-select.ts for why reads still choose.
36
+ // A write cannot tell which physical library to change: no `library` was
37
+ // passed and more than one supported library is connected, or the uuid
38
+ // passed is shared by copies on different drives. Its own code rather than
39
+ // invalid_argument because the useful client response is specific: ask
40
+ // which drive, then retry with its path. Only writes raise it; see
41
+ // library-select.ts for why reads still choose.
41
42
  "ambiguous_library",
42
43
  // update_track_metadata (spec §7.2). stale_value: an `expect` no longer
43
44
  // matches what is in the library. track_not_editable: this track, or one
@@ -81,8 +81,29 @@ export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineEr
81
81
  *
82
82
  * A path match is exact on the m.db file, not a prefix: a value that merely
83
83
  * *contains* a library path must not select it.
84
+ *
85
+ * A uuid shared by several libraries resolves to the first of them in this
86
+ * server's list of known libraries -- root-scan order at startup, arrival
87
+ * order for a drive plugged in later, but no order a caller can rely on. That
88
+ * is tolerable for a read, which changes no disk, and never for a write: see
89
+ * namedWriteLibrary.
84
90
  */
85
91
  export declare function findLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | null;
92
+ /**
93
+ * Every library a `library` value names, in the order of `libs`.
94
+ * More than one only for a uuid: copying an Engine Library folder onto another
95
+ * drive copies its uuid with it, while each library's path is its own.
96
+ */
97
+ export declare function findLibraries(libs: readonly LibraryInfo[], requested: string): LibraryInfo[];
98
+ /**
99
+ * The one library a write names, or the refusal.
100
+ *
101
+ * findLibrary would hand back the first of two libraries sharing a uuid, and
102
+ * which is first is only discovery order -- the very thing ambiguousLibrary
103
+ * exists so that a write does not rest on. A named uuid is no better an answer
104
+ * than an omitted `library` when it names both a USB drive and its copy.
105
+ */
106
+ export declare function namedWriteLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | EngineError;
86
107
  /**
87
108
  * The error for a `library` value that matched nothing. It names what was
88
109
  * passed and lists what is actually selectable, because the two ways to get
@@ -17,7 +17,9 @@ export const LIBRARY_ARG_DESCRIPTION = "Which library to use: either the uuid or
17
17
  "the supported library holding the most tracks. A READ may always omit it. A WRITE " +
18
18
  "may omit it only when a single supported library is connected: with two or more, a " +
19
19
  "write refuses with ambiguous_library listing them, since the choice decides which " +
20
- "disk changes; ask the user which, then pass it here.";
20
+ "disk changes; ask the user which, then pass it here. A library copied onto another drive " +
21
+ "keeps its uuid, so a uuid can name two connected libraries: a write naming such a uuid " +
22
+ "refuses with ambiguous_library as well. Pass the path to write to one of them.";
21
23
  export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
22
24
  /**
23
25
  * The default when no `library` was given: the supported library with the
@@ -88,9 +90,12 @@ export function writeNeedsLibrary(libs) {
88
90
  export function ambiguousLibrary(tied) {
89
91
  const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
90
92
  return err("ambiguous_library", `More than one library is connected, so there is no default to write to: ${list}. ` +
91
- `Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
92
- `do not choose for them. These are usually a USB drive and its copy on the computer, ` +
93
- `and one of them may be the drive they perform from.`, { detail: "not_committed" });
93
+ `Nothing was written. ASK which one to write to, then retry with \`library\` set to that ` +
94
+ `library's path -- do not choose for them. A copy keeps its uuid, so a uuid may name more ` +
95
+ `than one of these. They are usually a USB drive and its copy on the computer, ` +
96
+ `and one of them may be the drive they perform from. A read without \`library\` may have ` +
97
+ `come from a different one of these, so re-read anything the write depends on (track and ` +
98
+ `playlist ids, positions, current values) from the chosen library first.`, { detail: "not_committed" });
94
99
  }
95
100
  /**
96
101
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
@@ -106,19 +111,66 @@ export function ambiguousLibrary(tied) {
106
111
  *
107
112
  * A path match is exact on the m.db file, not a prefix: a value that merely
108
113
  * *contains* a library path must not select it.
114
+ *
115
+ * A uuid shared by several libraries resolves to the first of them in this
116
+ * server's list of known libraries -- root-scan order at startup, arrival
117
+ * order for a drive plugged in later, but no order a caller can rely on. That
118
+ * is tolerable for a read, which changes no disk, and never for a write: see
119
+ * namedWriteLibrary.
109
120
  */
110
121
  export function findLibrary(libs, requested) {
122
+ return findLibraries(libs, requested)[0] ?? null;
123
+ }
124
+ /**
125
+ * Every library a `library` value names, in the order of `libs`.
126
+ * More than one only for a uuid: copying an Engine Library folder onto another
127
+ * drive copies its uuid with it, while each library's path is its own.
128
+ */
129
+ export function findLibraries(libs, requested) {
111
130
  const wanted = requested.trim();
112
131
  if (!wanted)
113
- return null;
114
- const byUuid = libs.find((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
115
- if (byUuid)
132
+ return [];
133
+ const byUuid = libs.filter((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
134
+ if (byUuid.length > 0)
116
135
  return byUuid;
117
136
  // resolve() turns a relative value into something rooted at the process
118
137
  // cwd, which matches no library path -- exactly the intended outcome for
119
138
  // a value that is neither a uuid nor a real path.
120
139
  const wantedPath = resolve(expandHome(wanted));
121
- return libs.find((l) => resolve(l.path) === wantedPath) ?? null;
140
+ return libs.filter((l) => resolve(l.path) === wantedPath);
141
+ }
142
+ /**
143
+ * The one library a write names, or the refusal.
144
+ *
145
+ * findLibrary would hand back the first of two libraries sharing a uuid, and
146
+ * which is first is only discovery order -- the very thing ambiguousLibrary
147
+ * exists so that a write does not rest on. A named uuid is no better an answer
148
+ * than an omitted `library` when it names both a USB drive and its copy.
149
+ */
150
+ export function namedWriteLibrary(libs, requested) {
151
+ const matches = findLibraries(libs, requested);
152
+ if (matches.length > 1)
153
+ return sharedUuid(requested, matches);
154
+ if (matches[0])
155
+ return matches[0];
156
+ // The same refusal a read gets, with its list moved into `message`: on a
157
+ // write `detail` is reserved, for the reason given at ambiguousLibrary.
158
+ const miss = libraryNotFound(requested, libs);
159
+ return { ...miss, message: `${miss.message}. ${miss.detail}`, detail: "not_committed" };
160
+ }
161
+ /**
162
+ * The refusal for a uuid naming more than one library. Lists paths, since the
163
+ * uuid is the one thing the candidates do not differ in; `detail` stays
164
+ * exactly "not_committed" for the reason given at ambiguousLibrary.
165
+ */
166
+ function sharedUuid(requested, matches) {
167
+ const list = matches.map((l) => `${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
168
+ return err("ambiguous_library", `"${requested.trim()}" names more than one connected library -- a library copied onto another ` +
169
+ `drive keeps its uuid: ${list}. Nothing was written. ASK which one to write to, then retry ` +
170
+ `with \`library\` set to that one's path -- do not choose for them. One of them may be the ` +
171
+ `drive they perform from. A read naming this uuid may have come from another of these copies, ` +
172
+ `so re-read anything the write depends on (track and playlist ids, positions, current values) ` +
173
+ `from that path first.`, { detail: "not_committed" });
122
174
  }
123
175
  /**
124
176
  * The error for a `library` value that matched nothing. It names what was
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { discoverLibraries, defaultRoots, probeLibraries } from "./discovery.js";
7
7
  import { libraryCandidates, libraryTag, sidecarDir } from "./paths.js";
8
- import { LibraryArg, ambiguousLibrary, writeNeedsLibrary, findLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
8
+ import { LibraryArg, ambiguousLibrary, writeNeedsLibrary, findLibrary, namedWriteLibrary, libraryNotFound, pickDefaultLibrary, } from "./library-select.js";
9
9
  import { hasHotJournal } from "./store/connections.js";
10
10
  import { QueryProcess } from "./proc/query-client.js";
11
11
  import { IndexManager } from "./store/index-manager.js";
@@ -92,7 +92,8 @@ const WRITE_LIBRARY_NOTE = " With two or more supported libraries connected, thi
92
92
  "rather than picking one, and lists them; nothing is written. That is so whatever their track " +
93
93
  "counts are -- the count never said which disk should change. Ask the user which one, then " +
94
94
  "retry with `library` set; do not pick for them, since one of them may be the drive they " +
95
- "perform from.";
95
+ "perform from. A library copied onto another drive keeps its uuid, so naming a uuid that " +
96
+ "two connected libraries share is refused the same way -- pass the path.";
96
97
  /**
97
98
  * Not UNDO_SCOPE_NOTE: that one says Engine copies *playlist* changes between
98
99
  * libraries, which was measured. For track tags, a fresh Engine launch was
@@ -246,6 +247,13 @@ export async function createServer(opts = {}) {
246
247
  rescanLibraries();
247
248
  return findLibrary(knownList(), requested) ?? libraryNotFound(requested, knownList());
248
249
  };
250
+ /** Resolves `library` the read way -- see selectLibrary -- then prepares it. */
251
+ const acquire = async (requested) => {
252
+ const lib = selectLibrary(requested);
253
+ if (isEngineError(lib))
254
+ return lib;
255
+ return prepare(lib);
256
+ };
249
257
  /**
250
258
  * `index_stale` is swallowed only when an index is genuinely attached:
251
259
  * "the previous index is still in use" is a reason to answer anyway, but
@@ -256,10 +264,7 @@ export async function createServer(opts = {}) {
256
264
  * string "no such table: side.track_derived", instead of `index_stale`
257
265
  * with a `retry_after_ms` the model can act on.
258
266
  */
259
- const acquire = async (requested) => {
260
- const lib = selectLibrary(requested);
261
- if (isEngineError(lib))
262
- return lib;
267
+ const prepare = async (lib) => {
263
268
  const state = stateFor(lib);
264
269
  const fresh = await state.mgr.ensureFresh();
265
270
  if (!isEngineError(fresh))
@@ -269,8 +274,13 @@ export async function createServer(opts = {}) {
269
274
  return fresh;
270
275
  };
271
276
  /**
272
- * `acquire` for the write tools: identical, except that an omitted
273
- * `library` must resolve to exactly one candidate.
277
+ * The library a write may land in, without touching its search index. A
278
+ * tool that addresses tracks by id needs no index, and building one right
279
+ * before a write only makes it stale the moment the write commits.
280
+ * acquireForWrite adds the index for the tools that resolve playlists.
281
+ *
282
+ * Selection differs from a read's in that it must name exactly one
283
+ * physical library, whether `library` was omitted or given.
274
284
  *
275
285
  * `pickDefaultLibrary` breaks a tie on root-scan order, which is
276
286
  * deterministic and, for a read, fine -- libraries tie because one is a
@@ -283,43 +293,51 @@ export async function createServer(opts = {}) {
283
293
  * USB drive both held 257 tracks, tied precisely because one was a copy of
284
294
  * the other.
285
295
  *
286
- * Only the omitted case refuses. A caller who named a library gets it, tie
287
- * or no tie -- the ambiguity being refused here is the server's, not theirs.
296
+ * A named library is taken as named -- unless the name is a uuid two
297
+ * connected libraries share. Copying an Engine Library folder onto another
298
+ * drive copies its uuid, and resolving that uuid to the first match would
299
+ * be the same order-of-discovery pick the omitted case refuses, reached by
300
+ * a caller who had no way to know it was ambiguous. namedWriteLibrary refuses it and asks
301
+ * for the path, which tells the copies apart.
288
302
  *
289
- * Rescans first, because `knownList()` is a cache that deliberately keeps a
290
- * library a later scan cannot see -- so a momentarily locked drive does not
291
- * vanish from list_libraries. For a tie check that is wrong in the
292
- * direction that bites: pull the USB drive and one library is left, but the
293
- * cache still holds two, and the write is refused naming a drive that is no
294
- * longer there. rescanLibraries() forgets a candidate whose path is gone,
295
- * which is exactly the distinction wanted here, and it also lets a drive
296
- * plugged in mid-session be seen at all.
303
+ * Rescans first, in both cases, because `knownList()` is a cache that
304
+ * deliberately keeps a library a later scan cannot see -- so a momentarily
305
+ * locked drive does not vanish from list_libraries. For a tie check that is
306
+ * wrong in the direction that bites: pull the USB drive and one library is
307
+ * left, but the cache still holds two, and the write is refused naming a
308
+ * drive that is no longer there. rescanLibraries() forgets a candidate
309
+ * whose path is gone, which is exactly the distinction wanted here, and it
310
+ * also lets a drive plugged in mid-session be seen at all -- including a
311
+ * copy that makes a named uuid ambiguous.
297
312
  *
298
- * The cost is one filesystem probe per write, against a write that is about
299
- * to copy the entire database for its pre-write snapshot. Reads are left
300
- * alone: they run far more often and a stale pick between two copies is not
301
- * worth a probe apiece.
302
- */
303
- /**
304
- * The library a write may land in, without touching its search index. A
305
- * tool that addresses tracks by id needs no index, and building one right
306
- * before a write only makes it stale the moment the write commits.
307
- * acquireForWrite adds the index for the tools that resolve playlists.
313
+ * A copy that has never once been readable since startup -- a hot journal,
314
+ * no permission -- is still not counted: its uuid was never read, so there
315
+ * is nothing to match. rescanLibraries keeps only libraries it has read.
316
+ *
317
+ * The cost is one discovery scan per write: every candidate library under
318
+ * the roots is opened read-only and its header and track count read, the
319
+ * same scan list_libraries runs. That is small against a write that is
320
+ * about to copy the entire database for its pre-write snapshot. Reads are
321
+ * left alone: they run far more often and a stale pick between two copies
322
+ * is not worth a scan apiece.
308
323
  */
309
324
  const selectForWrite = (requested) => {
310
- if (requested === undefined) {
311
- rescanLibraries();
312
- const choices = writeNeedsLibrary(knownList());
313
- if (choices.length > 0)
314
- return ambiguousLibrary(choices);
315
- }
316
- return selectLibrary(requested);
325
+ rescanLibraries();
326
+ if (requested !== undefined)
327
+ return namedWriteLibrary(knownList(), requested);
328
+ const choices = writeNeedsLibrary(knownList());
329
+ if (choices.length > 0)
330
+ return ambiguousLibrary(choices);
331
+ return selectLibrary();
317
332
  };
318
333
  const acquireForWrite = async (requested) => {
319
334
  const lib = selectForWrite(requested);
320
335
  if (isEngineError(lib))
321
336
  return lib;
322
- return acquire(requested);
337
+ // The library just resolved, not `requested` again by the read rules:
338
+ // today both give the same answer, but only this one was checked for a
339
+ // uuid shared between copies.
340
+ return prepare(lib);
323
341
  };
324
342
  /**
325
343
  * Shared by the engine://libraries resource and the list_libraries tool so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "MCP server for an Engine DJ library: search and audit it, read cues and beatgrids, and build playlists when you ask. Not affiliated with inMusic or Denon DJ.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -19,11 +19,11 @@
19
19
  "author": "Mikhail Chereshnev <venuttv@gmail.com>",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/Venut-Labs/engine-dj-mcp.git"
22
+ "url": "git+https://github.com/Venut-Technologies/engine-dj-mcp.git"
23
23
  },
24
- "homepage": "https://github.com/Venut-Labs/engine-dj-mcp#readme",
24
+ "homepage": "https://github.com/Venut-Technologies/engine-dj-mcp#readme",
25
25
  "bugs": {
26
- "url": "https://github.com/Venut-Labs/engine-dj-mcp/issues"
26
+ "url": "https://github.com/Venut-Technologies/engine-dj-mcp/issues"
27
27
  },
28
28
  "type": "module",
29
29
  "engines": {