Install or upgrade: uv tool install --upgrade "zotero-mcp-server[all]" (or pip install -U zotero-mcp-server).
Headline: in local mode, read tools now answer straight from zotero.sqlite, with the Zotero API as a per-call fallback. On a 44,105-item library that is 1.8 ms instead of 2.5 s for the children of 25 items, and 110 ms instead of 104 s for the full tag list. Designed and built by @mronkko (#502). Set ZOTERO_BACKEND=api to keep the previous behaviour.
Added
- In local mode, read tools answer from
zotero.sqliteinstead of the Zotero API, with the API as a per-call fallback. Every read tool used to call pyzotero, so its access patterns were shaped by HTTP: one request per item, per page of 100, per parent's children. Reads now go through one read port with two implementations. The SQLite one answers from the database file in a fixed handful of queries, and is the default wheneverZOTERO_LOCAL=trueand the database is readable. Measured on a 44,105-item library: one item 0.4 ms vs 43.5 ms, children of 25 items 1.8 ms vs 2,476 ms, all tags 110 ms vs 103.9 s. Anything SQLite cannot express, such as a wildcard tag filter or a booleanitemTypeexpression, is re-asked of the API for that call, andZOTERO_BACKEND=apirestores the old behaviour. It also keeps read tools working with Zotero closed, makes global search (search_all_libraries) available by default in local mode, and fixes four bugs the port exposed:search_items(collection_key=...)reporting an existing collection as missing when Zotero was closed,search_by_citation_keymissing keys outside a 25-result window,items(itemKey=...)returning child notes, and a redundant probe inswitch_library. Readers held across tool calls reopen when Zotero writes, and the WAL snapshot of a large database is refreshed at most once perZOTERO_MCP_DB_SNAPSHOT_MIN_INTERVALseconds (default 5). Designed, built and measured by @mronkko (#502).
Fixed
-
Semantic search no longer fails outright after documents are deleted while the server is running (#545). A server kept open while another process (a CLI update, or the deletion pass) removed documents could get hits back whose document text was
None; the cross-encoder reranker accepts only strings, so every search failed withUnsupported input type: NoneTypeuntil restart. Those hits are now dropped before reranking and enrichment, with a warning, and the search returns the rest. Reported by @Aboottogo in #457. -
update-dbcould sit silent for hours in the rate limiter (#548). The token bucket's capacity was fixed from the initial tokens-per-minute while every 429 halved the rate, and the wait is the deficit divided by the rate, so with Gemini's default budget against a free-tier account a single request waited about 34 hours, indistinguishable from a hang; running out of API credit mid-run did the same. Capacity now follows the current rate, so a wait is at most a quarter-minute of budget, and a wait of 30 seconds or more is logged. Found and fixed by @ArneBouten. -
if_exists='file'no longer creates a duplicate of an item that is already in the library. The dedup lookup narrowed candidates with a server-side quick search on the identifier itself —zot.items(q=<doi>, qmode='everything'). Zotero'sqsearches titles and creator fields, and the API documentation notes that "searching of other fields will be possible in the future", soDOI,url,archiveIDandextraare not searchable server side today. Measured against a 16,859-item library, six of six items selected for having a DOI returned zero hits when searched by their own DOI, and no spelling of a stored preprint's arXiv ID found it while a title query found it immediately. The search returned nothing, the client-side comparison never ran, the caller read that as "not in the library", and a duplicate was created — which is everyif_exists='file'add, thezotero-clidefault. The existing tests could not catch it: the fake client ignoresqand hands back the whole library, so the matcher was exercised and the search never was.Where a title has already been fetched it is now passed alongside the identifier, and a miss on the identifier query falls back to querying the title — the one field the API does index — running the same identifier comparison over those candidates. The identifier still decides, so this widens the net without loosening the test: a same-title-different-paper is rejected exactly as it was. Wired into the six checks that hold metadata by the time they run — the arXiv path after its metadata fetch, the DOI batch path from the CrossRef payload, the ISBN path after the Open Library / Google Books lookup, the BibTeX/CSL-JSON batch import, which carries hundreds of entries per call and was re-creating every one of them, and the ISBN and URL checks
add_by_urlmakes before creating an item from a page's embedded metadata (#515). Note the cost: because the identifier query almost never matches against the Web API, a caller that passes a title should expect two searches per check rather than one. The identifier query is kept in front of the fallback rather than skipped, becauseqmode='everything'also searches child-attachment full text and finds an item whose stored title no longer matches the one just fetched — neither of which the title query covers.Titles are normalized before they are used as a query. Zotero's quick search splits on whitespace and requires every token to match (measured: reordering a title's words still finds it; appending one junk word drops it to zero), so markup in the fetched title — a real
<i>,<sub>or&— is a token that matches nothing, and one italicised species name took the whole lookup to zero against an item whose stored title is clean. The CrossRef mapping's own clean-up (see "A DOI added here now reads like the same DOI saved from the browser") does not make a title a search key: it keeps the inline markup Zotero renders —<i>,<b>,<sub>,<sup>, small caps as a styled<span>— which is exactly the markup at issue, and titles from arXiv, Open Library, a landing page or a BibTeX/CSL-JSON entry never pass through it. Since that mapping decodes entities first, a<not followed by a letter is kept as text, so a title about<10 Hzkeeps the words between its<and a later>. A tag is replaced by a space rather than deleted, because every token has to occur in the stored title whether Zotero kept the markup or not: measured,DREAM(D): …does not find an item stored asDREAM<sub>(D)</sub>: …, whileDREAM (D) : …does. -
The dedup candidate window no longer truncates the item it is looking for.
find_existing_itemsnarrowed withlimit=50, which never mattered while the only query was on an identifier the API cannot search — that query returns nothing, so the window was never full. A title query fills it. Quick search matches each token as a substring, so a short title pulls in far more than it appears to (Dependencematches 91 items in a 16,861-item library;Noise87), and results are sorteddateModifieddescending — an ordering that works against the caller, since the item being deduped against is by definition already in the library and therefore competing with everything touched since. Measured atlimit=50, a book titledStochastic Processes(83 matches, last modified 2024) fell outside the window and would have been duplicated. Raised to 100, the API maximum, which covers every over-50 title in that library. -
An arXiv paper already in the library is recognized however it got there. The arXiv matcher checked two of the four places Zotero can record an arXiv identity, and compared version-sensitively. An item saved by the browser connector stores the ID in
archiveID, and one added by DOI carries only arXiv's DataCite DOI (10.48550/arXiv.<id>); neither field was checked, so a later add of the same paper by its arXiv ID saw nothing and duplicated it. Separately,.../abs/2509.04259v1and a stored.../abs/2509.04259compared unequal, so re-adding a paper at a different version duplicated it too. Matching now goes through a version-independent identity that also accepts the DataCite DOI form._normalize_arxiv_idis untouched — it deliberately keeps the version because its other callers use the result to fetch a specific one — so fetch behaviour is unchanged. -
zotero_get_item_fulltextsays when a document was cut short. Reads stop atfulltext_display_max_pages(10 by default), but the text returned for the first ten pages of a 442-page book was byte-for-byte indistinguishable from the text returned for a complete three-page note: same## Full Textheading, no marker anywhere, and a tool description that promised "the entire paper". An agent summarized and cited the excerpt as if it had read the whole thing. The parser already reportspage_countandtruncatedon every extraction, so both capped paths — local storage and the last-resort download+parse — now carry them through to the heading:## Full Text (pages 1-10 of 442 — TRUNCATED), followed by the config key to raise and thezotero_read_pdf_pagescall that resumes at the first unread page. A read that fits under the cap is unchanged. Zotero's own.zotero-ft-cacheand the transient fulltext cache are not page-capped and are never reported as truncated.LocalZoteroReader's parser seam returns the wholeExtractedDocinstead of just its text, so page bookkeeping has one source of truth rather than being re-derived by callers (#448).