github 54yyyu/zotero-mcp v0.12.1
0.12.1

latest release: v0.12.2
3 hours ago

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.sqlite instead 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 whenever ZOTERO_LOCAL=true and 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 boolean itemType expression, is re-asked of the API for that call, and ZOTERO_BACKEND=api restores 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_key missing keys outside a 25-result window, items(itemKey=...) returning child notes, and a redundant probe in switch_library. Readers held across tool calls reopen when Zotero writes, and the WAL snapshot of a large database is refreshed at most once per ZOTERO_MCP_DB_SNAPSHOT_MIN_INTERVAL seconds (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 with Unsupported input type: NoneType until 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-db could 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's q searches titles and creator fields, and the API documentation notes that "searching of other fields will be possible in the future", so DOI, url, archiveID and extra are 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 every if_exists='file' add, the zotero-cli default. The existing tests could not catch it: the fake client ignores q and 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_url makes 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, because qmode='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 &amp; — 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 &lt;10 Hz keeps 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 as DREAM<sub>(D)</sub>: …, while DREAM (D) : … does.

  • The dedup candidate window no longer truncates the item it is looking for. find_existing_items narrowed with limit=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 (Dependence matches 91 items in a 16,861-item library; Noise 87), and results are sorted dateModified descending — 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 at limit=50, a book titled Stochastic 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.04259v1 and a stored .../abs/2509.04259 compared 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_id is 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_fulltext says when a document was cut short. Reads stop at fulltext_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 Text heading, 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 reports page_count and truncated on 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 the zotero_read_pdf_pages call that resumes at the first unread page. A read that fits under the cap is unchanged. Zotero's own .zotero-ft-cache and the transient fulltext cache are not page-capped and are never reported as truncated. LocalZoteroReader's parser seam returns the whole ExtractedDoc instead of just its text, so page bookkeeping has one source of truth rather than being re-derived by callers (#448).

Don't miss a new zotero-mcp release

NewReleases is sending notifications on new releases.