@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.
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+ import { roonConnection } from "../roon-connection.js";
3
+ function promisifyResult(fn) {
4
+ return new Promise((resolve) => fn((error) => resolve(error)));
5
+ }
6
+ export function registerVolumeTools(server) {
7
+ server.tool("change_volume", "Change the volume of a Roon zone. Each output in a zone may have independent volume controls.", {
8
+ zone: z.string().describe("Zone name or ID"),
9
+ value: z.number().describe("Volume value (absolute level, or relative adjustment)"),
10
+ how: z
11
+ .enum(["absolute", "relative", "relative_step"])
12
+ .default("absolute")
13
+ .describe("How to interpret the value: 'absolute' sets exact level, 'relative' adds/subtracts, 'relative_step' adjusts by step increments"),
14
+ }, async ({ zone, value, how }) => {
15
+ try {
16
+ const transport = roonConnection.getTransport();
17
+ const foundZone = roonConnection.findZoneOrThrow(zone);
18
+ const results = [];
19
+ for (const output of foundZone.outputs) {
20
+ if (!output.volume)
21
+ continue;
22
+ const error = await promisifyResult((cb) => transport.change_volume(output, how, value, cb));
23
+ if (error) {
24
+ results.push(`${output.display_name}: Error - ${error}`);
25
+ }
26
+ else {
27
+ results.push(`${output.display_name}: Volume ${how === "absolute" ? "set to" : "adjusted by"} ${value}`);
28
+ }
29
+ }
30
+ if (results.length === 0) {
31
+ return {
32
+ content: [{ type: "text", text: `No volume-controllable outputs in zone '${foundZone.display_name}'.` }],
33
+ };
34
+ }
35
+ return { content: [{ type: "text", text: results.join("\n") }] };
36
+ }
37
+ catch (error) {
38
+ return {
39
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ });
44
+ server.tool("mute", "Mute or unmute a Roon zone", {
45
+ zone: z.string().describe("Zone name or ID"),
46
+ mute: z.boolean().describe("true to mute, false to unmute"),
47
+ }, async ({ zone, mute }) => {
48
+ try {
49
+ const transport = roonConnection.getTransport();
50
+ const foundZone = roonConnection.findZoneOrThrow(zone);
51
+ const how = mute ? "mute" : "unmute";
52
+ const results = [];
53
+ for (const output of foundZone.outputs) {
54
+ if (!output.volume)
55
+ continue;
56
+ const error = await promisifyResult((cb) => transport.mute(output, how, cb));
57
+ if (error) {
58
+ results.push(`${output.display_name}: Error - ${error}`);
59
+ }
60
+ else {
61
+ results.push(`${output.display_name}: ${mute ? "Muted" : "Unmuted"}`);
62
+ }
63
+ }
64
+ if (results.length === 0) {
65
+ return {
66
+ content: [{ type: "text", text: `No volume-controllable outputs in zone '${foundZone.display_name}'.` }],
67
+ };
68
+ }
69
+ return { content: [{ type: "text", text: results.join("\n") }] };
70
+ }
71
+ catch (error) {
72
+ return {
73
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
74
+ isError: true,
75
+ };
76
+ }
77
+ });
78
+ server.tool("get_volume", "Get the current volume level and mute status for a Roon zone", {
79
+ zone: z.string().describe("Zone name or ID"),
80
+ }, async ({ zone }) => {
81
+ try {
82
+ roonConnection.getTransport();
83
+ const foundZone = roonConnection.findZoneOrThrow(zone);
84
+ const lines = [`Zone: ${foundZone.display_name}`];
85
+ for (const output of foundZone.outputs) {
86
+ if (output.volume) {
87
+ const vol = output.volume;
88
+ const parts = [`${output.display_name}:`];
89
+ if (vol.type === "incremental") {
90
+ parts.push("Incremental volume (no level readout)");
91
+ }
92
+ else {
93
+ parts.push(`${vol.value}${vol.type === "db" ? " dB" : ""}`);
94
+ if (vol.min != null && vol.max != null) {
95
+ parts.push(`(range: ${vol.min} to ${vol.max})`);
96
+ }
97
+ }
98
+ if (vol.is_muted)
99
+ parts.push("[MUTED]");
100
+ lines.push(` ${parts.join(" ")}`);
101
+ }
102
+ else {
103
+ lines.push(` ${output.display_name}: No volume control`);
104
+ }
105
+ }
106
+ return { content: [{ type: "text", text: lines.join("\n") }] };
107
+ }
108
+ catch (error) {
109
+ return {
110
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
111
+ isError: true,
112
+ };
113
+ }
114
+ });
115
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerZoneTools(server: McpServer): void;
@@ -0,0 +1,153 @@
1
+ import { z } from "zod";
2
+ import { roonConnection } from "../roon-connection.js";
3
+ export function registerZoneTools(server) {
4
+ server.tool("list_zones", "List all available Roon zones with their current playback status", {}, async () => {
5
+ try {
6
+ // Ensure we're connected (will throw if not)
7
+ roonConnection.getTransport();
8
+ const zones = roonConnection.getZones();
9
+ if (zones.length === 0) {
10
+ return {
11
+ content: [{ type: "text", text: "No zones found. Is Roon running?" }],
12
+ };
13
+ }
14
+ const zoneList = zones.map((zone) => {
15
+ const np = zone.now_playing;
16
+ const nowPlaying = np
17
+ ? `${np.three_line.line1}${np.three_line.line2 ? ` - ${np.three_line.line2}` : ""}${np.three_line.line3 ? ` (${np.three_line.line3})` : ""}`
18
+ : "Nothing playing";
19
+ return [
20
+ `Zone: ${zone.display_name}`,
21
+ ` State: ${zone.state}`,
22
+ ` Now Playing: ${nowPlaying}`,
23
+ np?.seek_position != null && np?.length
24
+ ? ` Position: ${formatTime(np.seek_position)} / ${formatTime(np.length)}`
25
+ : null,
26
+ zone.queue_items_remaining
27
+ ? ` Queue: ${zone.queue_items_remaining} items remaining`
28
+ : null,
29
+ ]
30
+ .filter(Boolean)
31
+ .join("\n");
32
+ });
33
+ return {
34
+ content: [{ type: "text", text: zoneList.join("\n\n") }],
35
+ };
36
+ }
37
+ catch (error) {
38
+ return {
39
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ });
44
+ server.tool("now_playing", "Get detailed information about what is currently playing in a Roon zone", {
45
+ zone: z.string().optional().describe("Zone name or ID. If omitted, returns info for all playing zones"),
46
+ }, async ({ zone }) => {
47
+ try {
48
+ roonConnection.getTransport();
49
+ if (zone) {
50
+ const z = roonConnection.findZoneOrThrow(zone);
51
+ return {
52
+ content: [{ type: "text", text: formatNowPlaying(z) }],
53
+ };
54
+ }
55
+ // Return all zones that are playing
56
+ const zones = roonConnection.getZones();
57
+ const playing = zones.filter((z) => z.now_playing);
58
+ if (playing.length === 0) {
59
+ return {
60
+ content: [{ type: "text", text: "Nothing is currently playing in any zone." }],
61
+ };
62
+ }
63
+ const result = playing.map(formatNowPlaying).join("\n\n---\n\n");
64
+ return {
65
+ content: [{ type: "text", text: result }],
66
+ };
67
+ }
68
+ catch (error) {
69
+ return {
70
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
71
+ isError: true,
72
+ };
73
+ }
74
+ });
75
+ server.tool("get_queue", "Get the play queue for a Roon zone", {
76
+ zone: z.string().describe("Zone name or ID"),
77
+ }, async ({ zone }) => {
78
+ try {
79
+ const transport = roonConnection.getTransport();
80
+ const z = roonConnection.findZoneOrThrow(zone);
81
+ const items = await new Promise((resolve, reject) => {
82
+ const timeout = setTimeout(() => reject(new Error("Queue request timed out")), 5000);
83
+ const sub = transport.subscribe_queue(z, 100, (response, msg) => {
84
+ if (response === "Subscribed") {
85
+ clearTimeout(timeout);
86
+ resolve(msg.items || []);
87
+ // Unsubscribe after getting the initial data
88
+ try {
89
+ sub.unsubscribe();
90
+ }
91
+ catch { /* ignore */ }
92
+ }
93
+ });
94
+ });
95
+ if (items.length === 0) {
96
+ return {
97
+ content: [{ type: "text", text: `Queue for '${z.display_name}' is empty.` }],
98
+ };
99
+ }
100
+ const lines = [`Queue for '${z.display_name}' (${items.length} items):\n`];
101
+ for (let i = 0; i < items.length; i++) {
102
+ const item = items[i];
103
+ const duration = item.length ? ` [${formatTime(item.length)}]` : "";
104
+ const artist = item.two_line.line2 ? ` - ${item.two_line.line2}` : "";
105
+ lines.push(`${i + 1}. ${item.two_line.line1}${artist}${duration}`);
106
+ }
107
+ return {
108
+ content: [{ type: "text", text: lines.join("\n") }],
109
+ };
110
+ }
111
+ catch (error) {
112
+ return {
113
+ content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }],
114
+ isError: true,
115
+ };
116
+ }
117
+ });
118
+ }
119
+ function formatNowPlaying(zone) {
120
+ const np = zone.now_playing;
121
+ if (!np) {
122
+ return `${zone.display_name}: Nothing playing (${zone.state})`;
123
+ }
124
+ const lines = [
125
+ `Zone: ${zone.display_name}`,
126
+ `State: ${zone.state}`,
127
+ `Track: ${np.three_line.line1}`,
128
+ ];
129
+ if (np.three_line.line2)
130
+ lines.push(`Artist: ${np.three_line.line2}`);
131
+ if (np.three_line.line3)
132
+ lines.push(`Album: ${np.three_line.line3}`);
133
+ if (np.seek_position != null && np.length) {
134
+ lines.push(`Position: ${formatTime(np.seek_position)} / ${formatTime(np.length)}`);
135
+ }
136
+ if (zone.settings) {
137
+ const settings = [];
138
+ if (zone.settings.shuffle)
139
+ settings.push("Shuffle");
140
+ if (zone.settings.loop !== "disabled")
141
+ settings.push(`Loop: ${zone.settings.loop}`);
142
+ if (zone.settings.auto_radio)
143
+ settings.push("Radio");
144
+ if (settings.length > 0)
145
+ lines.push(`Settings: ${settings.join(", ")}`);
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+ function formatTime(seconds) {
150
+ const mins = Math.floor(seconds / 60);
151
+ const secs = Math.floor(seconds % 60);
152
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
153
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@azurestacknerd/roon-mcp",
3
+ "version": "1.0.1",
4
+ "description": "MCP server for controlling Roon music playback via Claude Desktop",
5
+ "mcpName": "io.github.azurestacknerd/roon-mcp",
6
+ "type": "module",
7
+ "main": "build/index.js",
8
+ "bin": {
9
+ "roon-mcp": "./build/index.js"
10
+ },
11
+ "keywords": ["mcp", "roon", "audio", "music", "model-context-protocol"],
12
+ "author": "AzureStackNerd",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/AzureStackNerd/roon-mcp.git"
17
+ },
18
+ "files": [
19
+ "build",
20
+ "scripts"
21
+ ],
22
+ "scripts": {
23
+ "postinstall": "node scripts/patch-roon-api.js",
24
+ "build": "tsc",
25
+ "start": "node build/index.js",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.12.0",
30
+ "node-roon-api": "github:roonlabs/node-roon-api",
31
+ "node-roon-api-browse": "github:roonlabs/node-roon-api-browse",
32
+ "node-roon-api-status": "github:roonlabs/node-roon-api-status",
33
+ "node-roon-api-transport": "github:roonlabs/node-roon-api-transport",
34
+ "zod": "^3.24.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "typescript": "^5.8.0"
39
+ }
40
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Postinstall patch for node-roon-api.
3
+ *
4
+ * The ws library is an EventEmitter that emits 'error' events, but
5
+ * node-roon-api only sets DOM-style .onerror handlers on the WebSocket.
6
+ * This leaves EventEmitter 'error' events unhandled, which crashes Node.js.
7
+ *
8
+ * This script adds a proper .on('error', ...) listener to transport-websocket.js.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const filePath = join(__dirname, "..", "node_modules", "node-roon-api", "transport-websocket.js");
17
+
18
+ const PATCH_MARKER = "// [roon-mcp-patch] EventEmitter error handler";
19
+
20
+ try {
21
+ let content = readFileSync(filePath, "utf8");
22
+
23
+ if (content.includes(PATCH_MARKER)) {
24
+ console.error("[patch] node-roon-api already patched, skipping.");
25
+ process.exit(0);
26
+ }
27
+
28
+ // Add .on('error', ...) right after the existing .on('pong', ...) line
29
+ const target = "this.ws.on('pong', () => this.is_alive = true);";
30
+ const replacement = `this.ws.on('pong', () => this.is_alive = true);
31
+ ${PATCH_MARKER}
32
+ this.ws.on('error', (err) => { if (this.onerror) this.onerror(err); });`;
33
+
34
+ if (!content.includes(target)) {
35
+ console.error("[patch] Could not find target line in transport-websocket.js. Skipping patch.");
36
+ process.exit(0);
37
+ }
38
+
39
+ content = content.replace(target, replacement);
40
+ writeFileSync(filePath, content, "utf8");
41
+ console.error("[patch] Patched node-roon-api transport-websocket.js: added EventEmitter error handler.");
42
+ } catch (e) {
43
+ console.error("[patch] Failed to patch node-roon-api:", e.message);
44
+ // Non-fatal: the process.on('uncaughtException') handler in index.ts is a fallback
45
+ }