Changelog
[0.4.0] - 2026-09-07
Security
- Credential fields are removed from every answer.
GET /api/meanswers withUser.toOldJSONForBrowser(), and Audiobookshelf calls it withouthideRootToken— so the document carriestoken, the account's old non-expiring access token, for a root account included. The compact projection never named the field, butdetail: "full"handed the record on whole, which put a credential outliving this process into a model's context and into whatever that model's operator logs. Any field whose normalised name ends intoken,password,secret,apikey,passphrase,pashorprivatekeyis now replaced at any depth, inget_meand inlist_bookmarkswhich reads the same endpoint, and the result names what it removed rather than dropping it silently. - A confirmation binds every field the dialog names.
update_collectionandupdate_playlistopen their dialog on the reorder, because that is the part with no way back — but the same call may carry a name and a description, and those rode along unnamed and unbound. A token issued for "reorder these books" executed a second call that reordered the same books and renamed the collection to something the person never saw. The sentence now names every field the call will write, the caller's new values are shown on their own labelled lines, and the resource key covers them. - Emptying a playlist is deleting it, and the allowlist now knows. Audiobookshelf deletes a playlist outright once its last entry is removed, so
AUDIOBOOKSHELF_DENY_TOOLS=delete_playlistdid not remove the capability.remove_items_from_playlistreads the playlist before it asks, says in the dialog when the removal takes the last entry, and refuses outright wheredelete_playlistis not registered. - The API key cannot leave through an error message. There was no shape check on
AUDIOBOOKSHELF_API_KEYand no check before the request, so a key pasted across two lines reached undici — whose refusal isHeaders.append: "<value>" is an invalid header value., the whole value, whichrunthen answered with. The key is trimmed and checked for shape at startup (printable ASCII, 8 to 4096 characters, the message naming the variable and the length and never the value) and every header is checked again before the request, because aConfigcan be built withoutloadConfig. - Two supply-chain gaps in jobs holding an OIDC token. The npm publish job ran
npm ciwithout--ignore-scripts, so every dependency's install hook would have run while the Trusted Publishing token was available; nothing in the tree declares one, which is what makes the flag free. Andmcp-publisherwas fetched fromreleases/latest/downloadin two jobs withid-token: write— it is now pinned to a version and verified against the release's published checksum.gh release creategained--verify-tag, and pull requests are checked byactions/dependency-review-action. - Podcast feed URLs are redacted. A private feed is published as
https://user:token@host/feed.rssand Audiobookshelf stores it as given, soget_library_itemhanded the credential back in both channels. get_library_statsandget_meanswer as untrusted content. The first carries the titles and authors of a library's longest and largest items, the second an account's bookmark titles and selected tags — all written by somebody other than the operator, and all previously framed as this server's own words.- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - Approval keys bound to positions.
update_playlist(items) andupdate_collection(books) reorder a list, and the order is the whole change, so each entry was prefixed with its index by hand beforesetResourceKeysorted the list.delete_bookmarkkeyed the pair (item, seconds) as a plain set, so a token for one pairing also matched the swapped one. All three now build their key withorderedResourceKeyfrom mcp-approval 0.8.2, which binds every part to its position itself; the hand-written prefixes are gone. Keys over sets of ids — the removals, the deletions — stay onsetResourceKey, where sorting is the point.
Fixed
- Seventeen answers the instance can give that a tool could not. A
200with no body — which several routes legitimately send — was read as an object by six tools and handed to the result budget by three more, whereBuffer.byteLength(undefined)became the tool result. Atotalofnullor1e999failed a whole listing, becausez.number()refuses theInfinitythat1e999parses to. Onenullamong the entries of adetail: "full"list failed the answer with every good entry in it. An empty readback failedset_media_progress,create_bookmarkandupdate_bookmarkafter the write had happened. Every response now goes through a boundary that shapes it, and a field the instance did not send is absent rather than fatal. - The result budget is linear again. It cut one candidate per round and re-serialised the whole document to measure the result, so the number of rounds was the instance's to choose: 2 000 text fields took 5.4 seconds, 4 000 took 24, and 8 000 took 105 — on the thread that serves every request. Each round now collects what can be cut, spends the largest first until the estimate covers the overshoot, and measures once. The same 8 000 fields take 42 milliseconds. And the search is recursive, so the bulk of a
detail: "full"item —media.audioFiles, one level down — is shortened instead of making the tool refuse the answer. - The base URL is trimmed in one pass.
replace(/\/+$/, '')is tried from every position of a run of slashes and consumes it each time, so an operator URL ending in 80 000 of them cost 1.7 seconds at startup. It is now 0. - A
__proto__key from the instance no longer loses a count. The budget's record of dropped entries was an object literal written withrecord[key] =, which on that one name sets the prototype and drops the field — so the entries were dropped and the note said nothing had been. - Control characters are stripped from every string and every key a result carries, in both channels, and a lone surrogate is repaired. An escape sequence in a book title could repaint the log of whoever read the result, and a lone surrogate — legal JSON — makes a Python client raise
UnicodeEncodeError. Bidirectional marks and joiners are kept: they are content in a title. - Backend text quoted into an error is cut, stripped and labelled as the instance's words: a media type that is not
book, a content type that is not JSON, and every error body. An error body is read under a 64 KiB ceiling of its own rather than the 5 MB one, so a 401 answered with a login page costs what it should. remove_items_from_playlistvalidates its ids before it asks about them. The path check ran after the approval, so a person was asked about ids that had not been checked and an approval was spent on a call that could not run.- Caller arguments are bounded: ids at 128 characters, a search query at 500, a sort key at 100, a filter value at 1 000,
pageat a million and a playback position at 10^9. Each of them reaches a URL path, a confirmation sentence or an error message. -0no longer splits the two channels of an answer:JSON.stringifywrites it as0whilestructuredContentkeeps it.
Added
- The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - Server
instructions. Results carry anuntrustedmarker, but that is read after the fact — this is the channel a model sees before it calls anything. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.
Changed
docs/reference/tools.mdis written by hand again. It used to be generated from the registered tools, which kept it in step with the code at the price of a page nobody could edit:--checkcompared it byte for byte, so every line had to be derivable and a paragraph about how an endpoint really behaves had nowhere to go. A test now asserts what the generator guaranteed — the page documents exactly the tools that exist, marks exactly theessentialpreset, and marks exactly the tools that ask a person first — and leaves the prose to a person.homepageinpackage.jsonpoints at the documentation site rather than at the README anchor on GitHub. It is what npm shows next to the package, and every one of these servers has had a documentation site for weeks.- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller. - yarn, corepack and the lockfile are gone from the runtime image, for the same reason npm already was: nothing reaches them from
node dist/index.js, and each carries a vendored dependency tree for a scanner to find.
[0.3.0] - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result — which seven of them made unavoidable, since they answered with a sentence.The tools that report library metadata carry
untrusted: trueandsource: "audiobookshelf"as fields, not only as a preamble in the text. Book descriptions pulled from metadata providers, podcast feed summaries and episode titles are written by someone else, so a client that reads the structured half must not get them unframed.The documents are described as open objects with the top-level keys this server builds.
detail: "full"hands the API record back whole, so a strict shape would turn that mode into a failed call.Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call
confirm_tokenremains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.Three more tools ask before they act, all of which carried
destructiveHint: trueand went through unannounced:delete_bookmark,remove_books_from_collectionandremove_items_from_playlist.They were exempt because they could be undone, and that turned out to be only half true.
add_books_to_collectionappends at the end rather than restoring an order.create_bookmarkmakes a new bookmark at a position, with a new title — the one somebody typed is gone, and the position is the only thingdelete_bookmarkis given, so a wrongtimetakes out a different bookmark than the one that was meant. And Audiobookshelf deletes a playlist outright once its last entry is removed, whichremove_items_from_playlistalready warned about after the fact.delete_bookmark's description said in so many words "No confirmation token: a bookmark is a position and a title, and create_bookmark restores it." It does not restore the title.ELICITATIONswitches the dialog off —falsesends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one
export ELICITATION=falsereaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neithertruenorfalsestops the server: it is the only variable here that defaults to on, so failing open on a typo would leave the dialog running while the operator believed it was off. It is read after the API key is wiped from the environment, so that exit cannot leave the key behind.A
docs/guide/approval.mdpage, and a 👤 marker in the generated tool reference that is read off the registered schema rather than from a list kept beside it.
Changed
The advertised schemas avoid a spelling that is legal JSON Schema and still gets a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes
"additionalProperties": truerather than the empty schema{}zod emits for it. What the tools accept and return is unchanged; only the way the schema says so is.get_personalized_shelvesanswers{items: [...]}instead of the bare array the API sends. A schema whose root is an array is served to a 2025-era client rewritten as{result: …}, so the tool would otherwise answer in two shapes depending on which revision the client spoke.A result too large to shrink is an error rather than an envelope saying so. The envelope was a different shape from what the tool declares it returns, which the SDK refuses.
The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. The text is unchanged and still carries the token.The integration compose file publishes Audiobookshelf on
AUDIOBOOKSHELF_PORT(default 13378) instead of a hardcoded 13378, so a workstation that already runs one there does not need a patched compose file.Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.
The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.The tool filter, the confirmation store, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather than from copies kept here — 872 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.The shared libraries move to
mcp-approval0.7.1,mcp-tool-allowlist0.2.1,mcp-internal-hosts0.2.1,mcp-integration-harness0.2.0 andsvg-asset-set0.2.0.SECURITY.mdnow says what the confirmation proves: binding to one operation with one set of arguments, not freshness. No replay defence is built, because the sealing key is per process, the token is single-use, andrequestStateonly crosses the wire on protocol revision2026-07-28, which this server does not offer — it takes the SDK's default list, which ends at2025-11-25. The section names what would have to change for that to stop being true.stdio is served through
serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wiredStdioServerTransportserved it.
Fixed
A result ceiling of 100 000 bytes, applied in
jsonResultanduntrustedJsonResultrather than per tool — sodetail: "full", which switches the compact projections off, is covered by it too. Whole entries are dropped, never characters: a truncated document is not a smaller answer, it is an unparseable one. The result carries atruncatedblock naming what to call instead, and the budget counts bytes, because a library of CJK-titled books is roughly three bytes per counted UTF-16 unit.A compact collection or playlist embeds at most 25 members.
compactCollectionmappedbooksunconditionally — not behinddetail, not behind a count — andcompactPlaylistdid the same while embedding a full compactlibraryItemandepisodeper entry. Forty collections of three hundred books was twelve thousand embedded objects in one default-detail read tool.numBooksandnumItemsstill report the real count, andget_collection/get_playlistreturn the whole membership for one of them.update_collectionandupdate_playlistdocumented an effect they do not have. Both said their list argument "replaces the order completely, so it has to contain every item that should stay", which reads as "an item you leave out is removed". Measured against Audiobookshelf 2.29.0, neither can change membership at all:PATCH /api/collections/{id}treatsbooksas a sort key over the rows the collection already has — the controller loads them and orders them byfindIndexin the payload. An id that is not in the collection is ignored, and a book left out gets index-1and moves to the front rather than being removed. A payload of ids that do not exist answers200and changes nothing.PATCH /api/playlists/{id}refuses a list whose length differs from the playlist's, with400 Invalid playlist items. Length mismatch.
Both descriptions now say what the arguments do, including the front-of-list behaviour and the 400. The integration suite pins both against a real instance — a stub agrees with any semantics at all, which is how this survived.
The README's "list tools are capped at 100 entries per call" was true for one tool. Seven of the fourteen listing tools have no
limitat all:list_libraries,list_authors,list_tags,list_genres,list_collections,list_playlistsandlist_bookmarks. The claim now says what holds — a response ceiling, a result ceiling and an embedded-member cap — and names the seven.No client-side
limit/pagewas added to them: the Audiobookshelf routes behind those seven take no paging parameters and answer with everything at once, so apageargument would advertise server paging that does not exist and would refetch the whole payload for each page.AUDIOBOOKSHELF_READ_ONLYaccepts1,trueandyes, trimmed and case-insensitively, where it used to require the exact stringtrue. It fails towards the restriction, soAUDIOBOOKSHELF_READ_ONLY=1silently registering the write tools is the one outcome it must not have.AUDIOBOOKSHELF_INSECURE_TLSkeeps the exact-match rule, for the same reason read the other way round.Confirmation tokens are compared with a constant-time comparison. The copy in this repository used
!==, which leaks through timing how much of a guess was right. Reaching a token still requires having received it in a previous tool result, so this closes a margin rather than a hole.An entry in
AUDIOBOOKSHELF_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.AUDIOBOOKSHELF_API_KEYandAUDIOBOOKSHELF_ALLOW_TOOLSare adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.
Security
update_collectionandupdate_playlistask before they reorder.remove_books_from_collectionsits behind a dialog plus a token, and the consequence it names is not the membership — it is that "the curated order of the collection cannot be reconstructed from here". Reordering does exactly that and nothing else, and it went through with no question at all. The gate ran between verbs where the risk runs between effects.The gate is conditional on
library_item_ids/itemsbeing present. Renaming and re-describing still ask nothing: those are recoverable by typing the old text back, and a dialog in front of every rename is how people learn to tick without reading.The confirmation key carries each target's position, not just the set:
setResourceKeysorts before fingerprinting, so a bare list of ids would have given[A, B]and[B, A]the same key — and the order is the whole change.Chosen over removing the argument and adding guarded
reorder_*tools: that is the same guard, two more tools, and a breaking change to a documented schema.Five routes are exempted from that check explicitly, not by weakening it.
DELETE /api/collections/{id},DELETE /api/playlists/{id},PATCH /api/me/progress/{id},DELETE /api/me/progress/{id}andDELETE /api/me/item/{id}/bookmark/{time}answer200 text/plain "OK"on 2.29.0. All five are mutations whose caller ignores the value, so each says so at the call site. Which five could only be established against a real instance.A 200 that is not JSON is now an error instead of an empty list. The body used to be returned as a string, and a string finds neither an array nor an envelope in
listFrom— so an SSO portal, a captive proxy or a misconfigured reverse proxy in front of the instance madelist_librariesanswer "you have no libraries". A swallowed error replaced by a plausible wrong answer is worse than an error.Second fuse for the same failure: the base URL is built from the parsed URL rather than from the raw string.
AUDIOBOOKSHELF_URL=https://abs.example.com/#devpassed validation, lost everything from the#onwards infetch, and sent every request — bearer token attached — to/, where the web UI answers 200 with HTML. A query string went the same way one character earlier.Responses are read under a 5 MB ceiling.
content-lengthis checked before a byte is read, and a chunked body — which declares no length — is counted while reading and cut off./api/collectionstakes no paging parameters and embeds every book of every collection, so a shared server with forty collections of three hundred books answered in double-digit megabytes, whichresponse.text()and thenJSON.parseheld about three copies of. An error body is still read (truncated), because the status code is the diagnostic and a size complaint would replace it.
[0.2.0] - 2026-08-27
Added
AUDIOBOOKSHELF_ALLOW_TOOLSandAUDIOBOOKSHELF_DENY_TOOLSchoose which of the 44 tools are registered. Both take comma-separated tool names or a prefix with a trailing*, the allow list decides what is in and the deny list is subtracted from it, andAUDIOBOOKSHELF_ALLOW_TOOLS=essentialselects a curated eight —list_libraries,search_library,list_library_items,get_library_item,get_item_chapters,list_items_in_progress,get_media_progress,set_media_progress. A model picks the right tool far more reliably from eight than from forty-four, and every visible tool costs context on every request. Nothing changes for an installation that sets neither.A filtered tool is not registered at all, so it is absent from
tools/listand answerstools/callwith "tool not found" — the same cutAUDIOBOOKSHELF_READ_ONLYalready makes, not a second, weaker one.An entry that matches no tool stops the server at startup, naming the entry and listing the real names, rather than being ignored: an ignored typo leaves a tool missing from
tools/listwith nothing pointing at the cause.
Changed
- The README now carries the same eight badges, in the same order, as every other MCP server in this family, all of them reading from npm rather than hard-coded; the opening follows one shape; and the standalone "Full documentation" line is gone, because the docs badge three lines above it points at the same page.
Fixed
- The container image no longer ships OpenSSL 3.5.7-r0, which carries CVE-2026-14456 (denial of service via unbounded memory growth). The pinned
node:24-alpinedigest is already the newest one; Alpine's fixed 3.5.8-r0 has simply not been rebuilt into it yet, so the runtime stage now upgradeslibcrypto3andlibssl3by name. Upgrading those two rather than running a blanketapk upgradekeeps the rest of the image exactly as the digest pins it. The step can go once the base image ships the fix.
[0.1.4] - 2026-08-26
Changed
- The check that decides whether
AUDIOBOOKSHELF_URLpoints somewhere local — and therefore whether sending a credential over plainhttpis worth warning about — now uses the same host classifier as the other MCP servers in this family, insrc/hosts.ts. The string comparison it replaces missed several spellings of the same address:http://[::ffff:127.0.0.1], whichURLcanonicalises to[::ffff:7f00:1]before any check sees it, andlocalhost.with its root label. It also treated127.example.comas loopback, because it matched on the127.prefix, and so stayed quiet about a plain-http URL to a public host.
Nothing else changes: this server has no tool that takes a URL, so there is no request whose target a caller can choose.
[0.1.3] - 2026-08-18
Fixed
- The API key is no longer left in the environment when
AUDIOBOOKSHELF_URLis unset.loadConfigdeleted it only at the very end, behind the early return for a missing URL, so in that state the key stayed inprocess.envfor the whole process lifetime — readable in/proc/<pid>/environand inherited by every child process. The deletion now happens before any branch. - A malformed
AUDIOBOOKSHELF_URLis no longer echoed into the log. That branch fires precisely when the variable does not hold a URL, which most often means the API key was pasted into the wrong variable. http://[::1]:…no longer produces the "plain http to a non-local host" warning.URL.hostnamekeeps the brackets around an IPv6 literal, so the loopback check never matched that notation.
[0.1.2] - 2026-08-18
Fixed
- The architecture diagram no longer depends on the reader's operating system. It carried a
prefers-color-schemeblock, which resolves against the OS rather than the theme toggle of GitHub or npm — so dark-mode readers on a light OS got the light artwork on a dark page. The README now uses<picture>, which is resolved against the page, and the<img>that npm falls back to brings its own card instead of a media query.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand. docs/public/og.pngis generated at exactly 1280x640, GitHub's recommended size for a social preview, instead of being drawn by hand.
[0.1.1] - 2026-08-17
First release published by the automated pipeline, with npm provenance.
Added
- Multi-arch container image on GHCR (
ghcr.io/ni-c/audiobookshelf-mcp) for linux/amd64 and linux/arm64, built with an SBOM and build provenance. - Documentation site at https://audiobookshelf-mcp.ni-c.de, including a complete tool reference generated from the registered tools.
- Listed in the official MCP registry as
io.github.ni-c/audiobookshelf-mcp. SECURITY.mdwith the trust model,CONTRIBUTING.md, and issue forms.
Changed
- The runtime image no longer contains npm. It was only ever there because the base image ships it, the entrypoint is plain
node, and the dependency tree npm vendors accounted for every HIGH/CRITICAL advisory Trivy reported against the image — none of them in this project's own dependencies.
Fixed
- Test coverage raised from 93.9 % to 99.6 % of statements, mostly across the projections that absorb Audiobookshelf's varying response shapes, and the error paths.
[0.1.0] - 2026-08-17
Added
- Initial release: MCP server for Audiobookshelf.
- 29 read tools: libraries, library items with filtering and paging, search, series, authors, personalized shelves, tags, genres, items, chapters, podcast episodes, the current user, listening progress, listening statistics, listening sessions, bookmarks, collections and playlists.
- 15 write tools: listening progress, bookmarks, collections and playlists.
AUDIOBOOKSHELF_READ_ONLY=trueregisters the read tools only.- Confirmation tokens for the irreversible operations (
delete_collection,delete_playlist,delete_media_progress). - Compact projections for every media response, with
detail: "full"for the raw Audiobookshelf object.