engine-dj-mcp 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,10 +67,11 @@ through 3.0.2 — Engine DJ 4.5 and 5.x.
67
67
 
68
68
  ## Tools
69
69
 
70
- Nine read-only tools, and a tenth — `create_playlist` — that appears only
71
- when you start the server with `--allow-writes`. Every tool that reads
72
- library data also accepts an optional `library` argument see
73
- [Choosing a library](#choosing-a-library).
70
+ Nine read-only tools, and four that write — `create_playlist`,
71
+ `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
72
+ `reorder_playlist` that appear only when you start the server with
73
+ `--allow-writes`. Every tool that reads library data also accepts an
74
+ optional `library` argument — see [Choosing a library](#choosing-a-library).
74
75
 
75
76
  ### `search_tracks`
76
77
 
@@ -204,7 +205,7 @@ server checks staleness itself before answering.
204
205
 
205
206
  ### `create_playlist`
206
207
 
207
- The only tool that writes, and the only one that is not registered at all
208
+ The first of the four tools that write, none of which is registered at all
208
209
  unless the server was started with `--allow-writes`.
209
210
 
210
211
  Creates one new top-level playlist from track ids — `track_ids` sets both
@@ -237,6 +238,100 @@ library is exactly what it was, and `committed_unverified` — the rare one —
237
238
  means the write may have landed but could not be verified afterwards, and is
238
239
  the only case that hands back a `backup_path`.
239
240
 
241
+ ### `add_tracks_to_playlist`
242
+
243
+ Adds one or more tracks to an **existing** playlist — this edits that
244
+ playlist's contents, it does not create a new one (`create_playlist` does
245
+ that). If the playlist's entry chain is already damaged, the write is
246
+ refused outright rather than repaired, and nothing is added.
247
+
248
+ A playlist that is a **folder** (`is_folder: true` — it has child lists) is
249
+ edited like any other: Engine has no separate folder type, a folder can hold
250
+ entries of its own, and all three edit tools add to, remove from and reorder
251
+ those entries without complaint. The lists inside it are untouched either
252
+ way.
253
+
254
+ | Argument | What it does |
255
+ | --- | --- |
256
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does: a name matching more than one playlist is refused with every candidate's id and full path listed, not guessed at. |
257
+ | `track_ids` | Ids from `search_tracks` or `get_tracks`, in the order they should appear. A track already in the playlist is refused as `duplicate_track` — Engine allows a track in a playlist only once. |
258
+ | `at` | Where the new tracks land, against the playlist's current 1-based positions (the same numbering `get_playlist_tracks` reports): `"start"`, `"end"` (the default), or `{ after_position: n }`. |
259
+
260
+ The result carries `playlist_id`, `tracks_added`, `positions` — where the
261
+ new tracks landed — `undo`, `undo_complete` (always `true` here) and
262
+ `backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
263
+ reverses this edit: the positions the tracks landed at, plus
264
+ `expect_track_ids` naming the tracks that landed there, so a playlist
265
+ something else changed in the meantime is refused rather than having the
266
+ wrong rows removed. Call it to undo rather than restoring `backup_path` —
267
+ see [Restoring a snapshot](#restoring-a-snapshot). Refusals add
268
+ `playlist_not_found`, `playlist_chain_damaged` and `invalid_position` to
269
+ `create_playlist`'s own list; `detail` works the same way.
270
+
271
+ ### `remove_tracks_from_playlist`
272
+
273
+ Removes one or more tracks from an **existing** playlist by position — this
274
+ edits that playlist's contents; it never touches any other playlist. If the
275
+ entry chain is already damaged, the write is refused outright rather than
276
+ repaired.
277
+
278
+ | Argument | What it does |
279
+ | --- | --- |
280
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
281
+ | `positions` | 1-based positions `get_playlist_tracks` reports for this playlist right now. Includes entries whose track is missing from the library (`missing: true`) — removing one is a legitimate way to clean up a hole, and the one removal `undo` cannot reverse (see below). |
282
+ | `expect_track_ids` | Optional, one entry per position: verifies each named position still holds the track expected before anything is removed, refusing the whole call otherwise. `null` means "this position should hold an entry whose track is missing", not "no expectation". |
283
+
284
+ The result carries `playlist_id`, `tracks_removed`, `removed` — each
285
+ position's `track_id`, `null` for a missing one — `undo`, `undo_complete` and
286
+ `backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
287
+ one per removed track that can be restored. Run them in the order given,
288
+ never in parallel and never reversed — each step's target position is
289
+ computed against the list as it stands after the previous step has already
290
+ run, so firing them out of order puts tracks back in the wrong places.
291
+ Preferred over restoring `backup_path` for the same reason as above.
292
+
293
+ `undo_complete` is `false` when the removal included an entry whose track is
294
+ missing from the library: that entry named a track this library does not
295
+ have, so no `add_tracks_to_playlist` call can put it back, and an
296
+ `undo_note` names those positions. The steps that are returned still run and
297
+ still restore everything else; the missing entries are recoverable only from
298
+ `backup_path`, which reverts the whole library.
299
+
300
+ Refusals: `playlist_not_found`, `playlist_chain_damaged`, and
301
+ `invalid_position` — for a repeated or out-of-range position, or one that
302
+ does not hold what `expect_track_ids` expected.
303
+
304
+ `playlist_chain_damaged` always means the same thing for all three edit
305
+ tools: the playlist's entry chain was already broken **before** the edit,
306
+ which is why the edit refused to touch it. If instead the check each edit
307
+ runs on its own work disagrees — the chain did not read back as it was
308
+ written — the transaction is rolled back and that comes back as
309
+ `library_unreadable`, with `detail: "not_committed"`. Both leave the library
310
+ exactly as it was; only the second one is this server saying it does not
311
+ understand what the library just did.
312
+
313
+ ### `reorder_playlist`
314
+
315
+ Reorders an **existing** playlist's tracks — this changes the order of that
316
+ playlist's existing entries; it adds nothing and removes nothing. If the
317
+ entry chain is already damaged, the write is refused outright rather than
318
+ repaired.
319
+
320
+ | Argument | What it does |
321
+ | --- | --- |
322
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
323
+ | `order` | A full permutation of `1..n`, `n` being the playlist's current entry count. `order[i]` names the *current* 1-based position (from `get_playlist_tracks`) of the track that should end up at position `i + 1`. A partial "move x to y" instruction is not accepted — name every position, including ones that do not move. |
324
+
325
+ The result carries `playlist_id`, `undo`, `undo_complete` (always `true`
326
+ here) and `backup_path`. `undo` is the exact inverse permutation, as a single
327
+ `reorder_playlist` call. Refusals: `playlist_not_found`,
328
+ `playlist_chain_damaged`, and `invalid_position` if `order` is not a full
329
+ permutation of the playlist's current positions.
330
+
331
+ Reordering to the order a playlist is already in is accepted and rewrites no
332
+ entry: it still stamps the playlist's `lastEditTime`, and still costs this
333
+ session's snapshot if nothing had been written yet.
334
+
240
335
  ## Resources
241
336
 
242
337
  - **`engine://schema`** — the field semantics an assistant needs before
@@ -278,11 +373,32 @@ created inside your `Engine Library` folder. The search index lives in
278
373
  Without `--allow-writes` the server has no tool that can write, and the
279
374
  paragraph above holds exactly as written: SQLite itself refuses.
280
375
 
281
- With the flag, one tool appears `create_playlist`. It adds a new playlist
282
- and nothing else: no existing playlist is renamed, reordered, emptied or
283
- deleted, and no track, cue or beatgrid is touched. The single change to an
284
- existing row is the previous last playlist's link, made by Engine's own
285
- trigger.
376
+ With the flag, four tools appear. `create_playlist` adds a new playlist and
377
+ nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
378
+ `reorder_playlist` go further: with the flag, an **existing** playlist can
379
+ now be changed, not only created its tracks added to, removed from, or put
380
+ in a different order. What each one touches is the named playlist's own
381
+ entries, plus exactly two rows elsewhere: that playlist's own row, whose
382
+ `lastEditTime` every edit stamps so Engine sees the change, and — for
383
+ `create_playlist` only — the previous last playlist's link, made by Engine's
384
+ own insert trigger. No other playlist is renamed, emptied or deleted, and no
385
+ track, cue or beatgrid is touched by any of the four.
386
+
387
+ Every edit returns `undo` — the exact tool call that reverses it, expressed
388
+ against the positions the edit itself produced — and `undo_complete`, saying
389
+ whether replaying it puts the playlist back exactly as it was. Replaying
390
+ `undo` is the right way back from an edit; restoring `backup_path` is not,
391
+ because it reverts the **whole library** to before this session's first
392
+ write, discarding every play count, import, cue and beatgrid change Engine
393
+ DJ has recorded since, along with the one edit you actually wanted undone.
394
+ See [Restoring a snapshot](#restoring-a-snapshot).
395
+
396
+ There is exactly one edit `undo` cannot reverse, and it says so rather than
397
+ pretending otherwise: removing an entry whose track is missing from the
398
+ library (`missing: true`). Such an entry names a track this library does not
399
+ have, so there is no track id to add back — the result comes back with
400
+ `undo_complete: false` and an `undo_note` naming those positions, and the
401
+ steps it does return still restore everything else.
286
402
 
287
403
  Before the first write of a session the database is snapshotted to
288
404
  `~/.engine-dj-mcp/backups/`, and every write of that session returns its
@@ -311,14 +427,18 @@ Engine will show the new playlist after it next re-reads the library.
311
427
  `backup_path` is not an undo. It is a copy of the **whole** `m.db` from
312
428
  before the session's first write, so putting it back reverts the entire
313
429
  library to that moment: every play count, import, cue, beatgrid and rating
314
- Engine DJ has written since is discarded along with the playlist you wanted
315
- gone. Reach for it only if the library itself is damaged — the case where
316
- `create_playlist` comes back with `detail: "committed_unverified"`.
317
-
318
- **To undo a playlist, delete it in Engine DJ.** Engine's own delete trigger
319
- repairs the playlist chain and cascades the entries away, which is exactly
320
- what removing it should do and is not something restoring a snapshot does
321
- better.
430
+ Engine DJ has written since is discarded along with the one edit you wanted
431
+ gone. Reach for it only if the library itself is damaged — the case where a
432
+ write comes back with `detail: "committed_unverified"`.
433
+
434
+ **To undo a playlist you created, delete it in Engine DJ.** Engine's own
435
+ delete trigger repairs the playlist chain and cascades the entries away,
436
+ which is exactly what removing it should do and is not something restoring
437
+ a snapshot does better. **To undo an edit to an existing playlist, replay
438
+ the `undo` the edit returned instead** — it names the precise
439
+ `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
440
+ `reorder_playlist` call that puts the playlist back exactly as it was,
441
+ without touching anything else Engine DJ has recorded since.
322
442
 
323
443
  `run_sql` accepts arbitrary SQL, but only the first statement is ever
324
444
  executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
@@ -327,7 +447,7 @@ chained or exfiltrating statement cannot slip past the read-only connection.
327
447
  If Engine DJ was closed uncleanly and left an unrecovered journal, this
328
448
  server will not open the library to "fix" it, with or without
329
449
  `--allow-writes` — rolling a journal forward is a repair on someone else's
330
- file, and `create_playlist` refuses such a library outright rather than
450
+ file, and every write tool refuses such a library outright rather than
331
451
  letting SQLite do it on the way in. It reports `library_needs_recovery` and
332
452
  asks you to launch Engine DJ once so it can recover its own library.
333
453
 
@@ -374,9 +494,9 @@ still test for the blob: `beatData` has no "written but empty" state.
374
494
 
375
495
  **It writes nothing but playlists, and only when you ask for it.** Without
376
496
  `--allow-writes` the library is opened read-only at the OS level and there is
377
- no tool that could write. With the flag, `create_playlist` adds playlists
378
- and that is the whole list. Not a cue, not a tag, not a rating, and not even
379
- the recovery of a journal Engine DJ left behind.
497
+ no tool that could write. With the flag, the four write tools add, edit and
498
+ reorder playlists — and that is the whole list. Not a cue, not a tag, not a
499
+ rating, and not even the recovery of a journal Engine DJ left behind.
380
500
 
381
501
  **It does not read play history.** `Track.timeLastPlayed` answers "what have I
382
502
  not played in six months?", but the separate Engine history database —
@@ -392,11 +512,12 @@ flag — a folder is simply a playlist that other playlists sit under — so
392
512
  `is_folder` means "has child lists". A folder you have emptied is
393
513
  indistinguishable from a playlist with no tracks.
394
514
 
395
- **Playlists can be created, not edited.** With `--allow-writes` a new
396
- playlist can be added; there is no reordering, renaming, deleting, or adding
397
- a track to a playlist that already exists, and no set lists or suggested
398
- transitions. It answers questions about the collection and writes down the
399
- answer if you ask; the mixing is yours.
515
+ **A playlist's tracks can be edited; the playlist itself cannot.** With
516
+ `--allow-writes` a new playlist can be created, and an existing one can have
517
+ tracks added, removed or reordered but not renamed, deleted, moved between
518
+ folders, or turned into a folder itself, and there are no set lists or
519
+ suggested transitions. It answers questions about the collection and writes
520
+ down the answer if you ask; the mixing is yours.
400
521
 
401
522
  **Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
402
523
  their version and reported as unsupported rather than read on a guess.
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track"];
1
+ export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track", "playlist_chain_damaged", "playlist_not_found", "invalid_position"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
package/dist/errors.js CHANGED
@@ -27,6 +27,12 @@ export const ERROR_CODES = [
27
27
  "playlist_exists",
28
28
  "unknown_track",
29
29
  "duplicate_track",
30
+ // Editing an existing playlist. playlist_chain_damaged is the one that
31
+ // matters: a chain with a cycle or a severed link cannot be edited without
32
+ // making it worse, and the edit would not notice.
33
+ "playlist_chain_damaged",
34
+ "playlist_not_found",
35
+ "invalid_position",
30
36
  ];
31
37
  export function err(error, message, extra = {}) {
32
38
  return { error, message, ...extra };
@@ -1,4 +1,4 @@
1
- import { type EngineError } from "./errors.js";
1
+ import { type EngineError, type ErrorCode } from "./errors.js";
2
2
  import type { QueryProcess } from "./proc/query-client.js";
3
3
  /**
4
4
  * Engine stores both playlist order and playlist-entry order as singly
@@ -191,6 +191,16 @@ export interface PlaylistSelector {
191
191
  export interface SelectorNames {
192
192
  id: string;
193
193
  name: string;
194
+ /**
195
+ * The code for "this library has no such playlist", when the caller has a
196
+ * more accurate one than the default `invalid_argument`. The write tools
197
+ * pass `playlist_not_found`, which their own store functions already
198
+ * return for an id that reaches them -- without this, that code was
199
+ * unreachable through MCP, because resolution runs first and every miss
200
+ * came back as invalid_argument. Ambiguity is never reported through this:
201
+ * a name matching several playlists really is a problem with the argument.
202
+ */
203
+ notFound?: ErrorCode;
194
204
  }
195
205
  export interface ResolvedPlaylist {
196
206
  playlist: PlaylistItem;
@@ -205,10 +215,14 @@ export interface ResolvedPlaylist {
205
215
  * whole function exists to prevent. The error names every candidate with its
206
216
  * id and its full path, so the retry is a copy-paste rather than a guess.
207
217
  *
208
- * Deliberately reuses `invalid_argument` rather than adding an error code:
209
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
210
- * in this library" is a problem with the argument, reported the same way an
211
- * unknown field name is -- with the recognised values in `detail`.
218
+ * Reuses `invalid_argument` by default rather than adding an error code: the
219
+ * taxonomy is closed (see errors.ts), and for a reader "the playlist you
220
+ * named is not in this library" is a problem with the argument, reported the
221
+ * same way an unknown field name is -- with the recognised values in
222
+ * `detail`. A caller that already owns a more accurate code for that one
223
+ * case passes it as `names.notFound`; the write tools do, so their documented
224
+ * `playlist_not_found` is what a client actually sees. Ambiguity is never
225
+ * reported through it.
212
226
  */
213
227
  export declare function resolvePlaylist(qp: QueryProcess, sel: PlaylistSelector, names?: SelectorNames): Promise<ResolvedPlaylist | EngineError>;
214
228
  /** One entry of a playlist, in playlist order. */
package/dist/playlists.js CHANGED
@@ -353,6 +353,28 @@ function describe(items) {
353
353
  ? `${shown}; and ${items.length - NAMED_IN_ERROR} more (call get_playlists to see them all)`
354
354
  : shown;
355
355
  }
356
+ /**
357
+ * "No such playlist", for both branches resolvePlaylist can reach it from.
358
+ *
359
+ * The default `invalid_argument` code has no write-path contract on
360
+ * `detail` (see errors.ts), so the candidate listing lives there, exactly as
361
+ * before. `playlist_not_found` is different: a write tool's caller reads
362
+ * `detail` to learn whether the library changed (see errors.ts), and every
363
+ * other `playlist_not_found` -- raised inside store/write.ts once an id
364
+ * reaches it -- carries `detail: "not_committed"`. Nothing was attempted
365
+ * here either, so this must match, which leaves no room in `detail` for the
366
+ * candidate listing; it moves into `message` instead, so a human still sees
367
+ * it.
368
+ */
369
+ function notFoundError(names, reason, items) {
370
+ const candidates = items.length
371
+ ? `Playlists (id -- path): ${describe(items)}`
372
+ : "This library has no playlists.";
373
+ if (names.notFound) {
374
+ return err(names.notFound, `${reason}. ${candidates}`, { detail: "not_committed" });
375
+ }
376
+ return err("invalid_argument", reason, { detail: candidates });
377
+ }
356
378
  /**
357
379
  * Turns "id or name" into one specific playlist, or an actionable error.
358
380
  *
@@ -361,10 +383,14 @@ function describe(items) {
361
383
  * whole function exists to prevent. The error names every candidate with its
362
384
  * id and its full path, so the retry is a copy-paste rather than a guess.
363
385
  *
364
- * Deliberately reuses `invalid_argument` rather than adding an error code:
365
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
366
- * in this library" is a problem with the argument, reported the same way an
367
- * unknown field name is -- with the recognised values in `detail`.
386
+ * Reuses `invalid_argument` by default rather than adding an error code: the
387
+ * taxonomy is closed (see errors.ts), and for a reader "the playlist you
388
+ * named is not in this library" is a problem with the argument, reported the
389
+ * same way an unknown field name is -- with the recognised values in
390
+ * `detail`. A caller that already owns a more accurate code for that one
391
+ * case passes it as `names.notFound`; the write tools do, so their documented
392
+ * `playlist_not_found` is what a client actually sees. Ambiguity is never
393
+ * reported through it.
368
394
  */
369
395
  export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name: "playlist_name" }) {
370
396
  const hasId = sel.id !== undefined && sel.id !== null;
@@ -385,11 +411,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
385
411
  if (hasId) {
386
412
  const found = tree.items.find((i) => i.id === sel.id);
387
413
  if (!found) {
388
- return err("invalid_argument", `No playlist with ${names.id} ${sel.id} in this library`, {
389
- detail: tree.items.length
390
- ? `Playlists (id -- path): ${describe(tree.items)}`
391
- : "This library has no playlists.",
392
- });
414
+ return notFoundError(names, `No playlist with ${names.id} ${sel.id} in this library`, tree.items);
393
415
  }
394
416
  return { playlist: found, warnings: tree.warnings };
395
417
  }
@@ -397,11 +419,7 @@ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name
397
419
  if (matches.length === 1)
398
420
  return { playlist: matches[0], warnings: tree.warnings };
399
421
  if (matches.length === 0) {
400
- return err("invalid_argument", `No playlist named "${sel.name}" in this library`, {
401
- detail: tree.items.length
402
- ? `Playlists (id -- path): ${describe(tree.items)}`
403
- : "This library has no playlists.",
404
- });
422
+ return notFoundError(names, `No playlist named "${sel.name}" in this library`, tree.items);
405
423
  }
406
424
  return err("invalid_argument", `"${sel.name}" names ${matches.length} playlists in this library`, {
407
425
  detail: `Playlist names are unique only within a folder. Pass ${names.id}, or pass the full ` +
package/dist/server.js CHANGED
@@ -17,10 +17,24 @@ import { auditLibrary, AuditInput, AUDIT_CHECKS } from "./tools/audit.js";
17
17
  import { runSql, RunSqlInput } from "./tools/sql.js";
18
18
  import { listLibraries } from "./tools/libraries.js";
19
19
  import { refreshIndex } from "./tools/refresh.js";
20
- import { CreatePlaylistInput, runCreatePlaylist } from "./tools/write-playlist.js";
20
+ import { CreatePlaylistInput, runCreatePlaylist, AddTracksToPlaylistInput, runAddTracksToPlaylist, RemoveTracksFromPlaylistInput, runRemoveTracksFromPlaylist, ReorderPlaylistInput, runReorderPlaylist, } from "./tools/write-playlist.js";
21
21
  import { err, isEngineError, libraryNeedsRecovery } from "./errors.js";
22
22
  const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
23
+ /**
24
+ * A write that only ever *adds*. `destructiveHint: false` is a claim with a
25
+ * defined meaning in MCP -- "this tool performs only additive updates" -- and
26
+ * clients use it to decide whether to confirm with the user first. True of
27
+ * create_playlist (a new playlist, nothing else touched) and of
28
+ * add_tracks_to_playlist (new entries, existing ones left where they are).
29
+ */
23
30
  const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false };
31
+ /**
32
+ * A write that can destroy or reorganise what is already there:
33
+ * remove_tracks_from_playlist deletes entries, reorder_playlist rewrites the
34
+ * order of a list a DJ may be playing from live. Advertising either as
35
+ * additive told a client it need not ask before calling.
36
+ */
37
+ const RW_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false };
24
38
  /**
25
39
  * name/version reported to every client on initialize. Read from
26
40
  * package.json rather than typed here, so the two cannot re-diverge the way
@@ -429,7 +443,9 @@ export async function createServer(opts = {}) {
429
443
  "write of this session; it is a recovery route for a damaged library, NOT an undo. " +
430
444
  "Restoring it reverts the entire library to that moment, discarding everything " +
431
445
  "Engine DJ has written since (play counts, imports, cue and beatgrid edits). " +
432
- "No existing playlist or entry is ever modified; this only adds a new one. " +
446
+ "No existing playlist is renamed, reordered, emptied or deleted, and no track, cue or " +
447
+ "beatgrid is touched. The one existing row that moves is the previous last playlist's " +
448
+ "link, and Engine's own insert trigger is what moves it. " +
433
449
  "Fails with playlist_exists if a top-level playlist already has that title, and " +
434
450
  "with library_busy if Engine DJ or a player is holding a conflicting lock on the " +
435
451
  "library right then -- nothing is written in that case, so retry rather than " +
@@ -446,6 +462,103 @@ export async function createServer(opts = {}) {
446
462
  return reply(state);
447
463
  return reply(await runCreatePlaylist(state.lib.path, state.lib.uuid, args, opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups")));
448
464
  });
465
+ const backupDirFor = () => opts.backupBaseDir ?? join(homedir(), ".engine-dj-mcp", "backups");
466
+ server.registerTool("add_tracks_to_playlist", {
467
+ title: "Add tracks to a playlist",
468
+ description: "Add one or more tracks to an EXISTING playlist -- this edits that playlist's " +
469
+ "contents, it does NOT create a new one (use create_playlist for that). Name the " +
470
+ "playlist with playlist_id or playlist_name, exactly one of the two, resolved the " +
471
+ "same way get_playlist_tracks does: a name matching more than one playlist in this " +
472
+ "library is refused, listing every candidate's id and full path, rather than guessed " +
473
+ "at. track_ids are ids from search_tracks or get_tracks; a track already in the " +
474
+ "playlist is refused as duplicate_track, since Engine allows a track in a playlist " +
475
+ "only once. at chooses where the new tracks land, using the playlist's current " +
476
+ "1-based positions (the same numbering get_playlist_tracks reports): \"start\", " +
477
+ "\"end\" (the default), or { after_position: n }. If this playlist's entry chain is " +
478
+ "already damaged, the write is refused outright rather than repaired -- nothing is " +
479
+ "added, and the error names what is broken. " +
480
+ "On success, the result's `undo` is the exact remove_tracks_from_playlist call that " +
481
+ "reverses this edit -- the positions the new tracks landed at, plus expect_track_ids " +
482
+ "naming the tracks that landed there, so a list someone changed in the meantime is " +
483
+ "refused rather than having the wrong rows removed -- and `undo_complete` is true. " +
484
+ "Call it to undo " +
485
+ "rather than restoring backup_path, which reverts the WHOLE library to before this " +
486
+ "session's first write, discarding every play count, import, cue and beatgrid change " +
487
+ "Engine DJ has recorded since -- not just this one edit. backup_path is only a " +
488
+ "last-resort recovery route for a damaged library, never an undo. " +
489
+ LIBRARY_SELECTION_NOTE,
490
+ inputSchema: { ...AddTracksToPlaylistInput.shape, library: LibraryArg },
491
+ annotations: RW,
492
+ }, async (args) => {
493
+ const state = await acquire(args.library);
494
+ if (isEngineError(state))
495
+ return reply(state);
496
+ return reply(await runAddTracksToPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
497
+ });
498
+ server.registerTool("remove_tracks_from_playlist", {
499
+ title: "Remove tracks from a playlist",
500
+ description: "Remove one or more tracks from an EXISTING playlist by position -- this edits that " +
501
+ "playlist's contents; it never touches any other playlist. Name the playlist with " +
502
+ "playlist_id or playlist_name, exactly one of the two, resolved the same way " +
503
+ "get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
504
+ "not guessed at). positions are the 1-based positions get_playlist_tracks reports " +
505
+ "for THIS playlist right now -- they include entries whose track is missing from the " +
506
+ "library (get_playlist_tracks marks those missing: true), and removing one of those " +
507
+ "is a legitimate way to clean up a hole -- but it is the one removal that cannot be " +
508
+ "undone: such an entry names no track id, so no add_tracks_to_playlist call can put " +
509
+ "it back, and the result says so with undo_complete: false plus an undo_note naming " +
510
+ "those positions. expect_track_ids is optional and, when " +
511
+ "given, must have one entry per position: it verifies each named position still " +
512
+ "holds the track expected before anything is removed, refusing the whole call " +
513
+ "otherwise; null there means \"this position should hold an entry whose track is " +
514
+ "missing\", not \"no expectation\". If this playlist's entry chain is already " +
515
+ "damaged, the write is refused outright rather than repaired. " +
516
+ "On success, the result's `undo` is a SEQUENCE of add_tracks_to_playlist calls, one " +
517
+ "per removed track THAT CAN BE RESTORED -- run them IN THE ORDER GIVEN, never in " +
518
+ "parallel and never " +
519
+ "reversed: each step's target position is computed against the list as it stands " +
520
+ "after the previous step has already run, so firing them out of order or " +
521
+ "concurrently puts tracks back in the wrong places. Check `undo_complete`: false " +
522
+ "means one or more removed entries had no track to name and are gone for good -- " +
523
+ "`undo_note` says which positions, and the remaining steps still restore everything " +
524
+ "else. Preferred over restoring " +
525
+ "backup_path, which reverts the WHOLE library to before this session's first write, " +
526
+ "discarding everything Engine DJ has recorded since -- not just this edit. " +
527
+ LIBRARY_SELECTION_NOTE,
528
+ inputSchema: { ...RemoveTracksFromPlaylistInput.shape, library: LibraryArg },
529
+ annotations: RW_DESTRUCTIVE,
530
+ }, async (args) => {
531
+ const state = await acquire(args.library);
532
+ if (isEngineError(state))
533
+ return reply(state);
534
+ return reply(await runRemoveTracksFromPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
535
+ });
536
+ server.registerTool("reorder_playlist", {
537
+ title: "Reorder a playlist",
538
+ description: "Reorder an EXISTING playlist's tracks -- this changes the order of that playlist's " +
539
+ "existing entries; it adds nothing and removes nothing. Name the playlist with " +
540
+ "playlist_id or playlist_name, exactly one of the two, resolved the same way " +
541
+ "get_playlist_tracks does (an ambiguous name is refused with every candidate listed, " +
542
+ "not guessed at). order must be a full permutation of 1..n, n being the playlist's " +
543
+ "current entry count: order[i] names the CURRENT 1-based position (from " +
544
+ "get_playlist_tracks) of the track that should end up at position i + 1. A partial " +
545
+ "'move x to y' instruction is not accepted -- name every position, including ones " +
546
+ "that do not move. If this playlist's entry chain is already damaged, the write is " +
547
+ "refused outright rather than repaired. " +
548
+ "On success, the result's `undo` is the exact inverse permutation, as a single " +
549
+ "reorder_playlist call, and `undo_complete` is true; prefer it over restoring " +
550
+ "backup_path, which reverts the " +
551
+ "WHOLE library to before this session's first write, discarding everything Engine DJ " +
552
+ "has recorded since -- not just this reorder. " +
553
+ LIBRARY_SELECTION_NOTE,
554
+ inputSchema: { ...ReorderPlaylistInput.shape, library: LibraryArg },
555
+ annotations: RW_DESTRUCTIVE,
556
+ }, async (args) => {
557
+ const state = await acquire(args.library);
558
+ if (isEngineError(state))
559
+ return reply(state);
560
+ return reply(await runReorderPlaylist(state.qp, state.lib.path, state.lib.uuid, args, backupDirFor()));
561
+ });
449
562
  }
450
563
  /**
451
564
  * There was previously no way to shut this down at all: createServer