Added
update-db --allow-mass-deletion— the deletion pass now refuses to remove 25+ documents amounting to 25%+ of the syncing library's indexed documents in one run, a fingerprint far more likely to be a truncateditem_versions()response or a sync-scoping regression than a real purge. Intentional purges (including emptying a library outright) opt in with this flag for a single run. It is deliberately a CLI flag rather than an environment variable, so no persisted configuration can disable the guard and the MCP server's unattended background sync can never mass-delete. A run whose deletion pass was skipped (guard, failed or emptyitem_versions()) does not promote the sync watermark, so the rerun actually re-enters deletion detection instead of hitting the unchanged-version early exit. The same opt-in now also gates--force-rebuildwhen the collection holds documents not attributed to the active library (a reset destroys the whole collection but repopulates only the active library) and the--force-rebuild --limit Ncombination.- Structured JSON annotation export.
zotero_get_annotations(format="json")returns normalized annotation records with paper and attachment keys, page metadata, text, comments, color, tags, timestamps, and source.zotero_synthesize_annotations(format="json")returns the same records grouped by paper alongside structured notes and summary counts. The standalone CLI exposes this aszotero-cli annotations list --format json(#215). Records are normalized across all three annotation sources:pageis always the page label,page_indexalways 0-based (Better BibTeX and direct PDF extraction report 1-based pages internally), andcolor_categoryis derived for every source rather than only the Better BibTeX one.
Changed
fastmcpis pinned to<4, andhttpxis now a declared dependency. FastMCP 4 (currently4.0.0b1) targets the stateless2026-07-28MCP revision and is a breaking upgrade for this server: it removesctx.sample()/ctx.list_roots()/ctx.elicit(), raises thepydanticfloor to 2.12, and replaceshttpxwithhttpx2throughout. That last change is the reasonhttpxmoves into the dependency list —client.pyimports it directly for the HTTP/1.1-pinned local Zotero transport, and until now relied on it arriving transitively through FastMCP. The CI test job installedfastmcpunpinned, so it would have picked up the 4.0 stable release on its next scheduled run and failed without a commit to this repo; it is pinned to match. Themcpdependency is dropped: nothing insrc/imports it, and FastMCP already pins the SDK version it needs. The FastMCP 4 migration is tracked separately and will not ship until 4.0 leaves beta.zotero_synthesize_annotationsgroups its digest by item key instead of by title, so two distinct papers that share a title are no longer merged into one section. Markdown headings are qualified with the item key when a title collides.
Fixed
zotero_advanced_searchsorting works, and says so when it cannot. Sorting bydateordered results by month name: Zotero'sdata["date"]is a display string ("October 1, 2016"), and it was compared lexically, so October preceded March preceded January regardless of year. Date sorts now use the API's normalizedmeta.parsedDate, falling back to the first four-digit year in the display string for sparse records that carry noparsedDate. Separately, a sort field absent from every result — misspelled, or simply not present in that backend's item shape — sorted every key to the empty string, leaving results in library order while reporting nothing; the tool now says the sort was not applied and names the sortable fields instead of returning a silently unsorted list (#418).zotero_advanced_searchhonorscollectionconditions. The condition was advertised in the tool description but could never match: an item's membership is stored indata["collections"](a list of collection keys), and extraction read adata["collection"]that does not exist, so every collection condition compared the empty string against the requested key. Any query containing one returned "No items found", including queries whose other conditions matched hundreds of items. Membership is now read from the right field, an item filed in several collections matches on any of them, and an item in no collection correctly satisfiesisNot.collectionsis accepted as a spelling of the same field. Scoping is direct membership, matching Zotero's own "Collection is X" with subcollections not included. This was never mode-specific —advanced_searchfilters client-side on every backend, so the condition was equally broken against the web API (#418).zotero_update_itemwrites fields that a type stores under a renamed key. Several Zotero item types keep a base field under a type-specific key — a statute's title isnameOfAct, a case's iscaseName, a statute's date isdateEnacted— but the update path gated each field on whether the key was already present on the fetched item, using instance presence as a proxy for type validity. Settingtitleon a statute therefore did nothing and was reported as a skipped field, and the same held for every other base-field rename. Each generic parameter is now resolved to the type's actual field and validated against that type's declared field set, so a field that is valid but simply absent from the record is written rather than false-skipped (placeon a journal article andaccessDateon a book were both being refused this way). The mapping is a trimmed slice of Zotero's global/schemadocument vendored with the package, refreshed weekly in the background and on demand withzotero-mcp schema-refresh— the vendored copy is the floor, so resolution is correct offline and for local-only installs, and only item types Zotero adds after a release need the refresh. ThecitationKeyspecial case from #321 is retired: it is an ordinary schema field and now validates like one (#402). The background refresh backs off for a day after a failure rather than retrying on every startup, andZOTERO_MCP_SCHEMA_REFRESH=0declines the network call outright — the vendored table is always a correct floor, so the only cost is not picking up item types Zotero adds after a release (#402).- Syncing one library no longer deletes every other library's documents from the semantic index (#404). The incremental deletion pass diffed every stored id against
item_versions()of the active library, so the first incremental sync after multi-library indexing became possible treated all other libraries' documents as "no longer present in Zotero" and deleted them. Confirmed in the wild: a singleupdate-dbrun removed 833 documents, 738 of them live group-library items — essentially the entire group portion of a multi-library index. Deletion candidates are now fetched DB-side with awhere={"group_id": …}filter for the syncing library only, so another library's documents — and documents with no attribution at all — are structurally out of the pass's reach. Two failure modes that used to read as "the library is empty" now skip deletion instead of wiping:item_versions()raising (it previously degraded to an empty dict, turning a transient API failure into a full wipe of everything in scope) anditem_versions()succeeding with an empty body against a non-empty index. The library identity a run tags, watermarks and deletes under is pinned once per run from the run's own Zotero client, so a concurrentzotero_switch_librarycan no longer re-point half an update at another library. - The multi-library
group_idbackfill actually runs — it called methods that did not exist._backfill_group_ids()has invokedChromaClient.iter_metadatas()andupdate_metadatas()since #396, but only the test suite's fakes defined them; the real class never did, so every production backfill died withAttributeError, no pre-existing document was ever tagged, andzotero_semantic_search(library_id=…)silently matched nothing on migrated indexes. The real methods exist now (id-snapshot paging and batch-split updates, per the #368/#369 precedents), regression tests run against the realChromaClient, and a conformance test auto-discovers every Chroma-shaped fake in the suite and pins it to the real API so a fake-only method can never go green in CI again. The backfill's attribution also stopped guessing: a document is tagged only on positive evidence (the local database's key→group map, trashed items included so each library's own scoped pass cleans its trash, or membership in the active library'sitem_versions()), and documents with no evidence stay untagged — excluded from library-filtered search and, deliberately, from deletion. The failure log no longer recommends--force-rebuildas the repair: a rebuild re-embeds the whole collection and permanently drops any documents the current ingest paths cannot recreate. Users who already followed that advice have a freshly built, fully tagged index and need no action. - OpenAI Batch API imports no longer fail on large libraries.
ChromaClient.upsert_documentsalready split upserts into chunks ofclient.get_max_batch_size()to stay under ChromaDB's batch limit, butupsert_embeddings— the method the batch-import path uses to write precomputed vectors — upserted the whole payload in a single call, so any import larger than ChromaDB'smax_batch_size(~5461) failed outright (e.g. "Batch size of 6312 is greater than max batch size of 5461").upsert_embeddingsnow chunks the same way (#410).
Upgrading: the semantic index migrates to schema version 3 on your first zotero-mcp update-db after upgrading — a metadata-only backfill, no re-embedding. If you run a multi-library index, upgrade before your next sync: 0.6.4's deletion pass can delete every other library's documents.
Install: uv tool install zotero-mcp-server or pip install zotero-mcp-server