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 +116 -35
- package/dist/errors.js +6 -5
- package/dist/library-select.d.ts +21 -0
- package/dist/library-select.js +60 -8
- package/dist/server.js +63 -40
- package/package.json +15 -7
package/README.md
CHANGED
|
@@ -1,43 +1,61 @@
|
|
|
1
1
|
# engine-dj-mcp
|
|
2
2
|
|
|
3
|
-
[](https://github.com/Venut-Technologies/engine-dj-mcp/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/engine-dj-mcp)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
you ask
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
75
|
-
`add_tracks_to_playlist`, `remove_tracks_from_playlist`
|
|
76
|
-
`
|
|
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
|
|
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
|
|
668
|
-
`--allow-writes` the library is opened read-only at the OS level and
|
|
669
|
-
no tool that could write. With the flag,
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
package/dist/library-select.d.ts
CHANGED
|
@@ -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
|
package/dist/library-select.js
CHANGED
|
@@ -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.
|
|
93
|
-
`
|
|
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
|
|
114
|
-
const byUuid = libs.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
273
|
-
*
|
|
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
|
-
*
|
|
287
|
-
*
|
|
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
|
|
290
|
-
* library a later scan cannot see -- so a momentarily
|
|
291
|
-
* vanish from list_libraries. For a tie check that is
|
|
292
|
-
* direction that bites: pull the USB drive and one library is
|
|
293
|
-
* cache still holds two, and the write is refused naming a
|
|
294
|
-
* longer there. rescanLibraries() forgets a candidate
|
|
295
|
-
* which is exactly the distinction wanted here, and it
|
|
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
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return selectLibrary(
|
|
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
|
-
|
|
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.
|
|
4
|
-
"
|
|
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
|
|
27
|
+
"author": "Mikhail Chereshnev",
|
|
20
28
|
"repository": {
|
|
21
29
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/Venut-
|
|
30
|
+
"url": "git+https://github.com/Venut-Technologies/engine-dj-mcp.git"
|
|
23
31
|
},
|
|
24
|
-
"homepage": "https://github.com/Venut-
|
|
32
|
+
"homepage": "https://github.com/Venut-Technologies/engine-dj-mcp#readme",
|
|
25
33
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/Venut-
|
|
34
|
+
"url": "https://github.com/Venut-Technologies/engine-dj-mcp/issues"
|
|
27
35
|
},
|
|
28
36
|
"type": "module",
|
|
29
37
|
"engines": {
|