Skip to main content
Glama
roon-mcp
by bestimmaa

roon-mcp

npm version

An MCP server to control the Roon music player, so an AI agent can find music and start playback. Built on the official Roon Extension API (node-roon-api + node-roon-api-transport / node-roon-api-browse).


"Play a female vocal trance mix on Roon (Zone: This Computer) — 2000s Dutch trance vibes, the kind of set you'd hear at Amnesia in its prime. Start with something like 4 Strings' Take Me Away."

Claude searches across your library and streaming, hand-picks the tracks, and queues them — all from a single natural-language prompt.


Requirements

  • Node.js 20+

  • A running Roon Core on the local network to pair with

  • git on the install host — the node-roon-api* dependencies are published on GitHub (not npm) and are fetched via git URLs during install

Related MCP server: lms-mcp

MCP Client Configuration

Add this to your MCP client config. npx fetches the package on first run:

{
  "mcpServers": {
    "roon": {
      "command": "npx",
      "args": ["-y", "roon-mcp"],
      "env": { "ROON_DEFAULT_ZONE": "Office" }
    }
  }
}

On first launch, open Roon → Settings → Extensions and enable Roon MCP to pair. Pairing status is logged to stderr; stdout is reserved for the MCP protocol. The pairing token is persisted to ~/.config/roon-mcp/config.json (override with ROON_MCP_CONFIG), so the extension stays authorized across restarts rather than appearing as a new "Discovered" entry each time.

Global install (optional)

npm install -g roon-mcp
{
  "mcpServers": {
    "roon": {
      "command": "roon-mcp",
      "env": { "ROON_DEFAULT_ZONE": "Office" }
    }
  }
}

Configuration

Env var

Purpose

ROON_DEFAULT_ZONE

Optional fallback target for play_now / enqueue_and_play when no zoneId is given — a zone/output id or a display-name substring. If unset, the server falls back to the only zone, an Office zone, or the currently-playing zone; if it still can't decide it returns ZONE_AMBIGUOUS so the agent can ask.

ROON_HOST

Optional direct-connection host for the Core's API, bypassing SOOD multicast discovery — for VLAN-segmented networks, VPNs, or containers where discovery doesn't survive. Unset (default) uses discovery as before.

ROON_PORT

WebSocket port of the Core's API when ROON_HOST is set. Defaults to 9330.

ROON_MCP_CONFIG

Optional path for the persisted pairing token. A value ending in .json is the file itself; anything else is a directory to hold config.json. Defaults to $XDG_CONFIG_HOME/roon-mcp/config.json (i.e. ~/.config/roon-mcp/config.json).

Tools

Tool

Purpose

list_zones()

List playable zones/outputs (id, name, state, output ids).

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 for type:"genre"/type:"artist" and includeStreaming.

get_tracks_for({ itemKey, limit? })

Expand an artist/album/genre/playlist candidate into concrete playable tracks.

play_now({ zoneId?, itemKey, shuffle?, addToQueue? })

Play or queue one search candidate. By default replaces the queue and starts immediately. Pass addToQueue: true to append to the existing queue instead. zoneId optional (defaults as above).

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.

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.

control_playback({ zoneId?, action })

Run a transport verb: pause / resume / next / previous / stop / playpause (toggle play/pause).

seek({ zoneId?, seconds, mode? })

Seek within the current track. mode:"absolute" (default) seeks to seconds (0 = start); "relative" moves by seconds (negative skips backward).

set_loop({ zoneId?, mode })

Set the loop/repeat mode: off / all / one.

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.

mute({ zoneId?, muted })

Mute (muted: true) or unmute (muted: false) every output in the zone.

pause_all()

Pause every zone on the Core in one call. Resume per zone with control_playback.

mute_all({ muted })

Mute (muted: true) or unmute (muted: false) every mutable output on the Core.

set_auto_radio({ zoneId?, enabled })

Turn Roon Radio on/off for a zone: enabled: true appends similar tracks once the queue ends, false stops playback at queue end.

library_export({ path, limit? })

Walk Library → Albums and write the album catalog to path as JSON (written atomically; parents created; an existing file is only replaced if it is an earlier snapshot). The albums go to the file — the result carries only counts and timings. Can take tens of seconds on a large library, and holds the browse session for the whole walk (search/play calls wait behind it).

Streaming search (genre and artist)

  • Genre search fuzzy-matches what you type — "psychedelic trance" will find the right genre even if the name isn't exact. By default results come from your library.

  • includeStreaming: true (on type:"genre" or type:"artist") extends the search to your streaming service, so you can play artists or genres that aren't in your local collection.

  • limit caps the combined result. Library results come first; streaming tracks fill the remaining slots. When library results alone would fill the limit, up to half of it is reserved for streaming (trimming the lowest-ranked library tail, noted in message) so an explicit includeStreaming always surfaces streaming content when it exists.

Notes

  • After starting playback, now_playing reflects the track that just started, not whatever was playing before.

  • Volume is set as a percentage (0–100) and works correctly across grouped zones.

Concurrent instances

Roon pairs only one running instance of an extension at a time. Since every roon-mcp process shares the same identity and persisted pairing token, starting a second instance (a second MCP client session, or an orphaned process from a closed terminal) no longer fights the first one for the pairing slot — instead, whichever instance starts first claims a lock file (instance.lock, next to config.json) and every other instance fails fast with a CORE_PAIRING_HELD error naming the pid that holds it, instead of flapping between paired/unpaired.

A lock whose owning process is no longer alive is treated as stale and taken over — both at startup and by an already-running blocked instance, which re-checks the lock on every call. So when the holder exits (cleanly or via kill -9), the next call to any other instance acquires the lock, starts discovery, and pairs; nothing needs restarting.

MCP clients don't always reap server processes when a session ends, so orphaned roon-mcp instances can pile up and hold the lock long after their terminal is gone. If you hit CORE_PAIRING_HELD unexpectedly, clear them out:

pkill -f roon-mcp

Blocked instances that are still running recover on their own next call, and the next instance you start acquires the lock normally.

Assumptions

  • Core language: English. Category/action label matching (Artists, Play Now, Top Tracks, …) assumes an English Core.

  • Sources: local library + your configured streaming service. The server goes through Roon's Browse API, which routes to whatever streaming service you have set up in Roon (Tidal, Qobuz, etc.). Developed and tested against Tidal — other services should work but are untested. Results differ on a local-only Core.

  • Queue: replace. enqueue_and_play starts a fresh queue rather than adding to whatever is already playing.

  • Curation is agent-side. Dedupe / cap-per-artist / ordering / trimming stay in the agent; the server has no curation logic.

There is no save_playlist tool: Roon exposes no official playlist-write service, so durable playlists are out of scope. Curated playback is delivered by enqueue_and_play (an ad-hoc, in-the-moment queue).

Logging

Every Roon API call (browse, load, get_zones, control, change_volume, mute, seek, change_settings, pause_all, mute_all, …) emits one structured line to stderr (stdout stays reserved for MCP JSON-RPC):

[roon-call] {"t":"2026-06-19T18:00:00.000Z","lvl":"info","op":"browse","ms":12,"params":{"hierarchy":"search","item_key":"…"},"result":{"action":"list","count":7}}

Failures log lvl:"error" with the mapped error code, and retries surface as repeated lines for the same op. Grep stderr for [roon-call] to trace a flow.

Development

npm install              # pulls node-roon-api packages from RoonLabs' GitHub
npm run build
npm test                 # builds, then runs node:test
npm run dev              # tsc --watch
npm run typecheck

Run the built server directly:

npm start

Integration smoke test

scripts/integration.mjs spawns the built server over stdio (like a real MCP client) and runs list → search → expand against your Core. Audible steps are opt-in so it never blasts music by accident:

npm run build
node scripts/integration.mjs                 # list_zones + search only (read-only)
ROON_PLAY=1 node scripts/integration.mjs     # also play_now a shuffled genre
ROON_ENQUEUE=1 node scripts/integration.mjs  # also enqueue a few curated tracks
# overrides: ROON_ZONE="Office"  ROON_QUERY="Tycho"

Enable Roon MCP under Roon → Settings → Extensions on the first run (the script retries list_zones for ~45s while it waits to pair).

Releasing

npm run release -- <patch|minor|major>

Requires a clean worktree on main and a matching ## [<version>] entry in CHANGELOG.md. The script runs the tests, bumps the version, tags the commit, and verifies the package with npm pack. It then prints the push and npm publish commands to run.

License

MIT

Available Tools

9 tools
control_playbackRun a Roon transport verb (pause/resume/next/previous/stop)A

Use this for the common transport verbs — pause, resume, skip, next track, previous track, stop (e.g. "pause", "pause the kitchen", "skip", "next track", "next song", "play the next song", "resume", "resume in the office", "stop", "stop the music"). Runs one transport verb against the resolved zone. For "louder"/"softer" without a number, call now_playing first to read the current state, then set_volume with a target percent — this tool is verbs only. zoneId is optional and resolves like now_playing. Returns the resolved zoneId, the action taken, and the resulting playback state.

Inputs: action (required), zoneId.

enqueue_and_playBuild and start a curated Roon queueA

Use this when the user wants a custom lineup — a mix of artists, a shuffled selection across albums, a hand-picked set of tracks, or any time "queue", "setlist", "mix of", or "play these in order" comes up (e.g. "queue up five Tycho tracks then some Boards of Canada", "shuffle 10 ambient tracks", "build a set: artist A, then B, then C"). Builds an ad-hoc queue from an ordered list of curated item keys and starts playback in the target zone. This replaces the zone's current queue: the first playable item starts immediately (Play Now), the rest are appended in order. Pass itemKeys from recent get_tracks_for / search_music results (use them promptly — they are session-scoped). zoneId is optional (omit to use the default zone; see play_now). Optionally shuffle. Returns a PlaybackResult with queued/skipped counts so you can backfill skipped items.

Inputs: itemKeys (required), zoneId, shuffle.

get_tracks_forExpand a Roon item into tracksA

Use this after search_music when the user wants a concrete list of songs — to preview tracks, build a queue, or pick one to start with (e.g. "what tracks are on this album?", "give me 5 tracks of Dark Ambient", "what's on this playlist?"). Expands an artist, album, genre, or playlist candidate into concrete playable tracks. Pass an itemKey from a recent search_music result. Returns track candidates with session-scoped item keys (use them promptly with enqueue_and_play). Non-expandable items return empty tracks with a skipped reason rather than an error.

Inputs: itemKey (required), limit.

list_zonesList Roon zonesA

Use this when the user asks which rooms, speakers, or outputs Roon can play to, or before starting playback when the target zone is unclear (e.g. "what zones are on?", "play in the kitchen", "which speaker is in the office?"). Lists every zone/output the paired Core exposes with its id, display name, current playback state, and output ids. Call this first if no zone is obvious and ROON_DEFAULT_ZONE is not set.

Inputs: no named parameters.

muteMute or unmute a Roon zoneA

Use this when the user wants to mute or unmute a zone (e.g. "mute", "mute the kitchen", "unmute", "silence the office", "stop the noise"). Mutes (or unmutes) every output in the resolved zone. zoneId is optional and resolves like now_playing. muted: true mutes, muted: false unmutes.

Inputs: muted (required), zoneId.

now_playingGet the currently-playing track in a Roon zoneA

Use this when the user asks what is currently playing, what song is on, who's singing, what just started, or wants a snapshot before skipping or pausing (e.g. "what's playing?", "what's playing in the kitchen?", "who's this?", "what's the current track?", "what song is this?"). Returns a structured snapshot: zone id and name, playback state (playing/paused/loading/stopped), title, artist, album, and the current seek position when available. title/artist/album are undefined when nothing is playing. Call this before pause/skip/volume changes when the user hasn't named a zone — it confirms where to act and what the state is. zoneId is optional: omit to use ROON_DEFAULT_ZONE, or fall back to the only zone / an "Office" zone / the currently-playing zone; if it still can't decide it returns ZONE_AMBIGUOUS so the agent can ask the user or call list_zones.

Inputs: zoneId.

play_nowPlay an item now in a Roon zoneA

Use this when the user wants one specific thing playing right now — an album, artist, playlist, genre mix, or single track (e.g. "play Tycho", "put on In Rainbows", "start some Psytrance in the office", "play that track"). Immediately plays a single search candidate in the target zone and replaces whatever was queued. Pass an itemKey from a recent search_music (or get_tracks_for) result — item keys are session-scoped, so use a fresh one. zoneId is optional: omit it to use ROON_DEFAULT_ZONE, or fall back to the only zone / an "Office" zone / the currently-playing zone; if it still can't decide it returns ZONE_AMBIGUOUS so the agent can ask the user or call list_zones. Optionally shuffle. Returns a PlaybackResult.

Inputs: itemKey (required), zoneId, shuffle.

search_musicSearch Roon musicA

Use this when the user names music to find — an artist, album, track, playlist, radio station, or genre (e.g. "find Tycho", "look up the album In Rainbows", "play some Psytrance", "anything by Ryuichi Sakamoto?"). Resolves a free-text query into ranked Roon browse candidates. Optionally restrict to one item type (artist, album, track, genre, playlist, radio); for non-genre types, an empty typed search broadens to all categories. type:"genre" is special — genres don't appear in Roon's flat search, so the server walks the dedicated Genres tree and returns the nearest-match genre nodes (with parent path in the subtitle) without broadening; e.g. "Psychedelic Trance" yields "Psytrance"/"Trance". Set includeStreaming:true (only meaningful for type:"genre") to also pull a track mix from streaming services (e.g. TIDAL): the server takes the genre-relevant albums and samples tracks across them, so library genre nodes come first and ready-to-play streaming tracks are appended after. Returns opaque, session-scoped item keys for use by the playback tools — pair with get_tracks_for to expand, then play_now or enqueue_and_play.

Inputs: query (required), type, limit, includeStreaming.

set_volumeSet a Roon zone's volume to a target percentA

Use this when the user wants to change how loud a zone is — turn it up, turn it down, set the volume, or pick a level for a specific room (e.g. "turn it up to 60", "volume to 30 in the office", "set the kitchen to 50", "louder" — see note). Sets the zone's volume to level percent (0 = silent, 100 = max). The server rescales to each output's native range, so a single value works across mixed devices in a grouped zone. Incremental outputs (IR blasters with no numeric range) are reported as skipped rather than guessed at. zoneId is optional and resolves like now_playing. NOTE on relative changes ("louder" / "softer" without a number): this tool is absolute — read the current state with now_playing isn't enough on its own (volume isn't exposed there), so for relative changes, ask the user for a target percent or apply a reasonable default delta.

Inputs: level (required), zoneId.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.5/5.0

    Scored across 9 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: control_playback handles transport commands, play_now starts a single item, enqueue_and_play builds a queue, search_music finds candidates, get_tracks_for expands them, etc. No two tools overlap in functionality.

    Naming Consistency4/5

    Tool names follow a mostly consistent snake_case pattern with verbs leading (list_zones, set_volume, get_tracks_for). However, 'now_playing' and 'play_now' are verb-phrase based, and 'mute' is a single verb, introducing minor inconsistency.

    Tool Count5/5

    With 9 tools, the server is well-scoped for a Roon music control system. Each tool handles a essential aspect (search, playback, queue, volume, zone management) without being bloated or underpowered.

    Completeness4/5

    Core workflows are covered: search, browse, play, control, volume, and state. Minor gaps exist, such as no playlist management or fine-grained queue manipulation (e.g., insert, remove), but these are not critical for basic operation.

    Maintenance

    ActivityNo data
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers