Skip to content

Asking a person

Eight of the 44 tools can take something out of a list somebody curated, or out of a listening history nobody can reconstruct. All eight ask a person first.

Not a confirm: true argument the model can set. Not a token the model reads out of its own previous result. A dialog, raised through MCP elicitation, that goes to the client and is shown to whoever is sitting there.

The specification says a client should keep a human in the loop:

there SHOULD always be a human in the loop with the ability to deny tool invocations

This server does not rely on that. It raises the question itself, and until an answer comes back, nothing happens.

What asks

ToolWhat it takes out
delete_collectionthe curated list and its order; the books stay
delete_playlistthe playlist; the media stays
delete_media_progressposition, finished state and dates — the listening history of an item
delete_bookmarkthe title typed for a position
remove_books_from_collectionmembership, and the order it sat in
remove_items_from_playlistentries — and the whole playlist, if it was the last one
update_collectionthe curated order, when library_item_ids is given — see below
update_playlistthe curated order, when items is given — see below

The last three were added because "you can just put it back" turned out to be only half true:

  • add_books_to_collection appends at the end. It does not restore an order.
  • create_bookmark makes a new bookmark at that position, with a new title. The one somebody typed is gone, and the position is the only thing delete_bookmark is given — a wrong time takes out a different bookmark than the one that was meant.
  • Audiobookshelf deletes a playlist outright once its last entry is removed.

The last two are conditional, and that is the point of them: the gate hangs off the effect, not off the verb.

update_collection and update_playlist are named as updates, and one of the things they do is take something out — the order somebody arranged. That is the very consequence remove_books_from_collection cites for asking ("the curated order of the collection cannot be reconstructed from here"), so an operation whose only effect is that cannot be the cheaper call. Since 0.3.0 they ask whenever library_item_ids / items is present.

Be clear about what those two arguments do not do, because their own descriptions used to imply otherwise. Measured against Audiobookshelf 2.29.0:

  • PATCH /api/collections/{id} treats books as a sort key over the membership it already has. An id that is not in the collection is ignored, and a book left out of the list is not removed — it gets index -1 and moves to the front. A list of ids that do not exist at all answers 200 and changes nothing.
  • PATCH /api/playlists/{id} refuses a list whose length differs from the playlist's, with 400 Invalid playlist items. Length mismatch. It cannot drop an entry even in principle.

Use remove_books_from_collection and remove_items_from_playlist to change membership. Renaming and re-describing stay free: they are recoverable by typing the old text back, and a dialog in front of every rename is how people learn to tick without reading. Everything else — creating, adding, setting progress — asks nothing: it adds or sets without taking anything away.

What the dialog contains

Ids and counts. Never a title, a name or a description.

That is not a formatting preference. Book descriptions come from metadata providers, podcast titles from RSS feeds, collection names from whoever uses the server. None of it is written by you, and the prompt is read by a model at the exact moment it is deciding whether to delete something.

This will remove 2 book(s) from collection col_abc123.

The curated order of the collection cannot be reconstructed from here — putting
the books back with add_books_to_collection appends them at the end. The items
themselves are not deleted.

The approval is bound to its target, so one obtained for a collection cannot be replayed against another. For a set of targets the binding is a fingerprint of the exact list: an approval for ["a"] does not execute ["a", "b"].

Clients that cannot show a dialog

Not every MCP client implements elicitation, and a stateless gateway may not be able to speak for the one it is currently serving. Rather than refuse to work — which pushes people towards switching the guard off entirely — the tool falls back to a two-call token: the first call returns a random string, the second has to quote it back.

Be clear about what that proves, because this server is:

the token proves the call was made twice with the same arguments, and nothing more.

A model can read the token out of the first result and call again in the same turn without anybody seeing it. It catches a widened target set; it does not catch a model that was talked into the whole thing. The fallback text says so rather than implying somebody approved.

Switching the dialog off

sh
ELICITATION=false

Default is true. false does not remove the guard — it takes the fallback path above, which means the token. There is no setting in which a guarded call goes unannounced.

Use it where a dialog is the wrong shape rather than an unwanted one: a scheduled job, a test harness, a client whose dialog interrupts something else.

It is deliberately not prefixed

ELICITATION has no AUDIOBOOKSHELF_ in front of it, so one export ELICITATION=false — or one -e ELICITATION=false in a compose file — reaches every MCP server in that environment, not just this one. That is the point of it and also its risk.

Two things make it visible rather than silent:

  • a server started with it off prints one line at startup, in the log of every server it actually reached:

    audiobookshelf-mcp: ELICITATION=false — guarded tools fall back to the two-call token
  • the fallback text names the server that did not ask, instead of blaming a client that was working fine. :::

Anything other than true or false1, off, yesstops the server with exit code 1 and a message naming both valid values. This is the only variable in this family that defaults to on: a typo that fell back to the default would leave the dialog running while the operator believed it was off, and there would be nothing to tell them.

Annotations are the other half, and they are only a hint

Every tool of this server declares all four MCP tool annotations — readOnlyHint, destructiveHint, idempotentHint, openWorldHint — so a client can tell before it calls what a call would do. See Tools.

They are advice, and the specification says so:

clients MUST consider tool annotations to be untrusted unless they come from trusted servers

An annotation is something a client may ignore. The dialog is not: it is enforced here, on the server side, and no answer means no change. The two are different claims — the annotation says what a call does, the dialog decides whether it happens — which is why a tool can be marked destructive without being guarded. update_collection is exactly that case: it replaces a name somebody typed, and it is not asked about.

Behind a gateway

Both protocol revisions are handled from one code path. On 2025-11-25 the question is pushed to the client; on 2026-07-28 there is no server→client channel at all, so the call returns input_required, ends, and the client retries carrying the answer.

That answer arrives as ordinary request content, which the SDK does not validate — so the state that ties an answer to its question is sealed (HMAC). A reply whose seal does not open, or opens onto a different target, counts as no answer and produces a fresh question rather than an error. The likeliest cause is not an attack: it is a gateway that put the server to sleep while the person was reading.

If you run this behind mcp-hub, the hub passes elicitation through in both directions; see its elicitation guide.

Released under the MIT License.