deep-roon-mcp 0.3.2 → 0.3.8

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
@@ -22,7 +22,7 @@ server of its own and sends nothing anywhere else. What you ask goes to your MCP
22
22
 
23
23
  ## Status
24
24
 
25
- Version 0.2: fifty-one tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
25
+ Version 0.3: fifty-three tools. Qobuz, TIDAL or both — whatever is signed in on your Core.
26
26
 
27
27
  **Reading** — changes nothing in Roon:
28
28
 
@@ -38,7 +38,7 @@ Version 0.2: fifty-one tools. Qobuz, TIDAL or both — whatever is signed in on
38
38
  | `roon_status` | How Deep Roon MCP stands: Core, pairing, internal protocol, self-test, newer version |
39
39
  | `roon_zones` | Roon's zones and groups |
40
40
  | `roon_now_playing` | What plays in each zone, with the volume |
41
- | `roon_queue` | A zone's queue |
41
+ | `roon_queue` | A zone's queue, numbered (1 is what plays) |
42
42
  | `roon_cover` | The cover of what plays, as an image |
43
43
  | `roon_history` | Roon's play history, newest first |
44
44
  | `roon_top` | Most played albums, artists or tracks over your whole history |
@@ -68,6 +68,7 @@ Version 0.2: fifty-one tools. Qobuz, TIDAL or both — whatever is signed in on
68
68
  | `roon_seek` | A position in the playing track |
69
69
  | `roon_volume` | A zone's volume, mute |
70
70
  | `roon_settings` | Shuffle, repeat, Roon Radio |
71
+ | `roon_queue_jump`, `roon_queue_remove` | Jump to an item of the queue, take items out of it |
71
72
  | `roon_queue_clear` | Clear what comes after the playing track (or everything) |
72
73
  | `roon_transfer` | Move what plays, with its queue, to another zone |
73
74
  | `roon_group`, `roon_ungroup` | Group zones, and undo it |
@@ -104,7 +105,7 @@ The settings (Claude Desktop › Settings › Extensions › Deep Roon MCP):
104
105
  | Setting | What it does |
105
106
  |---|---|
106
107
  | Roon Core address | Empty: found on your network. Fill in the Core's address for another subnet or a private VPN (Tailscale) |
107
- | Essentials only | Seventeen tools instead of fifty-one, for small models |
108
+ | Essentials only | Seventeen tools instead of fifty-three, for small models |
108
109
  | Use Roon's internal protocol | Off: only the official API — zones, playback, volume, grouping; no search, reviews, Focus or history |
109
110
 
110
111
  ### Other clients
@@ -15525,7 +15525,7 @@ var require_lib2 = __commonJS({
15525
15525
  var version2;
15526
15526
  var init_package = __esm({
15527
15527
  "package.json"() {
15528
- version2 = "0.3.2";
15528
+ version2 = "0.3.8";
15529
15529
  }
15530
15530
  });
15531
15531
 
@@ -15852,6 +15852,11 @@ function field2(o, name) {
15852
15852
  for (const [k, v] of Object.entries(f)) if (k.endsWith("::" + name)) return v;
15853
15853
  return void 0;
15854
15854
  }
15855
+ function imageKey(c, o, name = "Image") {
15856
+ const url2 = field2(obj(c, field2(o, name)), "Url");
15857
+ const m = /^broker:\/\/\/image\/([^.]+)\./.exec(String(url2 ?? ""));
15858
+ return m ? m[1] : void 0;
15859
+ }
15855
15860
  function dateParts(v) {
15856
15861
  if (!Buffer.isBuffer(v) || v.length < 2) return void 0;
15857
15862
  const n = new BinaryReader(Uint8Array.from(v)).flexInt();
@@ -15953,6 +15958,8 @@ function albumRow(c, o) {
15953
15958
  if (flag(field2(o, "IsListenLater"))) r.listen_later = true;
15954
15959
  const p = field2(o, "Popularity");
15955
15960
  if (typeof p === "number") r.popularity = Math.round(p * 100) / 100;
15961
+ const img = imageKey(c, o);
15962
+ if (img) r.image = img;
15956
15963
  return r;
15957
15964
  }
15958
15965
  function resultRow(c, o) {
@@ -15962,6 +15969,8 @@ function resultRow(c, o) {
15962
15969
  const r = { kind: "artist", key: String(field2(o, "PerformerId") ?? ""), name: field2(o, "Name") ?? "" };
15963
15970
  const n = field2(o, "MainPerformerAlbumCount");
15964
15971
  if (typeof n === "number" && n > 0) r.albums_in_library = n;
15972
+ const img = imageKey(c, o, "Photo") ?? imageKey(c, o, "Avatar");
15973
+ if (img) r.image = img;
15965
15974
  return r;
15966
15975
  }
15967
15976
  if (t === "TrackLite" || t === "Track") {
@@ -15976,6 +15985,8 @@ function resultRow(c, o) {
15976
15985
  const l = field2(o, "LengthSeconds");
15977
15986
  if (typeof l === "number") r.length = duration3(l);
15978
15987
  r.source = sourceOf(o, al) ?? "library";
15988
+ const img = imageKey(c, al) ?? imageKey(c, o);
15989
+ if (img) r.image = img;
15979
15990
  return r;
15980
15991
  }
15981
15992
  return { kind: t, title: field2(o, "Title") ?? field2(o, "Name") ?? "" };
@@ -15996,6 +16007,7 @@ var init_graph = __esm({
15996
16007
  return (o?.fields?.$items ?? o?.$items ?? []).filter((x) => typeof x === "string");
15997
16008
  }, "strings");
15998
16009
  flag = /* @__PURE__ */ __name((v) => v === true || Buffer.isBuffer(v) && v.length > 1 && v[0] >= 1, "flag");
16010
+ __name(imageKey, "imageKey");
15999
16011
  __name(dateParts, "dateParts");
16000
16012
  yearOf = /* @__PURE__ */ __name((v) => dateParts(v)?.[0], "yearOf");
16001
16013
  __name(dateText, "dateText");
@@ -16341,7 +16353,12 @@ async function bio(key, max, wiki) {
16341
16353
  for (const n of names2) for (const x of list(n)) {
16342
16354
  const o = obj(c, x);
16343
16355
  const name = field2(o, "Name");
16344
- if (name && !out.some((y) => y.name === name)) out.push({ key: String(field2(o, "PerformerId") ?? "") || void 0, name });
16356
+ if (name && !out.some((y) => y.name === name)) {
16357
+ const r = { key: String(field2(o, "PerformerId") ?? "") || void 0, name };
16358
+ const img2 = imageKey(c, o, "Photo") ?? imageKey(c, o, "Avatar");
16359
+ if (img2) r.image = img2;
16360
+ out.push(r);
16361
+ }
16345
16362
  }
16346
16363
  return out.length ? out.slice(0, 12) : void 0;
16347
16364
  }, "people");
@@ -16357,6 +16374,8 @@ async function bio(key, max, wiki) {
16357
16374
  }
16358
16375
  const g = list("Genres").filter((x) => typeof x === "string");
16359
16376
  if (g.length) artist.genres = g;
16377
+ const img = imageKey(c, p, "Photo") ?? imageKey(c, p, "Avatar");
16378
+ if (img) artist.image = img;
16360
16379
  return {
16361
16380
  artist,
16362
16381
  texts,
@@ -16388,6 +16407,8 @@ async function discography(key, wanted) {
16388
16407
  if (typeof n === "number") artist.albums_in_library = n;
16389
16408
  const from = field2(p, "BirthPlace");
16390
16409
  if (from) artist.from = from;
16410
+ const img = imageKey(c, p, "Photo") ?? imageKey(c, p, "Avatar");
16411
+ if (img) artist.image = img;
16391
16412
  return { service: SERVICE_NAME[service2], artist, albums };
16392
16413
  });
16393
16414
  }
@@ -49281,7 +49302,15 @@ async function history(limit3) {
49281
49302
  closeSoon("after the history");
49282
49303
  return {
49283
49304
  total: out.total ?? null,
49284
- plays: out.events.map((e) => ({ at: localTime(e.playedAt), title: e.title, artist: clean(e.artist), album: e.album ?? void 0, played_pct: e.completionPct ?? void 0 }))
49305
+ plays: out.events.map((e) => ({
49306
+ at: localTime(e.playedAt),
49307
+ title: e.title,
49308
+ artist: clean(e.artist),
49309
+ album: e.album ?? void 0,
49310
+ played_pct: e.completionPct ?? void 0,
49311
+ track_key: e.roonTrackId ?? void 0
49312
+ }))
49313
+ // for roon_play_tracks (13 Oct 2026, Rosso's history)
49285
49314
  };
49286
49315
  });
49287
49316
  }
@@ -49384,6 +49413,8 @@ async function nowPlaying() {
49384
49413
  r.volume_max = v.max;
49385
49414
  if (v.is_muted) r.muted = true;
49386
49415
  }
49416
+ const img = off?.now_playing?.image_key;
49417
+ if (img) r.image = img;
49387
49418
  const outs = c.graph.findByType("Endpoint").filter((e) => {
49388
49419
  const zz = field2(e, "Zone");
49389
49420
  return isRef2(zz) && String(zz.$ref) === String(z2.oid);
@@ -49405,6 +49436,10 @@ async function nowPlaying() {
49405
49436
  r.title = field2(tr, "Title");
49406
49437
  r.artist = clean(field2(al, "PerformedBy"));
49407
49438
  r.album = field2(al, "Title");
49439
+ const tk = field2(tr, "TrackId");
49440
+ if (tk !== void 0 && tk !== null) r.track_key = String(tk);
49441
+ const ak = field2(al, "AlbumId");
49442
+ if (ak !== void 0 && ak !== null) r.album_key = String(ak);
49408
49443
  const f = field2(tr, "Format");
49409
49444
  if (f) r.format = f;
49410
49445
  const l = field2(tr, "LengthSeconds");
@@ -49661,7 +49696,7 @@ function registerReadTools(server, internal = true) {
49661
49696
  const reg = /* @__PURE__ */ __name(((name, cfg, cb) => R(name) ? server.registerTool.call(server, name, cfg, cb) : void 0), "reg");
49662
49697
  reg("roon_search", {
49663
49698
  title: "Search Roon",
49664
- description: 'Search Roon \u2014 the user\'s Roon library and the catalogues of the streaming services signed in (Qobuz, TIDAL) at once; each row says its `source` (library, Qobuz, TIDAL). Returns albums (with format, e.g. FLAC 96kHz 24bit), artists and tracks. `kind` narrows to album, track or artist \u2014 use `kind: "track"` with the song title (and artist) to find one specific track; a broad query without `kind` can fill up with artists. Albums and tracks carry a `key` for `roon_album`; artists carry a `key` for `roon_artist` and `roon_bio`. Albums say their `year`, and `in_library` when the user has them in Roon. Keys stay valid between calls.',
49699
+ description: 'Search Roon \u2014 the user\'s Roon library and the catalogues of the streaming services signed in (Qobuz, TIDAL) at once; each row says its `source` (library, Qobuz, TIDAL). Rows carry `image`, the key of the cover: the Core serves it at http://<core address>:9330/image/<key>.jpg, or scaled at /image/<key>.300.jpg (for clients that draw; a chat shows covers with roon_cover). The `image` of roon_now_playing is an official key, served at /api/image/<key>?scale=fit&width=600&height=600. Returns albums (with format, e.g. FLAC 96kHz 24bit), artists and tracks. `kind` narrows to album, track or artist \u2014 use `kind: "track"` with the song title (and artist) to find one specific track; a broad query without `kind` can fill up with artists. Albums and tracks carry a `key` for `roon_album`; artists carry a `key` for `roon_artist` and `roon_bio`. Albums say their `year`, and `in_library` when the user has them in Roon. Keys stay valid between calls.',
49665
49700
  inputSchema: { query: external_exports.string().min(1), kind: external_exports.enum(["album", "track", "artist"]).optional(), limit: limit(100, 30) },
49666
49701
  annotations: READ
49667
49702
  }, ({ query, kind, limit: n }) => run(() => search(query, n ?? 30, kind)));
@@ -49958,9 +49993,47 @@ function queueRow(c, oid) {
49958
49993
  if (album2) r.album = album2;
49959
49994
  const l = field2(tr, "LengthSeconds");
49960
49995
  if (typeof l === "number") r.length = duration3(l);
49996
+ const k = field2(tr, "TrackId");
49997
+ if (k !== void 0 && k !== null) r.key = String(k);
49998
+ const img = imageKey(c, al) ?? imageKey(c, tr);
49999
+ if (img) r.image = img;
49961
50000
  return r;
49962
50001
  }
49963
50002
  __name(queueRow, "queueRow");
50003
+ async function queueItemAt(c, zoneName, n) {
50004
+ const { z: z2, q } = queueOf(c, zoneName);
50005
+ const refs = await queueItems(c, q);
50006
+ const at = Math.max(0, field2(c.graph.getObject(q.oid), "CurrentItemIndex") ?? 0);
50007
+ const oid = refs[at + n - 1];
50008
+ if (oid === void 0) throw new RoonError(`The queue of ${z2.name} has no item ${n} (${Math.max(0, refs.length - at)} from here)`, "invalid");
50009
+ return { z: z2, q, oid, refs, at };
50010
+ }
50011
+ __name(queueItemAt, "queueItemAt");
50012
+ async function queueJump(zoneName, n) {
50013
+ const r = await withCore(async (c) => {
50014
+ const { z: z2, q, oid } = await queueItemAt(c, zoneName, n);
50015
+ c.remoting.callMethodNoReply(q.oid, formatMethodSignature("Queue", "PlayFromHere", [{ type: "TransportItem", name: "item" }]), buildArgs([Arg.ref(oid)]));
50016
+ log(`queue_jump: ${z2.name} \u2192 item ${n}`);
50017
+ return { zone: z2.name, n };
50018
+ });
50019
+ return { ok: true, ...r, ...await started2(r.zone, PRIORITY["now"]) };
50020
+ }
50021
+ __name(queueJump, "queueJump");
50022
+ async function queueRemove(zoneName, ns) {
50023
+ return withCore(async (c) => {
50024
+ const { z: z2, q } = queueOf(c, zoneName);
50025
+ const refs = await queueItems(c, q);
50026
+ const at = Math.max(0, field2(c.graph.getObject(q.oid), "CurrentItemIndex") ?? 0);
50027
+ const oids = [...new Set(ns)].map((n) => refs[at + n - 1]).filter((o) => o !== void 0);
50028
+ if (!oids.length) throw new RoonError("No such items in the queue", "invalid");
50029
+ c.remoting.callMethodNoReply(q.oid, formatMethodSignature("Queue", "RemoveItems", [{ type: "IEnumerable<TransportItem>", name: "items" }]), buildArgs([Arg.collection(oids.map((o) => buildArgs([Arg.ref(o)])))]));
50030
+ log(`queue_remove: ${z2.name} \u2212 ${oids.length} item(s)`);
50031
+ await wait(300);
50032
+ const after = await queueItems(c, q);
50033
+ return { ok: true, zone: z2.name, removed: oids.length, left: Math.max(0, after.length - at) };
50034
+ });
50035
+ }
50036
+ __name(queueRemove, "queueRemove");
49964
50037
  async function queue2(zoneName, limit3) {
49965
50038
  return withCore(async (c) => {
49966
50039
  const { z: z2, q } = queueOf(c, zoneName);
@@ -50117,6 +50190,7 @@ __name(cover2, "cover");
50117
50190
  // src/tools/control.ts
50118
50191
  init_official();
50119
50192
  init_session();
50193
+ var CHANGES_QUEUE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
50120
50194
  var PLAYS = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
50121
50195
  var REMOVES = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
50122
50196
  var zone = external_exports.string().describe("A Roon zone name (see roon_zones).");
@@ -50169,6 +50243,18 @@ function registerControlTools(server, internal = true) {
50169
50243
  inputSchema: { zone, limit: external_exports.number().int().min(1).max(200).optional() },
50170
50244
  annotations: { readOnlyHint: true, openWorldHint: false }
50171
50245
  }, ({ zone: zn, limit: limit3 }) => run(() => queue2(zn, limit3 ?? 50)));
50246
+ reg("roon_queue_jump", {
50247
+ title: "Jump in the queue",
50248
+ description: "Play an item of the zone's queue now (`n` as roon_queue numbers them: 1 is what plays, 2 the next \u2026); what came before it is done. Only on an explicit request.",
50249
+ inputSchema: { zone, n: external_exports.number().int().min(1) },
50250
+ annotations: PLAYS
50251
+ }, ({ zone: zn, n }) => run(() => queueJump(zn, n)));
50252
+ reg("roon_queue_remove", {
50253
+ title: "Remove from the queue",
50254
+ description: "Take items out of the zone's queue, by their numbers from roon_queue (`ns`, e.g. [3, 4]). Only on an explicit request.",
50255
+ inputSchema: { zone, ns: external_exports.array(external_exports.number().int().min(2)).min(1).max(100).describe("Positions from roon_queue (2 and up: 1 is what plays).") },
50256
+ annotations: CHANGES_QUEUE
50257
+ }, ({ zone: zn, ns }) => run(() => queueRemove(zn, ns)));
50172
50258
  reg("roon_queue_clear", {
50173
50259
  title: "Clear the queue",
50174
50260
  description: "Clear the queue of a Roon zone. By default the track that plays keeps playing and everything after it is removed; `everything: true` also removes the playing track (the zone falls silent). Only on an explicit request.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deep-roon-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.8",
4
4
  "description": "An MCP server for Roon that reaches the parts the official API doesn't: reviews, biographies, Qobuz's and TIDAL's shelves, TIDAL's playlists, your history, search across your library and the streaming services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "René Bouwmeester <21277119+renebouwmeester@users.noreply.github.com>",