engine-dj-mcp 0.17.0 → 0.17.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/README.md CHANGED
@@ -1,43 +1,61 @@
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
 
7
- An MCP server that gives an AI assistant your **Engine DJ** libraries — the
8
- one on your computer and the ones on your USB drives. It searches and audits
9
- them, reads the cues and beatgrids Engine stored, and builds playlists when
10
- you ask it to.
7
+ Ask Claude, or any AI assistant, about your **Engine DJ** library: find tracks
8
+ by BPM, key, genre or your own comments, check the collection for duplicates,
9
+ missing files and tracks with no cues, read the cues and beatgrids Engine
10
+ stored, and have playlists built or track tags fixed when you ask. It works
11
+ through MCP, the Model Context Protocol, the standard way AI apps such as
12
+ Claude Desktop, Claude Code, Cursor and VS Code connect to tools on your own
13
+ computer: the app starts this server, and the assistant calls it when a
14
+ question needs your library.
15
+
16
+ It reads the library on your computer and the ones on your USB drives. It
17
+ opens them **read-only at the operating-system level** and changes nothing
18
+ unless you start it with `--allow-writes` — then it can create and edit
19
+ playlists and edit genre, comment, label, year and rating, and it copies the
20
+ whole database to a backup before the first change. See [Safety](#safety).
21
+ The server itself sends nothing anywhere; what your AI app does with its
22
+ answers is covered in [PRIVACY.md](./PRIVACY.md).
23
+
24
+ **Status: Active · Pre-1.0.** In regular use and maintained; before 1.0 a MINOR
25
+ release may change behaviour, a PATCH never does. Changes are recorded in
26
+ [CHANGELOG.md](./CHANGELOG.md).
11
27
 
12
28
  > **Not affiliated with, endorsed by, or sponsored by inMusic Brands, Denon
13
29
  > DJ, or the Engine DJ product.** "Engine DJ" is used here only to name the
14
30
  > software whose library this tool reads and writes. No logos or brand
15
31
  > artwork from inMusic or Denon DJ are used in this project.
16
32
 
17
- Your library is opened **read-only at the operating-system level**. It is
18
- never written to unless you start the server with `--allow-writes` — see
19
- [Safety](#safety).
20
-
21
33
  ## What you can ask
22
34
 
23
35
  Once connected, these are ordinary questions in chat:
24
36
 
25
37
  - *"Something dark around 124 in a minor key I haven't played in six months."*
26
- - *"Which tracks still have no hot cue set?"*
27
38
  - *"Find me anything harmonically compatible with 8A between 138 and 142."*
28
- - *"What's in my ACID Beach playlist, in order?"*
29
- - *"Anything around 128 in ACID Beach?"*
30
- - *"What's broken in my collection — missing files, duplicates, bad tempos?"*
39
+ - *"What's broken in my collection — missing files, duplicates, tracks with no cues?"*
31
40
  - *"Where are the cue points on this track, and what tempo did Engine analyse?"*
32
41
  - *"Build me a playlist of everything in 5A from 140 BPM up."* (needs `--allow-writes`)
42
+ - *"Set the genre of these five tracks to Minimal and rate them four stars."* (needs `--allow-writes`)
33
43
 
34
44
  ## Install
35
45
 
46
+ You need [Node.js](https://nodejs.org) 22.16 or newer. There is nothing else
47
+ to install: every app below starts the server with `npx`, which downloads it
48
+ from npm the first time.
49
+
36
50
  ```bash
37
51
  npx engine-dj-mcp
38
52
  ```
39
53
 
40
- Claude Desktop — add to your configuration:
54
+ That command is what the apps run; you do not need to run it yourself.
55
+
56
+ ### Claude Desktop
57
+
58
+ Settings → Developer → Edit Config, and add to `claude_desktop_config.json`:
41
59
 
42
60
  ```json
43
61
  {
@@ -47,14 +65,43 @@ Claude Desktop — add to your configuration:
47
65
  }
48
66
  ```
49
67
 
50
- To let the assistant create playlists as well, add `--allow-writes` — a flag
51
- in `args` rather than an environment variable precisely so it is visible in
52
- the configuration you are reading:
68
+ Restart Claude Desktop.
69
+
70
+ ### Claude Code
71
+
72
+ ```bash
73
+ claude mcp add --scope user engine-dj -- npx -y engine-dj-mcp
74
+ ```
75
+
76
+ ### Cursor
77
+
78
+ [Add to Cursor](https://cursor.com/install-mcp?name=engine-dj&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImVuZ2luZS1kai1tY3AiXX0%3D)
79
+ — or open this deep link directly, or add the same `mcpServers` entry as for
80
+ Claude Desktop to `~/.cursor/mcp.json`:
81
+
82
+ ```text
83
+ cursor://anysphere.cursor-deeplink/mcp/install?name=engine-dj&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImVuZ2luZS1kai1tY3AiXX0%3D
84
+ ```
85
+
86
+ ### VS Code
87
+
88
+ [Add to VS Code](https://vscode.dev/redirect/mcp/install?name=engine-dj&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22engine-dj-mcp%22%5D%7D)
89
+ — or run:
90
+
91
+ ```bash
92
+ code --add-mcp '{"name":"engine-dj","command":"npx","args":["-y","engine-dj-mcp"]}'
93
+ ```
94
+
95
+ ### Letting it write
96
+
97
+ To let the assistant create and edit playlists and edit track tags, add
98
+ `--allow-writes` to `args` — a flag rather than an environment variable
99
+ precisely so it is visible in the configuration you are reading:
53
100
 
54
101
  ```json
55
102
  {
56
103
  "mcpServers": {
57
- "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.17.0", "--allow-writes"] }
104
+ "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.17.2", "--allow-writes"] }
58
105
  }
59
106
  }
60
107
  ```
@@ -62,18 +109,33 @@ the configuration you are reading:
62
109
  Pin the version in this one. Unpinned, `npx` fetches whatever is newest at
63
110
  every launch, and this configuration gives that code write access to your
64
111
  library. Pinned, a new release reaches it only when you change the number.
65
-
66
- **Requirements:** Node.js 22.16 or newer (`node:sqlite` stopped needing a
67
- flag in 22.13, but the pre-write snapshot uses its `backup()`, added in
68
- 22.16;
69
- there are no native dependencies), and an Engine DJ library at schema 3.0.0
70
- through 3.0.2 — Engine DJ 4.5 and 5.x.
112
+ Quit Engine DJ before asking for a change; see [Writing](#writing).
113
+
114
+ ## Compatibility
115
+
116
+ - **macOS: yes.** Every check against a real library has been run on macOS,
117
+ and it finds libraries where Engine DJ keeps them there: `~/Music` and the
118
+ top of each drive under `/Volumes`.
119
+ - **Windows and Linux: not supported yet.** The test suite passes on Ubuntu,
120
+ but no real library has been read on either system. Off macOS the server
121
+ looks only in `~/Music/Engine Library`, so a library on a USB drive is not
122
+ found, and there is no option to point it somewhere else.
123
+ - **Node.js 22.16 or newer**, with no native dependencies. `node:sqlite`
124
+ stopped needing a flag in 22.13, but the pre-write backup uses its
125
+ `backup()`, added in 22.16. CI runs the full suite on Node 22.16 and 24, on
126
+ macOS and Ubuntu.
127
+ - **Engine DJ libraries at schema 3.0.0 through 3.0.2** — Engine DJ 4.5 and
128
+ 5.x. Everything here has been exercised against a real **schema 3.0.2**
129
+ library; 3.0.0 and 3.0.1 are accepted by the version check and covered by
130
+ generated fixtures, but no real library at those versions has been read.
131
+ Anything outside the range is listed with its version and refused, never
132
+ read on a guess.
71
133
 
72
134
  ## Tools
73
135
 
74
- Nine read-only tools, and four that write — `create_playlist`,
75
- `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
76
- `reorder_playlist` — that appear only when you start the server with
136
+ Nine read-only tools, and five that write — `create_playlist`,
137
+ `add_tracks_to_playlist`, `remove_tracks_from_playlist`, `reorder_playlist`
138
+ and `update_track_metadata` — that appear only when you start the server with
77
139
  `--allow-writes`. Every tool that reads library data also accepts an
78
140
  optional `library` argument — see [Choosing a library](#choosing-a-library).
79
141
 
@@ -407,7 +469,7 @@ own checks.
407
469
  | --- | --- | --- |
408
470
  | `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
471
  | `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 |
472
+ | `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
473
  | `unsupported_schema` | The library's version is outside what this server supports. | yes |
412
474
  | `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
413
475
  | `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
@@ -448,7 +510,7 @@ scanned first and is often empty, so "the first one found" would hide the
448
510
  drive you actually work from.
449
511
 
450
512
  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.
513
+ connected a read picks one. Pass `library` when it matters which.
452
514
 
453
515
  **A write refuses instead**, as soon as more than one supported library is
454
516
  connected — whatever their track counts. `ambiguous_library` lists every
@@ -464,6 +526,17 @@ work.
464
526
 
465
527
  With a single library nothing changes: you never have to name it.
466
528
 
529
+ **Copies share a uuid.** Copy an `Engine Library` folder onto another drive —
530
+ a spare stick for a gig — and the copy keeps the original's uuid, so with both
531
+ connected one uuid names two libraries. A write naming that uuid is refused the
532
+ same way, with `ambiguous_library` listing both paths, rather than landing on
533
+ whichever drive was scanned first. Pass the path instead — it tells the copies
534
+ apart — and re-read from that path anything the write depends on, since a read
535
+ naming the uuid may have come from the other copy. Reads naming a shared uuid
536
+ are not refused: they answer from one of the copies. Before every write the
537
+ drives are scanned again, so a copy plugged in after the server started is
538
+ counted — as long as its library can be read.
539
+
467
540
  The refusal tells the assistant to **ask you** rather than choose. Otherwise
468
541
  "pass `library`, here are the two" is an invitation to take the first one,
469
542
  which puts the write back on an arbitrary disk and makes the refusal
@@ -476,6 +549,9 @@ does.
476
549
 
477
550
  ## Safety
478
551
 
552
+ The guarantees this server makes about your library are listed in
553
+ [PRINCIPLES.md](./PRINCIPLES.md); this section is how they work.
554
+
479
555
  Your library is opened **read-only at the operating-system level**, not by
480
556
  convention and not by a `PRAGMA` a query could turn back off. Writes are
481
557
  refused by SQLite itself, and without `--allow-writes` no file is ever
@@ -664,11 +740,15 @@ changes. The track's **main cue** does not count towards it — Engine sets
664
740
  that as a playback marker rather than the DJ placing it. `has_beatgrid` does
665
741
  still test for the blob: `beatData` has no "written but empty" state.
666
742
 
667
- **It writes nothing but playlists, and only when you ask for it.** Without
668
- `--allow-writes` the library is opened read-only at the OS level and there is
669
- no tool that could write. With the flag, the four write tools add, edit and
670
- reorder playlists — and that is the whole list. Not a cue, not a tag, not a
671
- rating, and not even the recovery of a journal Engine DJ left behind.
743
+ **It writes playlists and five track fields, and only when you ask for it.**
744
+ Without `--allow-writes` the library is opened read-only at the OS level and
745
+ there is no tool that could write. With the flag, five write tools appear:
746
+ four create playlists and add, remove and reorder their tracks, and
747
+ `update_track_metadata` changes genre, comment, label, year and rating in
748
+ Engine's database — and that is the whole list. Not a cue, a loop or a
749
+ beatgrid; not a title, artist, album, play count or file path; not the tags
750
+ inside your audio files; and not even the recovery of a journal Engine DJ
751
+ left behind.
672
752
 
673
753
  **It does not read play history.** `Track.timeLastPlayed` answers "what have I
674
754
  not played in six months?", but the separate Engine history database —
@@ -696,4 +776,5 @@ their version and reported as unsupported rather than read on a guess.
696
776
 
697
777
  ## Licence
698
778
 
699
- MIT — see [LICENSE](./LICENSE).
779
+ MIT — see [LICENSE](./LICENSE). What this project guarantees and refuses to do:
780
+ [PRINCIPLES.md](./PRINCIPLES.md).
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";
@@ -20,7 +20,12 @@ import { refreshIndex } from "./tools/refresh.js";
20
20
  import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
21
21
  import { UpdateTrackMetadataInput, runUpdateTrackMetadata } from "./tools/write-track-metadata.js";
22
22
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
23
- const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
+ /**
24
+ * Every tool here works on files on this machine -- the Engine library, the
25
+ * sidecar index and the backups beside it -- and none reaches a network
26
+ * service, so each one says so: `openWorldHint: false` on all four sets.
27
+ */
28
+ const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
24
29
  /**
25
30
  * A write that only ever *adds*. `destructiveHint: false` is a claim with a
26
31
  * defined meaning in MCP -- "this tool performs only additive updates" -- and
@@ -28,20 +33,20 @@ const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
28
33
  * create_playlist (a new playlist, nothing else touched) and of
29
34
  * add_tracks_to_playlist (new entries, existing ones left where they are).
30
35
  */
31
- const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
36
+ const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
32
37
  /**
33
38
  * A write that can destroy or reorganise what is already there:
34
39
  * remove_tracks_from_playlist deletes entries, reorder_playlist rewrites the
35
40
  * order of a list a DJ may be playing from live. Advertising either as
36
41
  * additive told a client it need not ask before calling.
37
42
  */
38
- const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
43
+ const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
39
44
  /**
40
45
  * A write that overwrites or clears what is there -- so destructive -- but
41
46
  * that a repeat of the same call leaves alone: a track already holding the
42
47
  * requested values is not written again. update_track_metadata.
43
48
  */
44
- const RW_OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true };
49
+ const RW_OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
45
50
  /**
46
51
  * name/version reported to every client on initialize. Read from
47
52
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -92,7 +97,8 @@ const WRITE_LIBRARY_NOTE = " With two or more supported libraries connected, thi
92
97
  "rather than picking one, and lists them; nothing is written. That is so whatever their track " +
93
98
  "counts are -- the count never said which disk should change. Ask the user which one, then " +
94
99
  "retry with `library` set; do not pick for them, since one of them may be the drive they " +
95
- "perform from.";
100
+ "perform from. A library copied onto another drive keeps its uuid, so naming a uuid that " +
101
+ "two connected libraries share is refused the same way -- pass the path.";
96
102
  /**
97
103
  * Not UNDO_SCOPE_NOTE: that one says Engine copies *playlist* changes between
98
104
  * libraries, which was measured. For track tags, a fresh Engine launch was
@@ -246,6 +252,13 @@ export async function createServer(opts = {}) {
246
252
  rescanLibraries();
247
253
  return findLibrary(knownList(), requested) ?? libraryNotFound(requested, knownList());
248
254
  };
255
+ /** Resolves `library` the read way -- see selectLibrary -- then prepares it. */
256
+ const acquire = async (requested) => {
257
+ const lib = selectLibrary(requested);
258
+ if (isEngineError(lib))
259
+ return lib;
260
+ return prepare(lib);
261
+ };
249
262
  /**
250
263
  * `index_stale` is swallowed only when an index is genuinely attached:
251
264
  * "the previous index is still in use" is a reason to answer anyway, but
@@ -256,10 +269,7 @@ export async function createServer(opts = {}) {
256
269
  * string "no such table: side.track_derived", instead of `index_stale`
257
270
  * with a `retry_after_ms` the model can act on.
258
271
  */
259
- const acquire = async (requested) => {
260
- const lib = selectLibrary(requested);
261
- if (isEngineError(lib))
262
- return lib;
272
+ const prepare = async (lib) => {
263
273
  const state = stateFor(lib);
264
274
  const fresh = await state.mgr.ensureFresh();
265
275
  if (!isEngineError(fresh))
@@ -269,8 +279,13 @@ export async function createServer(opts = {}) {
269
279
  return fresh;
270
280
  };
271
281
  /**
272
- * `acquire` for the write tools: identical, except that an omitted
273
- * `library` must resolve to exactly one candidate.
282
+ * The library a write may land in, without touching its search index. A
283
+ * tool that addresses tracks by id needs no index, and building one right
284
+ * before a write only makes it stale the moment the write commits.
285
+ * acquireForWrite adds the index for the tools that resolve playlists.
286
+ *
287
+ * Selection differs from a read's in that it must name exactly one
288
+ * physical library, whether `library` was omitted or given.
274
289
  *
275
290
  * `pickDefaultLibrary` breaks a tie on root-scan order, which is
276
291
  * deterministic and, for a read, fine -- libraries tie because one is a
@@ -283,43 +298,51 @@ export async function createServer(opts = {}) {
283
298
  * USB drive both held 257 tracks, tied precisely because one was a copy of
284
299
  * the other.
285
300
  *
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.
301
+ * A named library is taken as named -- unless the name is a uuid two
302
+ * connected libraries share. Copying an Engine Library folder onto another
303
+ * drive copies its uuid, and resolving that uuid to the first match would
304
+ * be the same order-of-discovery pick the omitted case refuses, reached by
305
+ * a caller who had no way to know it was ambiguous. namedWriteLibrary refuses it and asks
306
+ * for the path, which tells the copies apart.
288
307
  *
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.
308
+ * Rescans first, in both cases, because `knownList()` is a cache that
309
+ * deliberately keeps a library a later scan cannot see -- so a momentarily
310
+ * locked drive does not vanish from list_libraries. For a tie check that is
311
+ * wrong in the direction that bites: pull the USB drive and one library is
312
+ * left, but the cache still holds two, and the write is refused naming a
313
+ * drive that is no longer there. rescanLibraries() forgets a candidate
314
+ * whose path is gone, which is exactly the distinction wanted here, and it
315
+ * also lets a drive plugged in mid-session be seen at all -- including a
316
+ * copy that makes a named uuid ambiguous.
297
317
  *
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.
318
+ * A copy that has never once been readable since startup -- a hot journal,
319
+ * no permission -- is still not counted: its uuid was never read, so there
320
+ * is nothing to match. rescanLibraries keeps only libraries it has read.
321
+ *
322
+ * The cost is one discovery scan per write: every candidate library under
323
+ * the roots is opened read-only and its header and track count read, the
324
+ * same scan list_libraries runs. That is small against a write that is
325
+ * about to copy the entire database for its pre-write snapshot. Reads are
326
+ * left alone: they run far more often and a stale pick between two copies
327
+ * is not worth a scan apiece.
308
328
  */
309
329
  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);
330
+ rescanLibraries();
331
+ if (requested !== undefined)
332
+ return namedWriteLibrary(knownList(), requested);
333
+ const choices = writeNeedsLibrary(knownList());
334
+ if (choices.length > 0)
335
+ return ambiguousLibrary(choices);
336
+ return selectLibrary();
317
337
  };
318
338
  const acquireForWrite = async (requested) => {
319
339
  const lib = selectForWrite(requested);
320
340
  if (isEngineError(lib))
321
341
  return lib;
322
- return acquire(requested);
342
+ // The library just resolved, not `requested` again by the read rules:
343
+ // today both give the same answer, but only this one was checked for a
344
+ // uuid shared between copies.
345
+ return prepare(lib);
323
346
  };
324
347
  /**
325
348
  * Shared by the engine://libraries resource and the list_libraries tool so
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "engine-dj-mcp",
3
- "version": "0.17.0",
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.",
3
+ "version": "0.17.2",
4
+ "mcpName": "io.github.Venut-Technologies/engine-dj-mcp",
5
+ "description": "Ask an AI assistant about your Engine DJ library: find tracks by BPM, key, genre or comment, audit duplicates and missing files, read cues and beatgrids, and, only once you turn writes on, build playlists and edit genre, comment, label, year or rating, with a backup taken first. Not affiliated with inMusic or Denon DJ.",
5
6
  "keywords": [
6
7
  "mcp",
7
8
  "model-context-protocol",
@@ -13,17 +14,24 @@
13
14
  "claude",
14
15
  "ai",
15
16
  "playlist",
16
- "harmonic-mixing"
17
+ "harmonic-mixing",
18
+ "mcp-server",
19
+ "modelcontextprotocol",
20
+ "dj-library",
21
+ "camelot",
22
+ "duplicates",
23
+ "cue-points",
24
+ "beatgrid"
17
25
  ],
18
26
  "license": "MIT",
19
- "author": "Mikhail Chereshnev <venuttv@gmail.com>",
27
+ "author": "Mikhail Chereshnev",
20
28
  "repository": {
21
29
  "type": "git",
22
- "url": "git+https://github.com/Venut-Labs/engine-dj-mcp.git"
30
+ "url": "git+https://github.com/Venut-Technologies/engine-dj-mcp.git"
23
31
  },
24
- "homepage": "https://github.com/Venut-Labs/engine-dj-mcp#readme",
32
+ "homepage": "https://github.com/Venut-Technologies/engine-dj-mcp#readme",
25
33
  "bugs": {
26
- "url": "https://github.com/Venut-Labs/engine-dj-mcp/issues"
34
+ "url": "https://github.com/Venut-Technologies/engine-dj-mcp/issues"
27
35
  },
28
36
  "type": "module",
29
37
  "engines": {