@azurestacknerd/roon-mcp 1.0.1

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 ADDED
@@ -0,0 +1,180 @@
1
+ # Roon MCP Server
2
+
3
+ An MCP (Model Context Protocol) server that lets Claude Desktop control [Roon](https://roon.app/) music playback. Search your library, play music, control volume, and manage zones — all through natural language.
4
+
5
+ ## Features
6
+
7
+ - **20 MCP tools** for full Roon control
8
+ - Search and play artists, albums, tracks, and playlists
9
+ - Playback controls: play, pause, stop, skip, seek, shuffle, loop
10
+ - Volume control with mute support
11
+ - Zone management and now-playing info
12
+ - Queue management (add tracks, albums, playlists)
13
+ - Direct WebSocket connection to Roon Core (no discovery needed)
14
+ - Auto-reconnect on connection loss
15
+ - Persistent pairing (only authorize once in Roon)
16
+
17
+ ## Prerequisites
18
+
19
+ - [Node.js](https://nodejs.org/) 18 or later
20
+ - A [Roon](https://roon.app/) Core running on your network
21
+ - [Claude Desktop](https://claude.ai/download)
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ git clone https://github.com/AzureStackNerd/roon-mcp.git
27
+ cd roon-mcp
28
+ npm install
29
+ npm run build
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Add the server to your Claude Desktop configuration file:
35
+
36
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
37
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "roon": {
43
+ "command": "node",
44
+ "args": ["C:\\path\\to\\roon-mcp\\build\\index.js"],
45
+ "env": {
46
+ "ROON_HOST": "192.168.1.100",
47
+ "ROON_PORT": "9330"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### Environment Variables
55
+
56
+ | Variable | Default | Description |
57
+ | ----------- | --------------- | -------------------------------- |
58
+ | `ROON_HOST` | `192.168.1.100` | IP address of your Roon Core |
59
+ | `ROON_PORT` | `9100` | WebSocket port of your Roon Core |
60
+
61
+ > **Finding your Roon Core port**: In Roon, go to **Settings > General** and look for the HTTP port displayed under your Core name. The default is 9100, but it may differ.
62
+
63
+ ## First-Time Setup
64
+
65
+ 1. Start Claude Desktop with the MCP server configured
66
+ 2. In Roon, go to **Settings > Extensions**
67
+ 3. Find **Roon MCP for Claude** and click **Enable**
68
+ 4. The extension will remember its authorization for future restarts
69
+
70
+ ## Available Tools
71
+
72
+ ### Zone & Status
73
+
74
+ | Tool | Parameters | Description |
75
+ | ------------- | ---------- | -------------------------------------------------------- |
76
+ | `list_zones` | — | List all zones with current playback status |
77
+ | `now_playing` | `zone?` | Get current track info for a zone (or all playing zones) |
78
+ | `get_queue` | `zone` | Get the play queue for a zone |
79
+
80
+ ### Playback Controls
81
+
82
+ | Tool | Parameters | Description |
83
+ | ---------------- | ------------------------------ | ------------------------------------------------------ |
84
+ | `play` | `zone` | Start playback |
85
+ | `pause` | `zone` | Pause playback |
86
+ | `play_pause` | `zone` | Toggle play/pause |
87
+ | `stop` | `zone` | Stop playback and release audio device |
88
+ | `next_track` | `zone` | Skip to next track |
89
+ | `previous_track` | `zone` | Go to previous track |
90
+ | `seek` | `zone`, `seconds`, `relative?` | Seek to position (absolute or relative) |
91
+ | `shuffle` | `zone`, `enabled` | Enable or disable shuffle |
92
+ | `loop` | `zone`, `mode` | Set loop mode (`loop`, `loop_one`, `disabled`, `next`) |
93
+
94
+ ### Volume
95
+
96
+ | Tool | Parameters | Description |
97
+ | --------------- | ----------------------- | ------------------------------------------------------- |
98
+ | `change_volume` | `zone`, `value`, `how?` | Change volume (`absolute`, `relative`, `relative_step`) |
99
+ | `mute` | `zone`, `mute` | Mute or unmute |
100
+ | `get_volume` | `zone` | Get current volume level and mute status |
101
+
102
+ ### Search & Play
103
+
104
+ | Tool | Parameters | Description |
105
+ | --------------- | ---------------------------- | --------------------------------------------------------------- |
106
+ | `search` | `query`, `zone?` | Search the library (returns artists, albums, tracks, playlists) |
107
+ | `play_artist` | `artist`, `zone` | Search and play an artist |
108
+ | `play_album` | `album`, `zone` | Search and play an album |
109
+ | `play_playlist` | `playlist`, `zone` | Search and play a playlist |
110
+ | `play_track` | `track`, `zone` | Search and play a specific track |
111
+ | `add_to_queue` | `query`, `zone`, `category?` | Search and add to the queue |
112
+
113
+ ## Usage Examples
114
+
115
+ Once configured, you can ask Claude things like:
116
+
117
+ - _"What zones are available?"_
118
+ - _"What's playing right now?"_
119
+ - _"Play Blue by Joni Mitchell in the living room"_
120
+ - _"Play the album Bad by Michael Jackson in Bedroom"_
121
+ - _"Skip to the next track"_
122
+ - _"Turn the volume down a bit in the kitchen"_
123
+ - _"Add Bohemian Rhapsody to the queue"_
124
+ - _"Enable shuffle mode"_
125
+ - _"Search for Miles Davis"_
126
+
127
+ ## Architecture
128
+
129
+ ```text
130
+ src/
131
+ index.ts # Entry point, MCP server setup
132
+ roon-connection.ts # Roon connection management, zone cache
133
+ tools/
134
+ zone.ts # list_zones, now_playing, get_queue
135
+ playback.ts # play, pause, stop, seek, shuffle, loop
136
+ volume.ts # change_volume, mute, get_volume
137
+ browse.ts # search, play_artist/album/playlist/track, add_to_queue
138
+ types/
139
+ node-roon-api.d.ts # Type declarations for node-roon-api
140
+ node-roon-api-transport.d.ts # Type declarations for node-roon-api-transport
141
+ node-roon-api-browse.d.ts # Type declarations for node-roon-api-browse
142
+ node-roon-api-status.d.ts # Type declarations for node-roon-api-status
143
+ ```
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ # Build
149
+ npm run build
150
+
151
+ # Run directly
152
+ npm start
153
+
154
+ # Test interactively with MCP Inspector
155
+ npx @modelcontextprotocol/inspector node build/index.js
156
+ ```
157
+
158
+ ## Troubleshooting
159
+
160
+ ### "Not connected to Roon"
161
+
162
+ - Verify `ROON_HOST` and `ROON_PORT` point to your Roon Core
163
+ - Check that the extension is enabled in Roon > Settings > Extensions
164
+
165
+ ### Extension requires re-enabling after every restart
166
+
167
+ - The pairing token is stored in `roon-mcp/config.json`. After the first authorization it should persist across restarts. If you still need to re-enable, check that the `config.json` file exists in the project root after the first pairing.
168
+
169
+ ### Search finds wrong version of a track/album
170
+
171
+ - Include the artist name in your query for better matching, e.g. _"Dirty Diana Michael Jackson"_ instead of just _"Dirty Diana"_
172
+
173
+ ### Playback doesn't start after search
174
+
175
+ - Check the Roon Core logs for errors
176
+ - Ensure the zone is available and not in use by another controller
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { roonConnection } from "./roon-connection.js";
5
+ import { registerZoneTools } from "./tools/zone.js";
6
+ import { registerPlaybackTools } from "./tools/playback.js";
7
+ import { registerVolumeTools } from "./tools/volume.js";
8
+ import { registerBrowseTools } from "./tools/browse.js";
9
+ // Prevent process crashes from unhandled errors in node-roon-api's WebSocket.
10
+ // The ws library emits EventEmitter 'error' events, but node-roon-api only sets
11
+ // DOM-style .onerror handlers, leaving EventEmitter errors unhandled.
12
+ process.on("uncaughtException", (error) => {
13
+ console.error("[roon-mcp] Uncaught exception (kept alive):", error.message);
14
+ });
15
+ process.on("unhandledRejection", (reason) => {
16
+ console.error("[roon-mcp] Unhandled rejection (kept alive):", reason);
17
+ });
18
+ const server = new McpServer({
19
+ name: "roon-mcp",
20
+ version: "1.0.1",
21
+ });
22
+ registerZoneTools(server);
23
+ registerPlaybackTools(server);
24
+ registerVolumeTools(server);
25
+ registerBrowseTools(server);
26
+ async function main() {
27
+ // Start Roon connection (runs in background with auto-reconnect)
28
+ roonConnection.connect();
29
+ // Start MCP server on stdio
30
+ const transport = new StdioServerTransport();
31
+ await server.connect(transport);
32
+ console.error("[roon-mcp] MCP server running on stdio");
33
+ }
34
+ main().catch((error) => {
35
+ console.error("[roon-mcp] Fatal error:", error);
36
+ process.exit(1);
37
+ });
@@ -0,0 +1,21 @@
1
+ import RoonApiTransport from "node-roon-api-transport";
2
+ import RoonApiBrowse from "node-roon-api-browse";
3
+ import type { Zone } from "node-roon-api-transport";
4
+ export declare class RoonConnection {
5
+ private roon;
6
+ private status;
7
+ private core;
8
+ private zones;
9
+ constructor();
10
+ connect(): void;
11
+ private subscribeZones;
12
+ private getTransportUnsafe;
13
+ getTransport(): RoonApiTransport;
14
+ private getBrowseUnsafe;
15
+ getBrowse(): RoonApiBrowse;
16
+ isConnected(): boolean;
17
+ getZones(): Zone[];
18
+ findZone(nameOrId: string): Zone | null;
19
+ findZoneOrThrow(nameOrId: string): Zone;
20
+ }
21
+ export declare const roonConnection: RoonConnection;
@@ -0,0 +1,202 @@
1
+ import RoonApi from "node-roon-api";
2
+ import RoonApiTransport from "node-roon-api-transport";
3
+ import RoonApiBrowse from "node-roon-api-browse";
4
+ import RoonApiStatus from "node-roon-api-status";
5
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+ const ROON_HOST = process.env.ROON_HOST || "192.168.1.100";
9
+ const ROON_PORT = (() => {
10
+ const port = parseInt(process.env.ROON_PORT || "9100", 10);
11
+ if (isNaN(port) || port < 1 || port > 65535) {
12
+ console.error(`[roon-mcp] Invalid ROON_PORT: ${process.env.ROON_PORT}. Using default 9100.`);
13
+ return 9100;
14
+ }
15
+ return port;
16
+ })();
17
+ // Fixed path for config.json so pairing token persists across restarts
18
+ // regardless of CWD when Claude Desktop launches the process
19
+ const CONFIG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
20
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
21
+ function loadPersistedState() {
22
+ try {
23
+ const content = readFileSync(CONFIG_PATH, { encoding: "utf8" });
24
+ return JSON.parse(content)?.roonstate || {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ function savePersistedState(state) {
31
+ try {
32
+ let config = {};
33
+ try {
34
+ const content = readFileSync(CONFIG_PATH, { encoding: "utf8" });
35
+ config = JSON.parse(content) || {};
36
+ }
37
+ catch {
38
+ // file doesn't exist yet
39
+ }
40
+ config.roonstate = state;
41
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
42
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, " "));
43
+ }
44
+ catch (e) {
45
+ console.error("[roon-mcp] Failed to save config:", e);
46
+ }
47
+ }
48
+ export class RoonConnection {
49
+ roon;
50
+ status;
51
+ core = null;
52
+ zones = new Map();
53
+ constructor() {
54
+ this.roon = new RoonApi({
55
+ extension_id: "com.roon-mcp.claude",
56
+ display_name: "Roon MCP for Claude",
57
+ display_version: "1.0.1",
58
+ publisher: "roon-mcp",
59
+ email: "noreply@roon-mcp.local",
60
+ log_level: "none",
61
+ get_persisted_state: loadPersistedState,
62
+ set_persisted_state: savePersistedState,
63
+ core_paired: (core) => {
64
+ console.error(`[roon-mcp] Paired with core: ${core.display_name}`);
65
+ this.core = core;
66
+ this.subscribeZones();
67
+ },
68
+ core_unpaired: (core) => {
69
+ console.error(`[roon-mcp] Unpaired from core: ${core.display_name}`);
70
+ this.core = null;
71
+ this.zones.clear();
72
+ },
73
+ });
74
+ this.status = new RoonApiStatus(this.roon);
75
+ this.roon.init_services({
76
+ required_services: [RoonApiTransport, RoonApiBrowse],
77
+ provided_services: [this.status],
78
+ });
79
+ }
80
+ connect() {
81
+ console.error(`[roon-mcp] Connecting to Roon Core at ${ROON_HOST}:${ROON_PORT}...`);
82
+ this.status.set_status("Connecting...", false);
83
+ const doConnect = () => {
84
+ this.roon.ws_connect({
85
+ host: ROON_HOST,
86
+ port: ROON_PORT,
87
+ onclose: () => {
88
+ console.error("[roon-mcp] Connection lost, reconnecting in 3s...");
89
+ this.core = null;
90
+ this.zones.clear();
91
+ setTimeout(doConnect, 3000);
92
+ },
93
+ onerror: () => {
94
+ console.error("[roon-mcp] WebSocket error");
95
+ },
96
+ });
97
+ };
98
+ doConnect();
99
+ }
100
+ subscribeZones() {
101
+ const transport = this.getTransportUnsafe();
102
+ if (!transport)
103
+ return;
104
+ transport.subscribe_zones((response, msg) => {
105
+ if (response === "Subscribed" && msg.zones) {
106
+ this.zones.clear();
107
+ for (const zone of msg.zones) {
108
+ this.zones.set(zone.zone_id, zone);
109
+ }
110
+ console.error(`[roon-mcp] Subscribed to ${this.zones.size} zone(s)`);
111
+ this.status.set_status("Connected", false);
112
+ }
113
+ else if (response === "Changed") {
114
+ if (msg.zones_removed) {
115
+ for (const id of msg.zones_removed) {
116
+ this.zones.delete(id);
117
+ }
118
+ }
119
+ if (msg.zones_added) {
120
+ for (const zone of msg.zones_added) {
121
+ this.zones.set(zone.zone_id, zone);
122
+ }
123
+ }
124
+ if (msg.zones_changed) {
125
+ for (const zone of msg.zones_changed) {
126
+ this.zones.set(zone.zone_id, zone);
127
+ }
128
+ }
129
+ if (msg.zones_seek_changed) {
130
+ for (const update of msg.zones_seek_changed) {
131
+ const zone = this.zones.get(update.zone_id);
132
+ if (zone) {
133
+ if (zone.now_playing) {
134
+ zone.now_playing.seek_position = update.seek_position;
135
+ }
136
+ zone.queue_time_remaining = update.queue_time_remaining;
137
+ }
138
+ }
139
+ }
140
+ }
141
+ });
142
+ }
143
+ getTransportUnsafe() {
144
+ if (!this.core)
145
+ return null;
146
+ return this.core.services.RoonApiTransport ?? null;
147
+ }
148
+ getTransport() {
149
+ const transport = this.getTransportUnsafe();
150
+ if (!transport) {
151
+ throw new Error("Not connected to Roon. Please approve the extension in Roon Settings > Extensions.");
152
+ }
153
+ return transport;
154
+ }
155
+ getBrowseUnsafe() {
156
+ if (!this.core)
157
+ return null;
158
+ return this.core.services.RoonApiBrowse ?? null;
159
+ }
160
+ getBrowse() {
161
+ const browse = this.getBrowseUnsafe();
162
+ if (!browse) {
163
+ throw new Error("Not connected to Roon. Please approve the extension in Roon Settings > Extensions.");
164
+ }
165
+ return browse;
166
+ }
167
+ isConnected() {
168
+ return this.core !== null;
169
+ }
170
+ getZones() {
171
+ return Array.from(this.zones.values());
172
+ }
173
+ findZone(nameOrId) {
174
+ // Try exact zone_id match first
175
+ const byId = this.zones.get(nameOrId);
176
+ if (byId)
177
+ return byId;
178
+ // Try case-insensitive display_name match
179
+ const lower = nameOrId.toLowerCase();
180
+ for (const zone of this.zones.values()) {
181
+ if (zone.display_name.toLowerCase() === lower)
182
+ return zone;
183
+ }
184
+ // Try partial match
185
+ for (const zone of this.zones.values()) {
186
+ if (zone.display_name.toLowerCase().includes(lower))
187
+ return zone;
188
+ }
189
+ return null;
190
+ }
191
+ findZoneOrThrow(nameOrId) {
192
+ const zone = this.findZone(nameOrId);
193
+ if (!zone) {
194
+ const available = this.getZones()
195
+ .map((z) => z.display_name)
196
+ .join(", ");
197
+ throw new Error(`Zone '${nameOrId}' not found. Available zones: ${available || "(none - is Roon paired?)"}`);
198
+ }
199
+ return zone;
200
+ }
201
+ }
202
+ export const roonConnection = new RoonConnection();
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBrowseTools(server: McpServer): void;