roon-mcp 0.1.0 → 0.2.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/CHANGELOG.md +19 -0
- package/README.md +47 -11
- package/dist/RoonMcpServer.d.ts +3 -1
- package/dist/RoonMcpServer.js +215 -39
- package/dist/RoonMcpServer.js.map +1 -1
- package/dist/SearchService.d.ts +12 -0
- package/dist/SearchService.js +63 -1
- package/dist/SearchService.js.map +1 -1
- package/dist/SearchService.test.js +79 -4
- package/dist/SearchService.test.js.map +1 -1
- package/dist/TransportService.d.ts +64 -0
- package/dist/TransportService.js +216 -0
- package/dist/TransportService.js.map +1 -0
- package/dist/TransportService.test.d.ts +1 -0
- package/dist/TransportService.test.js +253 -0
- package/dist/TransportService.test.js.map +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +37 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The version history source of truth is git tags in the format `vMAJOR.MINOR.PATCH`.
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-06-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `now_playing`, `control_playback`, `set_volume`, and `mute` tools to expose
|
|
12
|
+
Roon's transport controls and current-track state. Previously the server
|
|
13
|
+
could only start playback, not steer it.
|
|
14
|
+
- `search_music({ type: "artist", includeStreaming: true })` now appends a
|
|
15
|
+
streaming track mix by the requested artist (via a track search filtered
|
|
16
|
+
to that artist's subtitle), so requests like "queue multiple *Artist*
|
|
17
|
+
songs" work even when the artist is missing from the local library or
|
|
18
|
+
has zero library albums. The streaming path mirrors the existing
|
|
19
|
+
genre `includeStreaming` flow: library candidates first, then
|
|
20
|
+
ready-to-play streaming tracks appended with `sourceGroup: "Streaming"`.
|
|
21
|
+
- `search_music({ type: "artist" })` now reports an artist with no library
|
|
22
|
+
content (e.g. `subtitle: "0 Albums"`) via the result's `message` field,
|
|
23
|
+
so the agent can opt in to the streaming fallback up front instead of
|
|
24
|
+
discovering the dead end at `get_tracks_for` time.
|
|
25
|
+
|
|
7
26
|
## [0.1.0] - 2026-06-21
|
|
8
27
|
|
|
9
28
|
### Added
|
package/README.md
CHANGED
|
@@ -60,12 +60,16 @@ npm install -g roon-mcp
|
|
|
60
60
|
| Tool | Purpose |
|
|
61
61
|
| --- | --- |
|
|
62
62
|
| `list_zones()` | List playable zones/outputs (id, name, state, output ids). |
|
|
63
|
-
| `search_music({ query, type?, limit?, includeStreaming? })` | Resolve a text query into ranked browse candidates (opaque, session-scoped item keys). `type` (`artist`/`album`/`track`/`genre`/`playlist`/`radio`) restricts the category; for non-genre types an empty typed search broadens to all categories. See [
|
|
63
|
+
| `search_music({ query, type?, limit?, includeStreaming? })` | Resolve a text query into ranked browse candidates (opaque, session-scoped item keys). `type` (`artist`/`album`/`track`/`genre`/`playlist`/`radio`) restricts the category; for non-genre types an empty typed search broadens to all categories. See [Streaming search](#streaming-search-genre-and-artist) for `type:"genre"`/`type:"artist"` and `includeStreaming`. |
|
|
64
64
|
| `get_tracks_for({ itemKey, limit? })` | Expand an artist/album/genre/playlist candidate into concrete playable tracks. |
|
|
65
65
|
| `play_now({ zoneId?, itemKey, shuffle? })` | Immediately play one search candidate; `zoneId` optional (defaults as above). |
|
|
66
66
|
| `enqueue_and_play({ zoneId?, itemKeys, shuffle? })` | Build an ad-hoc queue from curated item keys and start it (**replaces** the zone's queue); reports queued/skipped. |
|
|
67
|
+
| `now_playing({ zoneId? })` | Snapshot of the zone's current track — state, title, artist, album, seek position. `title`/`artist`/`album` are undefined when nothing is playing. |
|
|
68
|
+
| `control_playback({ zoneId?, action })` | Run a transport verb: `pause` / `resume` / `next` / `previous` / `stop`. |
|
|
69
|
+
| `set_volume({ zoneId?, level })` | Set the zone's volume to `level` percent (0–100). Rescales to each output's native range; incremental outputs are reported as skipped. |
|
|
70
|
+
| `mute({ zoneId?, muted })` | Mute (`muted: true`) or unmute (`muted: false`) every output in the zone. |
|
|
67
71
|
|
|
68
|
-
###
|
|
72
|
+
### Streaming search (genre and artist)
|
|
69
73
|
|
|
70
74
|
Genres don't appear in Roon's flat search hierarchy, so `search_music({ type: "genre" })`
|
|
71
75
|
is handled specially: the server walks Roon's dedicated **Genres** tree (cached per
|
|
@@ -75,15 +79,47 @@ never silently broadens to artists/albums. These candidates are **library-scoped
|
|
|
75
79
|
(genres present in your collection, including TIDAL albums you've added). Expand one
|
|
76
80
|
with `get_tracks_for` to get a cross-album mix of that genre.
|
|
77
81
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
Artist search is library-scoped too: `search_music({ type: "artist" })` returns artists
|
|
83
|
+
present in your collection. When the top-ranked artist has no library content — e.g. a
|
|
84
|
+
node with `subtitle: "0 Albums"` because all albums were removed, or an artist not in
|
|
85
|
+
your library at all but available on TIDAL — the result's `message` field carries a
|
|
86
|
+
hint so the agent can opt in to the streaming path (see below) without having to
|
|
87
|
+
discover the dead end at `get_tracks_for` time.
|
|
88
|
+
|
|
89
|
+
Pass **`includeStreaming: true`** (meaningful for both `type:"genre"` and
|
|
90
|
+
`type:"artist"`) to also pull a track mix from streaming services for discovery beyond
|
|
91
|
+
your library:
|
|
92
|
+
|
|
93
|
+
- For `type:"genre"`, the server takes the genre-relevant **albums** the flat search
|
|
94
|
+
surfaces and samples tracks across them. Library genre nodes come first, then
|
|
95
|
+
ready-to-play streaming tracks (each a `track` candidate, source group `Streaming`)
|
|
96
|
+
appended after.
|
|
97
|
+
- For `type:"artist"`, the server runs a track search and filters to entries by that
|
|
98
|
+
artist (matching the track's subtitle, which Roon uses for the artist on track rows).
|
|
99
|
+
Library artist candidates come first, then streaming tracks by the same artist
|
|
100
|
+
appended after. This is the path that unblocks "play/queue multiple *Artist* songs"
|
|
101
|
+
for an artist with no library content: the streaming tracks are queue-playable
|
|
102
|
+
directly via `enqueue_and_play`. Results are best-effort — a small fraction may be
|
|
103
|
+
features or compilations; the agent's per-artist cap / dedupe handles that.
|
|
104
|
+
|
|
105
|
+
Default is `false` (library only).
|
|
106
|
+
|
|
107
|
+
> Cost: with `includeStreaming` on, each sampled album/track re-navigates the flat
|
|
108
|
+
> search, so an opt-in streaming search does a handful of extra browse round-trips.
|
|
109
|
+
|
|
110
|
+
### Now playing & transport
|
|
111
|
+
|
|
112
|
+
`now_playing({ zoneId? })` returns a structured snapshot (state, title, artist,
|
|
113
|
+
album, seek position) so the agent can confirm what's on and where before
|
|
114
|
+
running a transport verb. `control_playback` takes one verb at a time
|
|
115
|
+
(`pause`, `resume`, `next`, `previous`, `stop`) — there is no compound
|
|
116
|
+
"pause and skip." For "louder" / "softer" without a number, `set_volume` is
|
|
117
|
+
absolute, so the agent should ask for a target percent or apply a default
|
|
118
|
+
delta; volume isn't reported in `now_playing`. Volume and mute fan out to
|
|
119
|
+
every output in the resolved zone and rescale per output, so a single
|
|
120
|
+
`set_volume({ level: 50 })` works correctly across a grouped zone with mixed
|
|
121
|
+
dB / numeric devices; incremental outputs (IR blasters and the like) are
|
|
122
|
+
reported as `skipped` in the result.
|
|
87
123
|
|
|
88
124
|
## Assumptions
|
|
89
125
|
|
package/dist/RoonMcpServer.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { PlaybackService } from "./PlaybackService.js";
|
|
|
2
2
|
import { RoonClient } from "./RoonClient.js";
|
|
3
3
|
import { SearchService } from "./SearchService.js";
|
|
4
4
|
import { TrackExpansionService } from "./TrackExpansionService.js";
|
|
5
|
+
import { TransportService } from "./TransportService.js";
|
|
5
6
|
import { ZoneService } from "./ZoneService.js";
|
|
6
7
|
/**
|
|
7
8
|
* Owns MCP startup and tool registration, and maps tool calls to services.
|
|
@@ -13,8 +14,9 @@ export declare class RoonMcpServer {
|
|
|
13
14
|
private readonly search;
|
|
14
15
|
private readonly tracks;
|
|
15
16
|
private readonly playback;
|
|
17
|
+
private readonly transport;
|
|
16
18
|
private readonly server;
|
|
17
|
-
constructor(roon: RoonClient, zones: ZoneService, search: SearchService, tracks: TrackExpansionService, playback: PlaybackService);
|
|
19
|
+
constructor(roon: RoonClient, zones: ZoneService, search: SearchService, tracks: TrackExpansionService, playback: PlaybackService, transport: TransportService);
|
|
18
20
|
registerTools(): void;
|
|
19
21
|
start(): Promise<void>;
|
|
20
22
|
stop(): Promise<void>;
|
package/dist/RoonMcpServer.js
CHANGED
|
@@ -21,13 +21,15 @@ export class RoonMcpServer {
|
|
|
21
21
|
search;
|
|
22
22
|
tracks;
|
|
23
23
|
playback;
|
|
24
|
+
transport;
|
|
24
25
|
server;
|
|
25
|
-
constructor(roon, zones, search, tracks, playback) {
|
|
26
|
+
constructor(roon, zones, search, tracks, playback, transport) {
|
|
26
27
|
this.roon = roon;
|
|
27
28
|
this.zones = zones;
|
|
28
29
|
this.search = search;
|
|
29
30
|
this.tracks = tracks;
|
|
30
31
|
this.playback = playback;
|
|
32
|
+
this.transport = transport;
|
|
31
33
|
this.server = new McpServer({
|
|
32
34
|
name: "roon-mcp",
|
|
33
35
|
version: "0.1.0",
|
|
@@ -37,8 +39,12 @@ export class RoonMcpServer {
|
|
|
37
39
|
registerTools() {
|
|
38
40
|
this.server.registerTool("list_zones", {
|
|
39
41
|
title: "List Roon zones",
|
|
40
|
-
description: "
|
|
41
|
-
"
|
|
42
|
+
description: "Use this when the user asks which rooms, speakers, or outputs Roon can " +
|
|
43
|
+
"play to, or before starting playback when the target zone is unclear " +
|
|
44
|
+
"(e.g. \"what zones are on?\", \"play in the kitchen\", \"which speaker is " +
|
|
45
|
+
"in the office?\"). Lists every zone/output the paired Core exposes with " +
|
|
46
|
+
"its id, display name, current playback state, and output ids. Call this " +
|
|
47
|
+
"first if no zone is obvious and ROON_DEFAULT_ZONE is not set.",
|
|
42
48
|
inputSchema: {},
|
|
43
49
|
}, async () => {
|
|
44
50
|
try {
|
|
@@ -52,24 +58,38 @@ export class RoonMcpServer {
|
|
|
52
58
|
});
|
|
53
59
|
this.server.registerTool("search_music", {
|
|
54
60
|
title: "Search Roon music",
|
|
55
|
-
description: "
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"genre
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
61
|
+
description: "Use this when the user names music to find — an artist, album, track, " +
|
|
62
|
+
"playlist, radio station, or genre (e.g. \"find Tycho\", \"look up the " +
|
|
63
|
+
"album In Rainbows\", \"play some Psytrance\", \"anything by Ryuichi " +
|
|
64
|
+
"Sakamoto?\"). Resolves a free-text query into ranked Roon browse " +
|
|
65
|
+
"candidates. Optionally restrict to one item type (artist, album, track, " +
|
|
66
|
+
"genre, playlist, radio); for non-genre types, an empty typed search " +
|
|
67
|
+
"broadens to all categories. type:\"genre\" is special — genres don't " +
|
|
68
|
+
"appear in Roon's flat search, so the server walks the dedicated Genres " +
|
|
69
|
+
"tree and returns the nearest-match genre nodes (with parent path in the " +
|
|
70
|
+
"subtitle) without broadening; e.g. \"Psychedelic Trance\" yields " +
|
|
71
|
+
"\"Psytrance\"/\"Trance\". Set includeStreaming:true (meaningful for " +
|
|
72
|
+
"type:\"genre\" and type:\"artist\") to also pull a track mix from " +
|
|
73
|
+
"streaming services (e.g. TIDAL): for a genre the server takes the " +
|
|
74
|
+
"genre-relevant albums and samples tracks across them; for an artist " +
|
|
75
|
+
"the server runs a track search and filters to entries by that artist. " +
|
|
76
|
+
"Library candidates come first, then ready-to-play streaming tracks " +
|
|
77
|
+
"(each a `track` candidate, source group `Streaming`) appended after. " +
|
|
78
|
+
"An artist with no library content (e.g. subtitle \"0 Albums\") is " +
|
|
79
|
+
"reported via the result's `message` so the agent can opt in. Returns " +
|
|
80
|
+
"opaque, session-scoped item keys for use by the playback tools — pair " +
|
|
81
|
+
"with get_tracks_for to expand, then play_now or enqueue_and_play.",
|
|
67
82
|
inputSchema: {
|
|
68
|
-
query: z
|
|
83
|
+
query: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.describe("What to search for — an artist, album, track, playlist, radio, or " +
|
|
87
|
+
"genre name (e.g. 'Tycho', 'In Rainbows', 'Dark Ambient')."),
|
|
69
88
|
type: z
|
|
70
89
|
.enum(MUSIC_ITEM_TYPES)
|
|
71
90
|
.optional()
|
|
72
|
-
.describe("Restrict
|
|
91
|
+
.describe("Restrict the search to one item type. Omit to broaden across all " +
|
|
92
|
+
"non-genre categories. Use 'genre' for music-genre lookups."),
|
|
73
93
|
limit: z
|
|
74
94
|
.number()
|
|
75
95
|
.int()
|
|
@@ -80,9 +100,11 @@ export class RoonMcpServer {
|
|
|
80
100
|
includeStreaming: z
|
|
81
101
|
.boolean()
|
|
82
102
|
.optional()
|
|
83
|
-
.describe("
|
|
84
|
-
"
|
|
85
|
-
"
|
|
103
|
+
.describe("Meaningful for type 'genre' and type 'artist': also pull a track " +
|
|
104
|
+
"mix from streaming services (e.g. TIDAL). For a genre, library " +
|
|
105
|
+
"genre nodes come first, then sampled streaming tracks. For an " +
|
|
106
|
+
"artist, library candidates come first, then streaming tracks by " +
|
|
107
|
+
"that artist. Default false (library only)."),
|
|
86
108
|
},
|
|
87
109
|
}, async (args) => {
|
|
88
110
|
try {
|
|
@@ -95,16 +117,21 @@ export class RoonMcpServer {
|
|
|
95
117
|
});
|
|
96
118
|
this.server.registerTool("get_tracks_for", {
|
|
97
119
|
title: "Expand a Roon item into tracks",
|
|
98
|
-
description: "
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
120
|
+
description: "Use this after search_music when the user wants a concrete list of " +
|
|
121
|
+
"songs — to preview tracks, build a queue, or pick one to start with " +
|
|
122
|
+
"(e.g. \"what tracks are on this album?\", \"give me 5 tracks of " +
|
|
123
|
+
"Dark Ambient\", \"what's on this playlist?\"). Expands an artist, " +
|
|
124
|
+
"album, genre, or playlist candidate into concrete playable tracks. " +
|
|
125
|
+
"Pass an itemKey from a recent search_music result. Returns track " +
|
|
126
|
+
"candidates with session-scoped item keys (use them promptly with " +
|
|
127
|
+
"enqueue_and_play). Non-expandable items return empty tracks with a " +
|
|
128
|
+
"skipped reason rather than an error.",
|
|
103
129
|
inputSchema: {
|
|
104
130
|
itemKey: z
|
|
105
131
|
.string()
|
|
106
132
|
.min(1)
|
|
107
|
-
.describe("
|
|
133
|
+
.describe("Item key to expand — from a recent search_music candidate (artist, " +
|
|
134
|
+
"album, genre, or playlist)."),
|
|
108
135
|
limit: z
|
|
109
136
|
.number()
|
|
110
137
|
.int()
|
|
@@ -124,22 +151,30 @@ export class RoonMcpServer {
|
|
|
124
151
|
});
|
|
125
152
|
this.server.registerTool("play_now", {
|
|
126
153
|
title: "Play an item now in a Roon zone",
|
|
127
|
-
description: "
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
154
|
+
description: "Use this when the user wants one specific thing playing right now — " +
|
|
155
|
+
"an album, artist, playlist, genre mix, or single track (e.g. \"play " +
|
|
156
|
+
"Tycho\", \"put on In Rainbows\", \"start some Psytrance in the office\", " +
|
|
157
|
+
"\"play that track\"). Immediately plays a single search candidate in " +
|
|
158
|
+
"the target zone and replaces whatever was queued. Pass an itemKey from " +
|
|
159
|
+
"a recent search_music (or get_tracks_for) result — item keys are " +
|
|
160
|
+
"session-scoped, so use a fresh one. zoneId is optional: omit it to use " +
|
|
161
|
+
"ROON_DEFAULT_ZONE, or fall back to the only zone / an \"Office\" zone / " +
|
|
162
|
+
"the currently-playing zone; if it still can't decide it returns " +
|
|
163
|
+
"ZONE_AMBIGUOUS so the agent can ask the user or call list_zones. " +
|
|
164
|
+
"Optionally shuffle. Returns a PlaybackResult.",
|
|
133
165
|
inputSchema: {
|
|
134
166
|
zoneId: z
|
|
135
167
|
.string()
|
|
136
168
|
.min(1)
|
|
137
169
|
.optional()
|
|
138
|
-
.describe("Target zone id or output id
|
|
170
|
+
.describe("Target zone id or output id from list_zones (e.g. an id, or a name " +
|
|
171
|
+
"substring like 'Office'). Omit to use ROON_DEFAULT_ZONE or fall " +
|
|
172
|
+
"back automatically."),
|
|
139
173
|
itemKey: z
|
|
140
174
|
.string()
|
|
141
175
|
.min(1)
|
|
142
|
-
.describe("
|
|
176
|
+
.describe("What to play — item key from a recent search_music or get_tracks_for " +
|
|
177
|
+
"result (album, artist, playlist, genre, track, etc.)."),
|
|
143
178
|
shuffle: z
|
|
144
179
|
.boolean()
|
|
145
180
|
.optional()
|
|
@@ -156,10 +191,15 @@ export class RoonMcpServer {
|
|
|
156
191
|
});
|
|
157
192
|
this.server.registerTool("enqueue_and_play", {
|
|
158
193
|
title: "Build and start a curated Roon queue",
|
|
159
|
-
description: "
|
|
160
|
-
"
|
|
194
|
+
description: "Use this when the user wants a custom lineup — a mix of artists, a " +
|
|
195
|
+
"shuffled selection across albums, a hand-picked set of tracks, or any " +
|
|
196
|
+
"time \"queue\", \"setlist\", \"mix of\", or \"play these in order\" comes " +
|
|
197
|
+
"up (e.g. \"queue up five Tycho tracks then some Boards of Canada\", " +
|
|
198
|
+
"\"shuffle 10 ambient tracks\", \"build a set: artist A, then B, then C\"). " +
|
|
199
|
+
"Builds an ad-hoc queue from an ordered list of curated item keys and " +
|
|
200
|
+
"starts playback in the target zone. This replaces the zone's current " +
|
|
161
201
|
"queue: the first playable item starts immediately (Play Now), the rest " +
|
|
162
|
-
"are appended in order. Pass itemKeys from recent get_tracks_for/" +
|
|
202
|
+
"are appended in order. Pass itemKeys from recent get_tracks_for / " +
|
|
163
203
|
"search_music results (use them promptly — they are session-scoped). " +
|
|
164
204
|
"zoneId is optional (omit to use the default zone; see play_now). " +
|
|
165
205
|
"Optionally shuffle. Returns a PlaybackResult with queued/skipped counts " +
|
|
@@ -169,11 +209,13 @@ export class RoonMcpServer {
|
|
|
169
209
|
.string()
|
|
170
210
|
.min(1)
|
|
171
211
|
.optional()
|
|
172
|
-
.describe("Target zone id or output id
|
|
212
|
+
.describe("Target zone id or output id from list_zones. Omit to use the default " +
|
|
213
|
+
"zone (see play_now)."),
|
|
173
214
|
itemKeys: z
|
|
174
215
|
.array(z.string().min(1))
|
|
175
216
|
.min(1)
|
|
176
|
-
.describe("Ordered item keys to queue
|
|
217
|
+
.describe("Ordered item keys to queue, from recent get_tracks_for or " +
|
|
218
|
+
"search_music results (tracks, albums, artists, etc.)."),
|
|
177
219
|
shuffle: z
|
|
178
220
|
.boolean()
|
|
179
221
|
.optional()
|
|
@@ -188,6 +230,140 @@ export class RoonMcpServer {
|
|
|
188
230
|
return toToolError(err);
|
|
189
231
|
}
|
|
190
232
|
});
|
|
233
|
+
this.server.registerTool("now_playing", {
|
|
234
|
+
title: "Get the currently-playing track in a Roon zone",
|
|
235
|
+
description: "Use this when the user asks what is currently playing, what song is " +
|
|
236
|
+
"on, who's singing, what just started, or wants a snapshot before " +
|
|
237
|
+
"skipping or pausing (e.g. \"what's playing?\", \"what's playing in " +
|
|
238
|
+
"the kitchen?\", \"who's this?\", \"what's the current track?\", " +
|
|
239
|
+
"\"what song is this?\"). Returns a structured snapshot: zone id and " +
|
|
240
|
+
"name, playback state (playing/paused/loading/stopped), title, " +
|
|
241
|
+
"artist, album, and the current seek position when available. " +
|
|
242
|
+
"`title`/`artist`/`album` are undefined when nothing is playing. " +
|
|
243
|
+
"Call this before pause/skip/volume changes when the user hasn't " +
|
|
244
|
+
"named a zone — it confirms where to act and what the state is. " +
|
|
245
|
+
"zoneId is optional: omit to use ROON_DEFAULT_ZONE, or fall back " +
|
|
246
|
+
"to the only zone / an \"Office\" zone / the currently-playing zone; " +
|
|
247
|
+
"if it still can't decide it returns ZONE_AMBIGUOUS so the agent " +
|
|
248
|
+
"can ask the user or call list_zones.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
zoneId: z
|
|
251
|
+
.string()
|
|
252
|
+
.min(1)
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("Target zone id or output id from list_zones (e.g. an id, or a " +
|
|
255
|
+
"name substring like 'Office'). Omit to use ROON_DEFAULT_ZONE " +
|
|
256
|
+
"or fall back automatically."),
|
|
257
|
+
},
|
|
258
|
+
}, async (args) => {
|
|
259
|
+
try {
|
|
260
|
+
const info = await this.transport.getNowPlaying(args.zoneId);
|
|
261
|
+
return structured(info);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
return toToolError(err);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
this.server.registerTool("control_playback", {
|
|
268
|
+
title: "Run a Roon transport verb (pause/resume/next/previous/stop)",
|
|
269
|
+
description: "Use this for the common transport verbs — pause, resume, skip, " +
|
|
270
|
+
"next track, previous track, stop (e.g. \"pause\", \"pause the " +
|
|
271
|
+
"kitchen\", \"skip\", \"next track\", \"next song\", \"play the " +
|
|
272
|
+
"next song\", \"resume\", \"resume in the office\", \"stop\", " +
|
|
273
|
+
"\"stop the music\"). Runs one transport verb against the resolved " +
|
|
274
|
+
"zone. For \"louder\"/\"softer\" without a number, call now_playing " +
|
|
275
|
+
"first to read the current state, then set_volume with a target " +
|
|
276
|
+
"percent — this tool is verbs only. zoneId is optional and resolves " +
|
|
277
|
+
"like now_playing. Returns the resolved zoneId, the action taken, " +
|
|
278
|
+
"and the resulting playback state.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
zoneId: z
|
|
281
|
+
.string()
|
|
282
|
+
.min(1)
|
|
283
|
+
.optional()
|
|
284
|
+
.describe("Target zone id or output id from list_zones. Omit to use " +
|
|
285
|
+
"ROON_DEFAULT_ZONE or fall back automatically (see now_playing)."),
|
|
286
|
+
action: z
|
|
287
|
+
.enum(["pause", "resume", "next", "previous", "stop"])
|
|
288
|
+
.describe("Transport verb to run: 'pause' to stop playback, 'resume' to " +
|
|
289
|
+
"restart it, 'next' to skip to the next track, 'previous' to " +
|
|
290
|
+
"go back, 'stop' to release the audio device."),
|
|
291
|
+
},
|
|
292
|
+
}, async (args) => {
|
|
293
|
+
try {
|
|
294
|
+
const result = await this.transport.control(args.zoneId, args.action);
|
|
295
|
+
return structured(result);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
return toToolError(err);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
this.server.registerTool("set_volume", {
|
|
302
|
+
title: "Set a Roon zone's volume to a target percent",
|
|
303
|
+
description: "Use this when the user wants to change how loud a zone is — turn " +
|
|
304
|
+
"it up, turn it down, set the volume, or pick a level for a " +
|
|
305
|
+
"specific room (e.g. \"turn it up to 60\", \"volume to 30 in the " +
|
|
306
|
+
"office\", \"set the kitchen to 50\", \"louder\" — see note). Sets " +
|
|
307
|
+
"the zone's volume to `level` percent (0 = silent, 100 = max). The " +
|
|
308
|
+
"server rescales to each output's native range, so a single value " +
|
|
309
|
+
"works across mixed devices in a grouped zone. Incremental " +
|
|
310
|
+
"outputs (IR blasters with no numeric range) are reported as " +
|
|
311
|
+
"skipped rather than guessed at. zoneId is optional and resolves " +
|
|
312
|
+
"like now_playing. NOTE on relative changes (\"louder\" / \"softer\" " +
|
|
313
|
+
"without a number): this tool is absolute — read the current " +
|
|
314
|
+
"state with now_playing isn't enough on its own (volume isn't " +
|
|
315
|
+
"exposed there), so for relative changes, ask the user for a " +
|
|
316
|
+
"target percent or apply a reasonable default delta.",
|
|
317
|
+
inputSchema: {
|
|
318
|
+
zoneId: z
|
|
319
|
+
.string()
|
|
320
|
+
.min(1)
|
|
321
|
+
.optional()
|
|
322
|
+
.describe("Target zone id or output id from list_zones. Omit to use " +
|
|
323
|
+
"ROON_DEFAULT_ZONE or fall back automatically."),
|
|
324
|
+
level: z
|
|
325
|
+
.number()
|
|
326
|
+
.min(0)
|
|
327
|
+
.max(100)
|
|
328
|
+
.describe("Target volume in percent (0 = silent, 100 = max). Mapped to " +
|
|
329
|
+
"each output's native range."),
|
|
330
|
+
},
|
|
331
|
+
}, async (args) => {
|
|
332
|
+
try {
|
|
333
|
+
const result = await this.transport.setVolume(args.zoneId, args.level);
|
|
334
|
+
return structured(result);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
return toToolError(err);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
this.server.registerTool("mute", {
|
|
341
|
+
title: "Mute or unmute a Roon zone",
|
|
342
|
+
description: "Use this when the user wants to mute or unmute a zone (e.g. " +
|
|
343
|
+
"\"mute\", \"mute the kitchen\", \"unmute\", \"silence the " +
|
|
344
|
+
"office\", \"stop the noise\"). Mutes (or unmutes) every output " +
|
|
345
|
+
"in the resolved zone. zoneId is optional and resolves like " +
|
|
346
|
+
"now_playing. `muted: true` mutes, `muted: false` unmutes.",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
zoneId: z
|
|
349
|
+
.string()
|
|
350
|
+
.min(1)
|
|
351
|
+
.optional()
|
|
352
|
+
.describe("Target zone id or output id from list_zones. Omit to use " +
|
|
353
|
+
"ROON_DEFAULT_ZONE or fall back automatically."),
|
|
354
|
+
muted: z
|
|
355
|
+
.boolean()
|
|
356
|
+
.describe("`true` to mute, `false` to unmute."),
|
|
357
|
+
},
|
|
358
|
+
}, async (args) => {
|
|
359
|
+
try {
|
|
360
|
+
const result = await this.transport.mute(args.zoneId, args.muted);
|
|
361
|
+
return structured(result);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
return toToolError(err);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
191
367
|
}
|
|
192
368
|
async start() {
|
|
193
369
|
this.roon.start();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RoonMcpServer.js","sourceRoot":"","sources":["../src/RoonMcpServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"RoonMcpServer.js","sourceRoot":"","sources":["../src/RoonMcpServer.ts"],"names":[],"mappings":"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"}
|
package/dist/SearchService.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export declare class SearchService {
|
|
|
21
21
|
* opt-in cost we accept for the streaming path.
|
|
22
22
|
*/
|
|
23
23
|
private collectStreamingGenreTracks;
|
|
24
|
+
/**
|
|
25
|
+
* Artist discovery beyond the library. The library album search that
|
|
26
|
+
* collectStreamingGenreTracks leans on returns nothing for an artist that
|
|
27
|
+
* has been fully removed from the library, so we go one level shallower: a
|
|
28
|
+
* track search, filtered to entries whose subtitle (the track's artist in
|
|
29
|
+
* Roon's flat search) matches the queried artist. On a TIDAL Core this
|
|
30
|
+
* surfaces the artist's streaming catalog as ready-to-play tracks. The
|
|
31
|
+
* result is best-effort: a small fraction may be features or compilations
|
|
32
|
+
* — the agent's per-artist cap / dedupe (see README "Curation is
|
|
33
|
+
* agent-side") handles that.
|
|
34
|
+
*/
|
|
35
|
+
private collectStreamingArtistTracks;
|
|
24
36
|
private performSearch;
|
|
25
37
|
/** Indices (into the top-level group list) to scan for the requested type. */
|
|
26
38
|
private selectGroupIndices;
|
package/dist/SearchService.js
CHANGED
|
@@ -46,7 +46,24 @@ export class SearchService {
|
|
|
46
46
|
}
|
|
47
47
|
// Search builds its own item keys inside performSearch, so a stale-session
|
|
48
48
|
// failure is recoverable with one reset-and-replay (see runExclusiveWithRetry).
|
|
49
|
-
|
|
49
|
+
const library = await this.browse.runExclusiveWithRetry(() => this.performSearch(input, limit));
|
|
50
|
+
// Artist search is library-only: when the artist has no library content
|
|
51
|
+
// (subtitle "0 Albums") or isn't in the library at all, optionally append a
|
|
52
|
+
// streaming track mix so the user request "play/queue <artist>" doesn't
|
|
53
|
+
// dead-end at get_tracks_for. Mirrors the genre streaming path's shape:
|
|
54
|
+
// library results first, streaming tracks appended with sourceGroup
|
|
55
|
+
// "Streaming". See collectStreamingArtistTracks.
|
|
56
|
+
if (input.type === "artist" && input.includeStreaming) {
|
|
57
|
+
const streaming = await this.collectStreamingArtistTracks(input.query, limit);
|
|
58
|
+
if (streaming.length > 0) {
|
|
59
|
+
const candidates = [...library.candidates, ...streaming];
|
|
60
|
+
const message = library.message
|
|
61
|
+
? `${library.message} Also showing ${streaming.length} streaming track(s).`
|
|
62
|
+
: `No library artist matched "${input.query}"; showing streaming tracks.`;
|
|
63
|
+
return { query: input.query, candidates, broadened: library.broadened, message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return library;
|
|
50
67
|
}
|
|
51
68
|
async searchGenres(query, limit, includeStreaming) {
|
|
52
69
|
const libraryGenres = await this.genres.searchGenres(query, limit);
|
|
@@ -107,6 +124,42 @@ export class SearchService {
|
|
|
107
124
|
}
|
|
108
125
|
return out;
|
|
109
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Artist discovery beyond the library. The library album search that
|
|
129
|
+
* collectStreamingGenreTracks leans on returns nothing for an artist that
|
|
130
|
+
* has been fully removed from the library, so we go one level shallower: a
|
|
131
|
+
* track search, filtered to entries whose subtitle (the track's artist in
|
|
132
|
+
* Roon's flat search) matches the queried artist. On a TIDAL Core this
|
|
133
|
+
* surfaces the artist's streaming catalog as ready-to-play tracks. The
|
|
134
|
+
* result is best-effort: a small fraction may be features or compilations
|
|
135
|
+
* — the agent's per-artist cap / dedupe (see README "Curation is
|
|
136
|
+
* agent-side") handles that.
|
|
137
|
+
*/
|
|
138
|
+
async collectStreamingArtistTracks(query, limit) {
|
|
139
|
+
const trackSearch = await this.browse.runExclusiveWithRetry(() => this.performSearch({ query, type: "track" }, limit));
|
|
140
|
+
const tracks = trackSearch.candidates.filter((c) => c.type === "track");
|
|
141
|
+
if (tracks.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const q = normalize(query);
|
|
144
|
+
const matched = tracks.filter((t) => {
|
|
145
|
+
const artist = normalize(t.subtitle ?? "");
|
|
146
|
+
// Substring is enough: Roon track subtitles are the artist (sometimes
|
|
147
|
+
// "Artist / Album"); a fuller match doesn't help here. We fall back to
|
|
148
|
+
// the track's own title as a last resort so a query that exactly
|
|
149
|
+
// matches a single-song single (e.g. a self-titled track) still
|
|
150
|
+
// surfaces something.
|
|
151
|
+
return artist === q || artist.includes(q) || normalize(t.title) === q;
|
|
152
|
+
});
|
|
153
|
+
return matched.slice(0, limit).map((t) => ({
|
|
154
|
+
itemKey: t.itemKey,
|
|
155
|
+
title: t.title,
|
|
156
|
+
subtitle: t.subtitle,
|
|
157
|
+
type: "track",
|
|
158
|
+
score: t.score,
|
|
159
|
+
available: t.available,
|
|
160
|
+
sourceGroup: "Streaming",
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
110
163
|
async performSearch(input, limit) {
|
|
111
164
|
// Roon only registers the search when `input` and `pop_all` ride in the
|
|
112
165
|
// same browse call; submitting `input` after a separate reset yields a
|
|
@@ -148,6 +201,15 @@ export class SearchService {
|
|
|
148
201
|
else if (ranked.length === 0) {
|
|
149
202
|
message = "No results.";
|
|
150
203
|
}
|
|
204
|
+
// When an artist search surfaces a candidate with no library content,
|
|
205
|
+
// surface that up front so the agent doesn't have to discover the dead
|
|
206
|
+
// end at get_tracks_for time. The streaming-artist fallback in
|
|
207
|
+
// searchMusic picks this up.
|
|
208
|
+
if (input.type === "artist" && /^\s*0\s+albums?\s*$/i.test(ranked[0]?.subtitle ?? "")) {
|
|
209
|
+
const name = ranked[0].title;
|
|
210
|
+
const hint = `Artist "${name}" has no library albums; pass includeStreaming:true to sample streaming tracks.`;
|
|
211
|
+
message = message ? `${message} ${hint}` : hint;
|
|
212
|
+
}
|
|
151
213
|
return { query: input.query, candidates: ranked, broadened, message };
|
|
152
214
|
}
|
|
153
215
|
/** Indices (into the top-level group list) to scan for the requested type. */
|