navidrome-mcp
The navidrome-mcp server enhances Navidrome by building mood-aware, personalized playlists that combine your library's metadata, ListenBrainz listening history, Last.fm tags, and your own curated playlists. You can:
Discover & inspect: Get library overviews (genres, decades, mood playlists, tag vocabularies), check mood labelling coverage, profile vibe playlists (top artists, genres, time-of-day patterns), and analyze listening habits (recent, top, hourly/weekday patterns, rediscover, trending).
Search & filter: Perform compound searches across the entire library using flexible filters: mood axes (energy, valence, intensity, organic), mood descriptors, vibes, Last.fm tags, genres, year/date ranges, BPM, duration, play/listen counts, recency, time-of-day fit, starred status, with per-artist/album diversity caps and personal affinity ranking.
Mood & similarity: Enrich the library so every track gets a mood profile, find similar tracks using Navidrome's similarity agents and co-occurrence in curated playlists, and understand what each vibe means in your library.
Listening history: Analyse ListenBrainz data to see recent plays, top tracks/artists, hour-of-day/weekday habits, rising/falling trends, and forgotten favourites.
Playlist management: Create, update (replace/append), delete, and list playlists; also define rule-based smart playlists that Navidrome continuously re-evaluates.
Daylist automation: Get a real-time listening context for the current hour (time, dominant vibes, artists, recent listens), generate a personalized playlist with a unique title, and publish it as a rolling daylist that avoids repeating recent tracks.
Maintenance: Refresh the local index from Navidrome/ListenBrainz to keep data current, and run mood enrichment to enable whole-library mood search.
Provides a descriptive tag vocabulary (e.g. 'nu-disco', 'melancholy', 'shoegaze') used for mood-based playlist generation, with background tag fetching enabled via LASTFM_API_KEY.
navidrome-mcp
An MCP server for Navidrome that builds playlists which actually match a described mood, grounded in what you own, what you have listened to, and how you yourself have labelled your music.
Why this exists
Navidrome's API alone cannot do this, for three concrete reasons:
Its REST filtering is exact-match only.
year=1997works;year=1990-1999silently returns nothing. There are no ranges and no AND/OR, so a query like "90s rock I haven't played in a year, max 2 per artist" is not expressible.It keeps no listen history. Only a
playCountand a lastplayDate. There is no way to ask "what do I put on at 7am on a Tuesday".Nothing in the library says how a track feels. Measured on a real 9,311-track library: 32 genres with Rock alone covering 47%, BPM present on 24 tracks (0.3%), ReplayGain on 222 (2%), MusicBrainz recording IDs on 222 (2%). That last number also rules out AcousticBrainz as a primary mood source: its audio-derived mood models are keyed by MBID, and resolving the rest via ISRC → MusicBrainz (rate-limited to 1 req/s) would still only reach ~23% of the library.
So this server maintains its own index and joins three sources:
Source | Provides |
Navidrome (native + Subsonic APIs) | Authoritative metadata, similarity agents, the playlist write path |
ListenBrainz | Timestamped listen history: time-of-day and weekday habits, and the source the play-count backfill below is built from |
Last.fm | A real descriptive tag vocabulary ( |
Only the first is required. Listen history, Last.fm tags and your own playlists each improve ranking and time-of-day fit where they exist, and none of them is load-bearing: the same query means the same thing in a library that has none of them.
Listen history has two failure modes worth knowing about
Both were live here until 2026-08-10, and both are silent.
The history can be a prefix, and used to stay one. ListenBrainz publishes a rate-limit
budget on every response (X-RateLimit-Remaining, X-RateLimit-Reset-In; observed 30
requests per 5 seconds). Overrunning it does not return 429. The server stalls responses,
first to seconds and then to tens of seconds, and eventually closes the connection
(UND_ERR_SOCKET). The walk then stopped and kept its prefix, which is a perfectly valid
history: nothing distinguishes a quarter of a large account from all of a small one.
What made that permanent rather than merely unlucky is that the walk always restarted from the newest listen, so every later sync re-fetched what it already had and stopped at the same wall. The index sat at 32k of 126k for months, including through an explicit full resync. Requests are now paced against the published budget, a retry waits for the window to turn over instead of spending what is left of it, and an interrupted walk resumes from the OLDEST listen held. A sync that still cannot finish leaves the index usable and says so, and the next start picks the walk up where it stopped.
One play can arrive twice. Scrobbling to both Last.fm and ListenBrainz while also
running ListenBrainz's Last.fm importer delivers every play from two submitters. The
timestamps differ, because Last.fm stamps a scrobble when it is submitted and a direct
submitter stamps it at playback start, so nothing that keys on an exact time notices.
Measured here: a median 219 seconds apart, inflating about a quarter of recent listens.
dedupeListens collapses a pair only when a different submitter reported it, since two
plays close together from the same submitter are what a genuine repeat looks like. The
window and the reasoning are checked by npm run check:dedupe.
LISTENBRAINZ_HISTORY_DAYS bounds a cold start and defaults to effectively the whole
account. Note that an incremental sync resumes from the newest listen already held, so
listens backfilled with older timestamps need refresh_index with full_listens.
Related MCP server: Navidrome-MCP
Where mood comes from
Not from here. This server has no LLM client and no way to label a track. Mood is
produced by navidrome-mood, a Navidrome
plugin that judges each track and writes the values into the audio files as tags. This
server reads those tags and does everything downstream: filtering, cohesion, sequencing,
playlist writing.
The split is deliberate and one-directional. The plugin never calls this server and is useful without it: its tags drive Navidrome's own smart playlists, and Music Assistant and every Subsonic client can read them too. This server depends on the plugin only for mood; install it if you want mood-aware playlists, skip it and everything else still works.
Keeping a second labelling path here as a fallback would mean maintaining the same vocabulary in two languages and shipping two answers to one question. There is deliberately no fallback.
What the plugin writes, per track: energy, valence, intensity, acousticness, density, how fast it feels, whether it is sung, two to four vocabulary terms, the times of day it fits, the vibe regions it falls in, and for a track in none of them, the region it came closest to.
That last one is why mood_vibes_near exists. Region membership is strict, and about a
third of a library falls outside every region; most of those are ordinary tracks a little
past an edge rather than unusual music between the regions. Setting mood_vibes_near
unions them in, which on a 9,195 track library takes the share reachable by region from
65% to 94%. Leave it off when the region's character is the point.
The vocabulary is defined, not derived. Each of its 52 terms carries an explicit anchor
in mood-space, and each of the 14 vibes is a named region with a centre and a radius, so the
same track gets the same coordinates in anyone's library. Deriving the words from one
collection instead would bake that collection's shape into them: a rock-heavy library yields
riffy and bass-heavy, which say nothing useful about a jazz one.
The radii are the one part fitted to a collection rather than defined, and the split is deliberate. A centre says what a vibe means and must not move; a radius says how close counts as close, which depends on how tightly the music clusters. Fitting them against an even spread of the coordinate space produced regions wider than the typical gap between any two tracks in a real library, so they are fitted against a measured distribution instead. Coordinates therefore travel between libraries unchanged; region membership is calibrated.
Words alone cannot carry cohesion, which is why the axes exist. Measured on a real library,
tender covered both Debussy's Suite bergamasque and Metallica's Nothing Else Matters:
both labels correct, and useless as a playlist filter. Distance in mood-space separates them.
Call mood_coverage to see how much of a library is labelled. When the answer is none it
says which of the three causes applies (plugin never run, plugin ran but wrote nothing, or
tags written but not declared in Navidrome's own config file) because those need
different fixes and all three otherwise read as an empty library.
Backfilling Navidrome's play counts
Navidrome counts only what Navidrome served. Its scrobbler is outbound only and
IncPlayCount is play_count + 1, so there is no import path: a library
listened to for years before Navidrome existed shows a handful of plays, and
every smart playlist, client sort and play_count_min filter reads that
number rather than the real one. On the library this was built against it was
976 plays against a ListenBrainz history of 123,157 listens.
Run it, and that column becomes the count for everything. Ranking, sorting and
the count filters read Navidrome and nothing else; the connector's own listen
total stops being scored beside it, because after a backfill the two describe
the same plays and weighing both counts the same evidence twice. Skip it and
describe_library says so, rather than leaving every ordering quietly wrong.
scripts/ closes the gap. Two steps, because they run in different places:
tsx scripts/plan-playcounts.ts /data/navidrome-mcp/index.json playcounts.json
python3 scripts/apply-playcounts.py playcounts.json /data/navidrome.db # dry run
python3 scripts/apply-playcounts.py playcounts.json /data/navidrome.db --writeThe planner matches the history to tracks with the same key this server uses for
everything else, so a fix to the matcher fixes both at once. The applier is
standard-library Python because it has to run wherever navidrome.db is, which
is often a machine with no node; pointing it at a Docker volume works:
docker run --rm -v <volume>:/data -v "$PWD":/host python:3.12-slim \
python /host/apply-playcounts.py /host/playcounts.json /data/navidrome.db --writeFive things worth knowing before running it:
Do not use Navidrome's
/rest/scrobbleendpoint instead, however much more supported it looks. Navidrome forwards a scrobble to ListenBrainz and Last.fm, so replaying a decade of history through it submits that history back to the service it came from and corrupts it permanently.The write is a floor, not an assignment.
max(existing, imported)means Navidrome's own counting is never rolled back and a second run changes nothing. Onlyplay_countandplay_dateare touched;starredandratingshare the row and are the user's own judgements.A play can land on more than one file. Nothing in a scrobble says which copy was played, so a library holding both a single and an album version credits both. 486 of 7,580 matched tracks were in that position here. Each track's own count stays defensible; the library-wide total is inflated.
Not every listen matches. 50,781 of 123,157 matched nothing, which is mostly music heard elsewhere and never acquired. The planner reports that number every run: a sudden jump means the matcher broke, not that taste changed.
Matching on identifiers instead is not the safer option it looks like, and is worth understanding before anyone tries to "fix" the fuzzy match. Two reasons. Coverage: 222 of 9,200 files here carry a genuine MusicBrainz recording id. A further 839 carry a Discogs id in the MusicBrainz-named
musicbrainz_trackidtag, which grades as total disagreement against ListenBrainz and means nothing. Semantics: even where both sides hold real ids, they disagree about a third of the time, and the sampled disagreements are all the same song under a different recording, never a different song. ListenBrainz maps a scrobble to its own canonical recording, which is rarely the exact pressing on disk. An id join would therefore reject plays this one correctly credits. It would be more precise and match less.
Design notes
In-memory index, no database. A full library pull is ~20s for ~10k tracks; once local, every compound query is a single array pass. A JSON snapshot on disk makes restarts instant. Deliberately no native dependency, so it installs under
npm ci --ignore-scriptson Node 20.The index is a cache, never a source of truth. If the snapshot is missing or unreadable the server just re-syncs.
It re-syncs itself once it goes stale, half an hour by default, in the background on whatever tool call notices.
NAVIDROME_SYNC_TTL_MINchanges the window and0turns it off. This is not a nicety: a snapshot that loads cleanly used to be treated as current forever, so music added after the last manualrefresh_indexnever appeared, and a restart did not help. It also catches the gap where the mood plugin labels a track minutes after Navidrome imports it, which otherwise froze that track in the index unlabelled.Navidrome's own compound engine is still exposed via
create_smart_playlist, for standing playlists that should keep re-evaluating server-side. Note those rules can only see Navidrome's own fields, not ListenBrainz listens or Last.fm tags.npm run check:vocabasserts what typechecking cannot about this side's copy of the vocabulary: every synonym resolves and is reachable, no term or region name contains a character Navidrome splits tag values on, and every hour of the day is claimed by some region. The anchors and region geometry live in the plugin and are asserted there.
Tools
Tool | Purpose |
| Orientation: size, genres, decades, vibe regions, tag vocabulary |
| How much of the library is labelled, and what to fix when none of it is |
| The workhorse: full compound filtering, diversity caps, affinity ranking |
| What a vibe actually consists of in this library, and how tightly it clusters |
| Expand from seeds via agents + playlist co-occurrence |
| recent / top / by_hour / by_weekday / rediscover / trending |
| Read playlists |
| Write playlists |
| Self-updating rules-based playlists |
| What suits right now: the hour, which vibes fit it, what dominates it, what was just heard |
| Publish one revision of a rolling playlist, by title |
| Re-sync from Navidrome / ListenBrainz |
Prompt: daylist - refreshes the daylist playlist for the current hour.
Rolling playlists
A rolling playlist is one that gets rewritten on a schedule: daylist hourly, mix: chill
and the rest whenever they are asked for. Its title is fixed for its whole life and the
phrase that changes goes in the description, which is what commit_playlist enforces: it
matches on the title, never renames what it finds, and creates a playlist only when no
playlist carries that title yet. A generator that renamed its own output instead would lose
track of it and make a new one on the next run, and nobody notices that until the sidebar
holds forty near-identical lists.
search_tracks's exclude_recent_runs keeps a rolling playlist off its own recent tracks.
It is scoped to one playlist, so the hourly daylist moving fast does not starve the others.
Configuration
Env var | Required | Notes |
| yes | e.g. |
| yes | Playlists are created as this user |
| no |
|
| no | Listen history is a public read; no token needed |
| no | Defaults to Navidrome's bundled public key |
| no | Default |
| no | Snapshot location. Default |
| no |
|
| no | Default |
Licence
MIT
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceGenerates personalized music playlists based on mood analysis using AI sentiment detection and emoji understanding. Integrates with Last.fm API to create playlists with multi-language support and provides streaming links for Spotify, Apple Music, and YouTube.1Apache 2.0
- AlicenseNot gradedqualityAmaintenanceAnalyze listening patterns, create custom playlists, discover missing albums, validate radio streams, and provide personalized recommendations through natural language.7983AGPL 3.0
- AlicenseNot gradedqualityDmaintenanceGenerates .m3u playlists on the user's PC based on their current mood or theme, using metadata from local music files.5GPL 3.0
- AlicenseCqualityCmaintenanceSyncs YouTube Music liked songs, analyzes them for DJ metadata like BPM and key, and enables creating playlists from previews.10MIT
Related MCP Connectors
Search MusicBrainz artists, releases, works, labels; resolve ISRC/ISWC/barcode; fetch cover art.
MusicBrainz MCP — wraps MusicBrainz Web Service v2 (free, no auth)
Spotify MCP — Web API via client_credentials OAuth
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/312-dev/navidrome-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server