oscardvs/zoteus
Zotero library for your agent: search, PDF passages with page locators, CSL citations, add by DOI, group libraries, and safe writes.
Changelog
All notable changes to Zoteus are documented here. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[1.23.1] - 2026-10-31
Security
- Production dependencies updated for the October advisories.
npm auditreported 10 against 1.23.0 (1 critical, 7 high, 2 moderate); the non-breaking updates clear 8: the MCP SDK 1.29.0 to 1.32.2 (its floor inpackage.jsonmoves with it),body-parser,proxy-addr,qs,fast-uri,ip-address,honoand@hono/node-server. The two that remain need major versions and are planned with the move to MCP SDK v2:zod(GHSA-mrhx-c7xc-m3w5, memory use when validating very large arrays) andpdfjs-dist(GHSA-hq66-cqwq-w95j, which needs scripting in a browser page and does not apply to Zoteus, which runs pdf.js in Node withisEvalSupported: false). The criticalproxy-addradvisory needs an IPv4-mapped IPv6 trust subnet; Zoteus trusts one proxy hop by count, so it was not exposed.
Added
- Notes from Markdown:
zotero_create_note,zotero_append_to_note,zotero_replace_note. One tool per operation, so each carries the right hint: creating and appending are additive, replacing isdestructiveHint: trueand returns the old HTML aspreviousNote. Markdown (headings, nested lists, bold, italic, strikethrough, code and code blocks, links, blockquotes, tables, horizontal rules, inline and display math) becomes the HTML Zotero's note editor stores, with no new dependency; raw HTML is escaped and links are held to http(s), mailto, ftp and zotero:.[@ITEMKEY, p. 12]becomes the citation span Zotero's note editor writes, with the item's CSL-JSON stored on the note, so it opens the paper in Zotero and becomes a live field when the note is added to Word; a cited key that names nothing, or names an attachment, refuses the write. Appending keeps the note's own content and citations and re-applies itself to a fresh copy after a version conflict. Writes go to the desktop app first, likezotero_create_items. Seedocs/notes.md. - Annotation digests:
zotero_annotation_digest(read-only) andzotero_save_annotation_digest. The digest lists the annotations on one paper or on the top-level items of a collection (paged), grouped per paper: type, colour and its palette name, page label, the highlighted text verbatim, the comment and tags, in reading order, filterable by type, colour and tag. The save tool writes the same digest as a note laid out like Zotero's own "Add Note from Annotations": each highlight keeps the link that opens the PDF at that spot, each carries a citation with its page, and the text comes from the annotations themselves rather than through the model. A paper's digest is attached to the paper, a collection's is filed in the collection (up to 200 items, and the note says when it stopped).
Fixed
- The HTTP server closes idle MCP sessions, so its memory no longer grows until it
crashes. Every session kept its own MCP server in memory until the client closed it,
which clients almost never do: the hosted server logged 648 sessions and 2 closes in 4.3
days, and ran out of JavaScript heap (about 490 MB) four times between 21 and 30 October,
dropping every connected user each time. Measured locally, each abandoned session held
about 640 KB. A session is now closed after 30 minutes with no request in flight, and
past 100 open sessions the longest idle close first; one with a request in flight is
never closed under it. A closed session answers 404, which clients meet by reconnecting.
Only the HTTP transport (
--http) is affected; stdio has one session. - In HTTP mode, a promise rejection that belongs to no request is logged instead of ending the server. On 26 October pdf.js rejected one of its own promises over a damaged PDF while full text was being read, and Node's default ended the process, which in HTTP mode serves every client. Every pdf.js document is now also destroyed once it has been read, as the figure renderer already did, which ends any work pdf.js still had running for it.
- The server keeps answering while an index job writes large full-text bodies (#106,
thanks @Michael-Logies, who also wrote the fix). An update whose full-text catch-up re-read
a page of attachments, and a build's full-text pass, chunked and wrote every body of the
page in one synchronous stretch. With
ZOTEUS_INDEX_FULLTEXT_MAX_CHARS=0on a 285k-passage index that stretch lasted minutes, during which every tool call from every connected client timed out, andzotero_index action:"status"andaction:"stop"could not answer either, so a working job looked hung. Both loops now yield after each item.action:"status"says when an update is in its catch-up (reading Zotero's extraction census, or how many body texts of how many it has re-read), and every job reportsworstLoopStallMs, the longest stretch in which the server could answer nothing, with a warning in the log when it passes a second. - A Client ID Metadata Document may declare loopback redirects. With
ZOTEUS_CIMD_ENABLED=true, everyredirect_uriin a client's metadata document had to use a scheme fromZOTEUS_CIMD_ALLOWED_REDIRECT_SCHEMES(defaulthttps), so Claude Code, whose document declareshttp://localhost/callbackandhttp://127.0.0.1/callback, was refused as an unknown client as soon as it chose CIMD over Dynamic Client Registration. Plainhttptolocalhost,127.0.0.1or[::1]is now accepted whatever the scheme list says (RFC 8252), and the authorization endpoint matches it on any port. Every otherhttpredirect is still refused. CIMD stays off by default. - Collections in the Zotero trash no longer show up in collection listings.
api.zotero.org keeps a trashed collection in its ordinary
/collectionslisting, flaggeddata.deleted: true, and counts it inTotal-Results; the desktop app leaves it out. So a library read from the cloud listed collections the user had deleted, inzotero_list_collections,zotero_manage_collections action:"list"and thezotero://collectionsresource, and a lookup by name could find one. They are now dropped from every listing and fromtotalResults, after the crawl, so paging still counts them the way the API does. A subcollection whose own flag is missing but whose parent is in the trash is dropped with it. zotero_evidence_tableandzotero_word_documentdeclaredestructiveHint: true. Both takeoverwrite: true, which replaces a file that already exists, and the old contents are gone: not the "additive updates only" a false hint promises. OpenAI's plugin scan flagged both.zotero_attachment, which takes the same argument, already declared it. Nothing changes in what they do; withoutoverwriteneither ever replaces a file.
Changed
zotero_semantic_searchno longer starts an index build. On an empty index it used to start a background crawl of the whole library (auto_build, on by default), which wrote an index file, could create a new one for a named library, and spent embedding-API calls when an API embedder was configured, all from a tool annotatedreadOnlyHint: truethat clients run without asking. It now answers an empty index with an error naming thezotero_index action:"build"call for that library, and reports progress while a build runs, as before.auto_buildis still accepted, so no call is refused, and has no effect.zotero_pdf_imageswrites no files. On a local install it saved every figure under<data dir>/pdf-images/by default (and pages withsave: true), so a tool annotated read-only wrote to disk on every figures call. The images come back inline, as they always did.saveis still accepted and does nothing; withsave: truethe notice says no file was written. Both tools stay read-only, and stay available underZOTEUS_READ_ONLY, which keeps only read-only tools.
[1.23.0] - 2026-10-19
Fixed
- Collections, saved searches, tags,
zotero_create_items,zotero_update_itemandzotero_fulltext action:"set"write through the running Zotero desktop app, so they no longer need a cloud key for the personal library. On a desktop-only install (Zotero 10.0.6, noZOTERO_API_KEY), "create a subcollection in Thesis" was refused with "This operation writes to a cloud/group library and requires a cloud API key", while the same install imported items and attached PDFs through the desktop without complaint. Only trash, delete, annotate, attach and import had ever been routed to the desktop; every other write tool demanded the cloud, although Zotero 10's local API takes all of them. Measured on 10.0.6:POST /collections(top level and nested), keyed rename,DELETE /collections?collectionKey=,POST/DELETE /searches, item create,PATCHand versioned keyedPOSTon/items, andPUT /items/<key>/fulltextall succeed with the local grant. These tools now go to the desktop first for the personal library and to the cloud otherwise, in the order the other write tools already used, and reporttarget("local"or"cloud"). The versions they send are read from the same API the write goes to, since the desktop numbers versions independently of the cloud. If the desktop turns out not to take writes (Zotero 9, a grant that is gone) before anything was written, the whole operation reruns on the cloud; once a desktop write has landed it never does, because the two copies would then hold it twice after the next sync. A desktop that cannot find an item is no longer mistaken for one that cannot take writes: its "Local API 404" reads matched the same test as a missing write endpoint. Group libraries the app holds follow in the next entry. The full-text change also retires the premise of #79, that neither desktop write path had a full-text endpoint. - Group libraries the desktop app holds are written through it, with no cloud key. Every
write client addressed
/api/users/0, and the docs said group writes always go to the cloud, so a keyless install could read a group it held and write nothing to it. Zotero 10.0.6 takes writes on/api/groups/<id>/...exactly as on the personal library (checked in itsserver_localAPI.js, and measured: a collection, items, a tag edit, a PATCH, a merge, an attachment upload and download, full text, trash and a collection delete all landed in a synced group, key-free). Every write tool now goes to the desktop for a group in the app's own group list and to the cloud otherwise;LocalWriteClienttakes the library on every method and tracks each library's version separately, since a group's numbering has nothing to do with the personal library's. A group the app holds read-only is refused by name, saying the group's Library Editing (or File Editing) setting leaves this account out, instead of Zotero's bare "Write access denied", and is not retried on the cloud, which refuses the same account for the same reason. With no key and no desktop holding the group, the refusal now says which of the two is missing rather than claiming the desktop can only ever write the personal library. The connector protocol, which saves into whatever library the app has open, stays personal-only. - A library with an unsynced cloud write keeps its writes on the cloud until the desktop catches up. Reads of such a library were already held on the cloud until the desktop app was seen to hold that write; writes now follow the same rule. Otherwise a write sent to the desktop in that interval was invisible to the reads that came next, and acted on a copy missing the change the cloud took (an update to an item the desktop had never seen was a 404 there). This only arises when a write had to go to the cloud, for instance because the app was not running; desktop writes record nothing, since reads of that library already go to the app.
zotero_merge_itemsapplies merges through the desktop app. It refused to write without a key, claiming only the cloud takes a versioned PATCH, which has not been true since Zotero 10: the local API checksIf-Unmodified-Since-Versionagainst each item's ownclientVersionand pins a POST to the library's. A merge now runs on the desktop for any library it holds and on the cloud otherwise, reading and writing one copy: the preview, the plan'sversions, the master's PATCH, the 412 replan of the master, the child reparenting and the final trash POST pinned to the library version all come from the same API, reported asreadFromandtarget. The second-opinion veto now runs both ways: on the desktop, a child the cloud has and the desktop has not yet pulled keeps its duplicate out of the trash, as a child only the desktop had already did for a cloud merge. If the desktop turns out not to take writes (Zotero 9), the merge reruns on the cloud from the cloud's records before anything was written. Without a key, thedc:replacesrelation now names the account the desktop syncs as rather thanusers/0, which no Zotero resolves.zotero_attachmentuploads and downloads through the desktop app. Both went through the cloud only. An upload (fromurlorfile_path, standalone or underparent_item) now goes through the desktop's own three-phase upload for any library it holds, with the same fallback rule aszotero_attach_file: to the cloud only if nothing was created yet. A download reads the file the desktop holds on its own disk, routed like every other read, and falls back to Zotero file storage when the desktop cannot produce it (a file never downloaded to this computer), saying why. Results carrytarget(upload) andreadFrom(download).- A desktop answering 400 is no longer taken for a desktop that cannot write. Full text
for a
text/plainattachment is refused by Zotero 10.0.6 with "Full-text content is not supported for this item type", and the words "not supported" made Zoteus read that as a missing write path: a keyless caller got a refusal about Zotero versions and grants, and a keyed one would have been sent to the cloud. Any 400 now surfaces as Zotero's own reason. - A personal-library write with nowhere to go says why. The refusal called the personal library "a cloud/group library" and advised the desktop app in the same breath, including when the desktop app was running. It now says one of three true things: the desktop app is not reachable and there is no key; the desktop answered but refused the write (quoting why); or the operation itself is cloud-only.
zotero_create_itemsand theadd_items/remove_itemsand tag edits report a write that failed for every item as an error rather than a success, as every other write tool already did (#77). One item missing from a membership or tag edit is now a per-item failure instead of aborting the call.- A Zotero 9 desktop is recognised before a write, not after one. Every desktop-first
write now asks the running app whether it takes local-API writes at all (the same cheap
probe each write starts with, cached once it succeeds) before choosing it, so on Zotero 9
with a cloud key an operation runs on the cloud from start to finish. That matters most for
zotero_merge_items, whose preview has to read the copy its write will act on. Zotero's own answer to a write on a read-only endpoint, 400 "Endpoint does not support method" (501 "Method not implemented" for PATCH, PUT and DELETE), is also read as "no local writes" again rather than as a rejected request. zotero_merge_itemspreviews need no write access. A dry run whose library the desktop is not taking writes for, with no key or a read-only one, is a read-only preview again, with anotesaying this server cannot apply it, instead of being refused by the write gate. Its check for children the desktop holds and the cloud does not yet now asks the desktop itself, not the router, which answers from the cloud while a cloud write to that library waits for the app to sync.
[1.22.4] - 2026-10-01
Fixed
- Several Zoteus processes can share one search index safely. Every Claude Code session
and Claude Desktop runs its own Zoteus on the same data directory, and sharing one index
file did harm in three ways. Opening the index wrote to it, so while another process held
the write lock every tool in a newly started one,
zotero_whoamiincluded, failed with "database is locked". A job held that lock across its embedding requests: anaction:"update"is one transaction from its first row to its last, so a local embedder over a few thousand passages held it for minutes. And two jobs on one file undid each other: a build with no checkpoint to resume empties the store, so a process that auto-built from a semantic search wiped what another was building (557 of 1,967 passages ended without a vector in a measured run). Opening now writes nothing, a build commits before each embedding request, an update commits its delta first and embeds after it (so a failed update still rolls back whole), and one job per index file runs at a time: the others say which process holds it, read its rows as they commit, and start nothing. - Tag counts from the cloud are real, and
zotero_tag_auditreads every tag. api.zotero.org answers an unsorted tag listing withTotal-Results: 0and in no stable order.zotero_list_tagsreportedtotalResults: 0on every cloud-served library, andzotero_tag_audit, which pages until it has read that many tags, audited only the first 100. Tag listings now ask forsort=title, which makes the count true and the order match the desktop app's. &&in atagoritemTypefilter means AND, as documented. Neither Zotero API reads&&: both took "to-read && 2024" as one tag name and found nothing, and the cloud refused anitemTypewritten that way with HTTP 400. Zoteus now sends each part as a repeated parameter, which is the AND both APIs understand.- A top-level search filtered by tag no longer returns child notes on the desktop. The
desktop runs a tag filter as a sub-search that ignores
top, sotop:truewith a tag answered 34 items where 17 were child notes. That combination now takes the same route astopwith an item type (#79); the cloud, which answers it correctly, is unchanged. - Trashed items are found through a tag, item type or key filter on the desktop. With
includeTrashed, Zotero's local API evaluatestag,itemTypeanditemKeyin a sub-search that never sees the trash, sotag:"x", includeTrashed:trueanswered 0 where the cloud answered 55. The desktop path now assembles that answer from reads Zotero gets right.topwith anitemTypefilter also kept trashed matches off the page they were counted for, on both APIs. - ISBNs, PMIDs and ADS bibcodes are recognised as people write them. A hyphenated
ISBN-13, "PMID: 31452104", a real bibcode such as
2019ApJ...882L..24Aand a DOI written as "doi:10..." used to fail with "Could not parse as a known identifier". They are now read, and with no translation-server the error names what the identifier is, that resolving it needs a translation-server, and what works instead (a hosted server says only its operator can attach one). A translation-server that could not resolve a DOI no longer stops the built-in DOI resolver from trying. - A bibliography entry the parser could read nothing from is skipped, not imported empty.
A malformed BibTeX entry (an unclosed brace, nothing after the key), an RIS record with
only
TY, or a CSL-JSON item with only a type became an empty item in the preview, ready to be saved as a blank row. It is now listed underskippedwith the reason, on the translation-server path as well as Zoteus's own parsers. - A BibTeX entry missing the comma after its key is read as meant. The first field used to be swallowed into the key and lost. The entry is now read as if the comma were there, with a warning quoting the line to fix. When a translation-server leaves entries empty that Zoteus's own parser recovers, Zoteus's reading of the file is used and the result says so.
by_urlexplains a failed scrape. With a translation-server running, a page it could not read returned the bare "translation-server /web returned 501.". The error now names the page, what the server answered, and what works instead: the identifier inside a DOI or arXiv link, or saving the page with the Zotero Connector.- Long citation style names resolve. "Chicago Manual of Style 17th edition
(author-date)", "American Psychological Association 7th edition" and the other titles
Zotero lists styles under now resolve, and a past edition the CSL repository still
carries resolves to that edition's own style rather than the current one. "Vancouver" now
names
nlm-citation-sequencedirectly, since the CSL repository renamedvancouver. zotero_groupssays whatnumItemscounts. It is every item row a group holds, children and the trash included, so it is not comparable with a search'stotalResults. Every answer that carries a count now says so innumItemsNote, with how to count top-level items instead.- A running index job's item count reads as progress.
zotero_whoamiand the update progress line reported a half-finished job's item count as the size of the index. They now say a job is running, an update says how many changed items it is working through, and passages still waiting for a vector are explained while the job runs.
[1.22.3] - 2026-09-30
Fixed
- A large library no longer hides a running Zotero (#102). The liveness probe asked the
desktop app for
/users/0/items?limit=1, and Zotero answers that by searching, loading and sorting the whole library before it slices off one item, so the request cost the size of the library whatever the limit said. On a library of 166,000 items it took longer than the probe's 1.5 s budget on every attempt,zotero_whoamireportedlocalApi: falsefor the life of the process, and every read went to the cloud Web API, where a library-wide full-text search then timed out. The probe now asks for/users/0/collections?limit=1, which the same endpoint class answers in milliseconds whatever the library holds, and Zotero 10 desktop writes read their server id and library version off the same request. zotero_whoamisays why the local API is unavailable. The answer carries the latest probe's outcome (localApiProbe: nothing listening on the port, no answer within the budget, or the HTTP status Zotero answered with), and the remedy is chosen by it: a 403 is Zotero running with "Allow other applications on this computer to communicate with Zotero" switched off, a timeout names the budget and where Zotero's debug output shows its own timing, a refused connection names the port andZOTERO_LOCAL_PORT. The startupCapabilities:log line carries the same reason besidelocalApi=false.- The search index's write-ahead log gives its disk space back (#98). SQLite never
shrinks a
-walfile on its own: after one large transaction, such as a schema migration or a big build, it kept that size through every later checkpoint and restart. One full-text library carried a 1.08 GB log beside its 3.6 GB index for three weeks. The index now caps the log at 64 MB whenever SQLite restarts it, and truncates it to zero when it opens and when it closes. The truncation never waits: while another process sharing the data directory is mid-read, it is skipped and the next open or close reclaims the space.
[1.22.2] - 2026-09-27
Fixed
npx @oscardvs/zoteusno longer installs the test and build tools. 1.22.1 shipped the whole lockfile asnpm-shrinkwrap.json, and npm installs every entry a dependency's shrinkwrap lists, so the first launch fetched vitest, typescript, esbuild and eslint with the server: 278 packages and 228 MB. The shipped shrinkwrap now leaves the dev-only entries out: 99 packages and 132 MB on Linux, every one at the version this release was tested with.
[1.22.1] - 2026-09-27
Changed
- The npm package ships its lockfile.
@oscardvs/zoteusnow carriesnpm-shrinkwrap.json, sonpx @oscardvs/zoteus@X.Y.Z(how the Claude plugin starts the server) installs exactly the dependency tree this release was tested with instead of whatever the version ranges resolve to on the day. The Claude directory holds a plugin that runs a pinned npx package without one. It is written frompackage-lock.jsonwhile npm packs and removed afterwards, so the repository keeps a single lockfile. - The Claude plugin has an icon: the zoteus.com mark, as
.claude-plugin/icon.svg.
[1.22.0] - 2026-09-27
Added
- A Claude plugin, ready for the Claude directory.
plugins/zoteuspackages the server with four skills:grounded-answers,evidence-table,citationsandlibrary-upkeep. Together they teach Claude the workflows the MCP prompts already describe: quote the retrieved passage with its page, render comparisons throughzotero_evidence_table, take every reference from a tool, and confirm before writing. The server runs asnpx -y @oscardvs/zoteus@<this version>. The Zotero API key is an optional, masked setting, so the plugin still starts, key-free through the desktop app, where no key is given. This is the directory's replacement for desktop-extension listings, which it no longer accepts. The repository is also a plugin marketplace, so Claude Code users can install it now with/plugin marketplace add oscardvs/zoteusand/plugin install zoteus@zoteus.tests/claude-plugin.test.tsfails a release that leaves the plugin's version or npx pin behind the package, and a skill that names a tool the server does not register. Submission steps for the plugin and for the hosted connector are indocs/distribution.md§8.
Fixed
- One account connected from two clients no longer disconnects one of them. Every OAuth
authorization mints its own Zotero key, so an account connected from Claude and from
ChatGPT presents two keys. The hosted server kept one context per account and treated the
second key as a replacement: it retired the first key's context and refused that key from
then on, so connecting one client made the other answer "Zotero authorization changed or
the session expired. Reconnect to start a new session." on every call, and reconnecting it
did the same to the first. An account now holds a context per key (up to 8), each with its
own clients and capabilities, so each client keeps the permissions granted on its own
consent page, and all of them share the account's search indexes, so the same index files
are never opened twice. The cache bound (50) still counts accounts, and evicting one still
closes its indexes; a key dropped past the per-account bound is rebuilt on its next call
rather than refused.
A cached key is asked about again at zotero.org after 10 minutes, so a key Zotero has
revoked stops being served within that time even by tools that never reach zotero.org
(
zotero_semantic_searchreads the local index). Only a refusal from Zotero retires a key; while zotero.org is not answering, the cached key keeps working. - A library with more collections than one page is listed whole (#90).
zotero_list_collectionsandzotero_manage_collections action:"list"returned the first page Zotero served (25 on the cloud) with no count and no way to ask for more, so a library of 1,460 collections looked like a library of 25, and a lookup by name created a duplicate of a collection that was on page two. Both now read every page, sort by name, and takeq(a name filter over the whole library),startandlimit; they returntotalResultsand say "Showing 1-200 of 1460" when there is more. Rename and reparent found their collection by scanning one page of 100, so most keys in a large library were "not found"; they, andzotero_import'scollection_keyon the desktop route, now ask Zotero for the collection by key. Thezotero://collectionsresource lists every page too. - A DOI imported without translation-server keeps its volume, issue, pages, full date and
ISSN (#89). The built-in fallback read them from OpenAlex (or Crossref) and dropped all
but the year and the journal. Each field is now written only where the item type has it,
and the venue goes to the type's own field:
proceedingsTitlefor a conference paper,bookTitlefor a chapter,repositoryfor a preprint, wherepublicationTitleused to be sent to all of them.zotero_scholar action:"lookup"returns the same details aswork.biblio.
[1.21.0] - 2026-09-21
Added
- Several library indexes in one data directory, and search across them. A data directory
now holds one search index file per library instead of one in total: the personal library
keeps its existing file and every other library gets a sibling beside it, so indexing a
group no longer means a second
ZOTEUS_DATA_DIRand no longer risks the first library's rows.zotero_indexgained an action that lists the libraries that have an index here, and its build, update and status actions route to the named library's own file.zotero_semantic_searchgained alibrariesargument that searches several at once and labels every hit with the library it came from. Merging is by RANK, not by score: each index scores against its own library's statistics, so scores from two libraries are not one scale, and the answer says so rather than implying a comparability it does not have. Open indexes are bounded byZOTEUS_INDEX_MAX_OPEN(default 4), which bounds memory and file handles, not how many libraries may be indexed. Upgrading a group-default install: an index built by 1.20.x or earlier withZOTERO_LIBRARY_TYPE=groupholds the group's rows under the personal library's stamp, and this release cannot tell that file from a genuine personal-library index. A plainzotero_indexbuild or update there is therefore refused, naming both ways out (delete the index file and build again, which restamps it, or name the group explicitly to build a second index beside it), rather than silently crawling and embedding the group a second time. - Open-access PDF discovery and attachment.
zotero_scholar action:"lookup"reports an open-access copy when OpenAlex knows one, andzotero_attach_filecan find and attach it from the item's DOI. The version is recorded and surfaced, because a green open-access author manuscript is not the published record and attaching one as though it were is the failure this was designed against. Bytes are validated before anything is written, so an HTML paywall page cannot be stored as a PDF.ZOTEUS_OA_FETCH=falsekeeps discovery reporting the link it found while refusing to download from an arbitrary host. - Duplicate-aware import and a previewable merge.
zotero_importcan check the library for an existing copy before saving, matching on normalised DOI, ISBN and title, and says how far it looked when the census stopped at its cap rather than reporting a clean result it did not earn. The newzotero_merge_itemsfolds duplicates into a master item: it PREVIEWS BY DEFAULT, fills only fields the master is missing, unions tags, collections and relations, reparents child notes and attachments, and trashes the emptied duplicates (recoverable). The preview reads the same records the write will act on and reports theirversions; pass them back asexpect_versionsand the write runs only against those exact records, so the plan that is approved is the plan that runs. - An evidence-table workflow. A
zotero-evidence-tableprompt walks retrieval (resolve studies, retrieve one passage each, classify coverage from what the tool actually returned) andzotero_evidence_tablerenders the gathered rows as Markdown or CSV deterministically, so a quotation cannot drift from the passage it cites. Coverage counts are computed rather than claimed, and a row that contradicts its own evidence is named in a warning: a locator with no quotation, a claimed passage with no quotation, a quotation on a source marked unavailable, or any verdict other than "unverified" on a row with no retrieved passage. In CSV, a cell a spreadsheet would run as a formula is written with a leading apostrophe and named inwarnings. The tool can write a file (save_path), so it is not marked read-only. - Word documents with live Zotero citation fields.
zotero_word_documentwrites a .docx whose citations are Word field codes carrying CSL data, not plain text. See the note under Known limitations: that the fields REFRESH in Word has not been verified by anyone here. - Direct bibliographic import and PDF metadata recovery.
zotero_importaccepts BibTeX, RIS and CSL-JSON as text or a local file, parsed in-repo so it works offline and on the hosted deployment rather than requiring a translation-server, and can recover an identifier from a local PDF's first pages and resolve it. Nothing is saved unless asked, so an import is a free preview of what would be created.ZOTEUS_IMPORT_MAX_ENTRIES(default 200) caps one file. - OCR for scanned PDFs.
zotero_get_fulltextdetects a PDF with no text layer and says so precisely instead of calling it "scanned or corrupt", and a PDF whose text layer covers only some pages (an OCR'd cover page, a "Scanned by" stamp) names the pages that lack one instead of passing as a complete extraction. Withocr:trueit reads the pages that have no text layer through an OCR engine, merges them with the text layer of the rest, preserves page locators, and says which pages are the publisher's text and which are a machine reading of a picture (fulltextSource"ocr" or "pdf+ocr", plusocrPages). A page is given up on after 60 s and the worker stopped, so a hung engine cannot block every later OCR call in the process. The engine is an optional runtime dependency this package does not ship, resolved the way the embedding runtime is:ZOTEUS_OCR,ZOTEUS_OCR_PATH,ZOTEUS_OCR_MAX_PAGESandZOTEUS_OCR_LANGS. - An Ollama embedding provider.
ZOTEUS_EMBEDDINGS=ollamaembeds through a local Ollama daemon (ZOTEUS_OLLAMA_URL), so semantic search needs neither an API key nor a@huggingface/transformersinstall and the text stays on the machine. The default model isall-minilm;nomic-embed-textexpects task prefixes Zoteus does not send and retrieves worse without them, so selecting it is warned about rather than left to fail silently. A refusal from the daemon is reported in its own words (a generation model on/api/embed, say), not as a bare status code. - Retraction and correction notices.
zotero_scholarreports update notices for a DOI from Crossref (which carries the Retraction Watch data) and OpenAlex. It reports RECORDS, never a verdict: there is no "not retracted" answer, a source that did not respond is reported as a coverage gap rather than folded into "nothing found", and a disagreement between the two is surfaced. - Lab visibility.
zotero_whoamisays whether the caller is on their own tenant context or sharing the operator's, and which library is the default and why.zotero_groupssays, per group, whether this key can actually write to it and why not when it cannot, and whether that group has a search index. - A command line.
zoteus --versionandzoteus --helpprint and exit instead of starting a server, andzoteus index build|statusruns a headless index build, which the docs have long recommended and which had no command (zoteus index <action> --helpprints the usage too). A bare invocation, the HTTP flags and an unrecognised flag all behave exactly as before.
Changed
- Per-user context caching on a hosted server. Evicting a context now closes its search
indexes (checkpointing the write-ahead log) instead of leaving the handles open; eviction
skips a context with a build running or a search in flight, and at shutdown running builds
are cancelled and given up to five seconds to commit before their stores close. A session
resolves its context on every call rather than binding it at
initialize, so an account whose context was evicted gets a fresh one on its next call instead of every later call failing until the connector is reconnected by hand. A key the account has since replaced is refused with that "reconnect" answer rather than rebuilding the old context. - A per-user context whose Zotero key zotero.org did not confirm for that account is refused at connect, and tried again on the next call, rather than built without cloud access.
- An MCP session is bound to the account, client and Zotero credential that opened it. A request presenting a known session id under a different credential is answered 404, which makes the client start a session of its own, instead of being served the existing one.
zotero_index action:"libraries"lists an index whose library this key can no longer read, with the reason, instead of omitting it;action:"pause"on a library with no index answers without creating one.
Fixed
zotero_pdf_imagesworks on Windows again (#84). pdf.js accepts an asset directory only if the string ends with a forward slash; Zoteus built the directories with the platform separator, so on Windows every call failed insidegetDocumentwithInvalid factory url: "...\cmaps\" must include trailing slashbefore a byte of the file was parsed. The directories now use forward slashes and end in/on every platform. That failure is also no longer reported as "the file is not a readable PDF": a rejected asset directory is a Zoteus setup fault and is reported as one, with a request to file an issue.- The server no longer dies at boot when Node's bundled undici asserts on the desktop app
closing a connection (#85). On Node 24.20.0 (undici 7.29.0) the local-API capability probe
ended the process with an uncatchable
assert(!this.paused)thrown from a socket event handler: Zotero closes the connection right after its response, and undici's HTTP/1 parser, paused under body backpressure, asserted when the FIN arrived (nodejs/undici#5360). Every client that speaks to the desktop app (the local API reads and their liveness probe, local-API and connector writes, Better BibTeX) now goes over a smallnode:httptransport that keeps fetch's contract for what those clients use:Connection: close, no redirect following, streamed bodies, andTypeError('fetch failed')with the original error ascauseon a connection failure. The shared fetcher keeps its concurrency cap and time budgets; the cloud Web API, OpenAlex, Crossref and open-access downloads are unchanged. On a Node whose undici is known to carry the assertion, startup logs one warning naming what is routed around it, what is not, and the Node to upgrade to (24.21.0, which bundles undici 7.29.1). zotero_importwithsave_to_librarysaves through the connector protocol when the desktop app's local API rejects every item, instead of returning "Nothing succeeded" (#88). Zotero 10.0.3 answers every create onPOST /api/users/0/itemswith a per-item 400,'primaryData' not loaded for item (null/1/<key>), while updates and deletes on the same endpoint and the connector protocol'ssaveItemsstill work. The endpoint answers 200 with per-item failures, so the "local writes unavailable" check rightly did not fire and the documented connector fallback never ran. The same items are now sent through the connector protocol, with the existingcollection_keyandattach_urlhandling, and the result says so:targetis"desktop", a newlocalApiRejectedfield carries the local API's rejections, and the summary names the rejection and quotes its first message. A partial success is left alone, since re-sending would duplicate the items that did save; without the connector protocol (older Zotero) the result is unchanged; and if the connector save fails too, both refusals are reported.- A group-default install could lose its index. With
ZOTERO_LIBRARY_TYPE=group, a build crawled the group but stamped the index as the personal library. Naming that same group afterwards was then falsely refused, and an explicitlibrary_type:"user"build passed the guard and ERASED the group's rows. The stamp now names the library actually crawled. zotero_attachmentandzotero_tag_auditconfined a caller-supplied path to the whole data directory, which on a multi-tenant deployment is shared: a caller naming another tenant's path had those bytes copied into their own library throughzotero_attachment, andzotero_tag_audit vocabulary_pathanswered with three distinguishable errors over that directory, an existence oracle on every other tenant's files and a reflection of the OAuth store's key names. Every caller-supplied path, read or write, is now confined to the caller's own subtree, and a remote caller with no identity of its own (passcode mode) gets atenants/sharedsubtree rather than the bare data directory.- A
Backoffheader from any host stalled every subsequent request in the process and reported it as Zotero throttling. Back-off is now tracked per origin, so a repository can only slow down traffic to itself. - A bibliographic file over about a megabyte froze the server for tens of minutes in a quadratic format-sniffing regex, and a single entry with many fields or a whitespace-padded creator field did the same in the BibTeX value reader and the name splitter: every braced or quoted value scanned forward to the next entry on its own (40,000 fields took 3.5 s, and the 2 MB cap allowed minutes), and the name splitter backtracked quadratically on runs of spaces (200,000 spaces took 55 s). All three are linear now; the same inputs take milliseconds.
- One unbalanced brace in a BibTeX file silently discarded every entry after it while still
reporting a confident count. Unparseable input is now reported rather than dropped, in the
text summary as well as in
warningsandskipped; the summary also says when a library item already matched and the save went ahead onallow_duplicate. - A
.bibor.risfile that is not UTF-8 (Latin-1 or Windows-1252, as older reference managers write) imported "Schr\uFFFDdinger" with no warning. Such a file is now read as Windows-1252 and the answer says so; a UTF-16 byte-order mark is honoured. - A title-only duplicate match with no year on either side needs a title of at least four words or a shared creator surname, and says which, so a year-less "Introduction" no longer refuses the save against every "Introduction" in the library.
path: "~/Downloads/x.bib", as the docs showed, now works on a local install; a remote caller's~is still refused.- A DOI containing a fragment character truncated the Crossref batch request and voided the retraction check for the rest of that batch, which then reported clean.
- An open-access download had no bound on the response body, so a slow host could hang the call indefinitely.
zotero_attach_file urlandzotero_import attach_urlon a hosted deployment are now held to the same bounds as an open-access download: https only, public hosts only, every redirect hop re-checked and pinned to a vetted address, a 64 MB cap enforced while streaming and a clock on the body. A tenant could previously pointurlathttp://169.254.169.254/...orhttp://127.0.0.1:<port>/...and have the bytes stored in their library. A local, stdio Zoteus keeps fetchingurlfrom wherever its user points it.ZOTEUS_OA_FETCHwas never an all-egress switch, and the docs now say so.- Open-access downloads try every address the host resolves to rather than only the first, so
a dual-stack host or one dead A record no longer fails the download, and a connection that
fails on every address is reported as a sentence naming the host and the error code instead
of a bare
ENETUNREACHorCERT_*. - The pinned HTTPS transport no longer pushes an already-buffered body into a stream the caller
has cancelled, which threw
Invalid state: Controller is already closedoutside any handler. zotero_scholar action:"notices"no longer ends with "no update record at either source" when one source had no record of the DOI; it names which source had nothing to check.zotero_word_documentwrote into a shared directory readable by other tenants, and the evidence-table CSV did not neutralise spreadsheet formula injection in quotations taken from PDFs.
Known limitations
- Nothing here has verified that Microsoft Word with the Zotero plugin actually REFRESHES the
citation fields
zotero_word_documentwrites. The file is a valid .docx and the field codes are structurally correct and tested as such, but neither Word nor the plugin is present on the machine this was built on. Every user-visible string says so. - OCR text is not added to the search index. It is returned with page locators by
zotero_get_fulltext; making it searchable needs an index schema change that was deliberately not made here. - Lab administration covers visibility only. Invitations, seats, licence assignment and a billing owner are not implemented and are not blocked on code.
[1.20.3] - 2026-09-17
Fixed
- Eighteen tools left
destructiveHintundeclared, which fails an MCP tool scan that requires all three behaviour hints. Every read-only tool shipped{ readOnlyHint: true, openWorldHint: true }and said nothing about whether it destroys anything, so a client or directory that insists each tool "must set readOnlyHint, openWorldHint, destructiveHint to true or false" rejected the server outright rather than reading the omission as a no. The affected tools werezotero_whoami,zotero_search_items,zotero_get_item,zotero_schema,zotero_groups,zotero_export,zotero_get_fulltext,zotero_pdf_images,zotero_sync,zotero_styles,zotero_format_bibliography,zotero_bibliography,zotero_semantic_search,zotero_scholar,search_tools,zotero_list_tags,zotero_list_collectionsandzotero_tag_audit. All of them now declaredestructiveHint: falseexplicitly. Nothing about their behaviour changes: they were already non-destructive, and the write tools that genuinely are destructive already said so. A test now asserts that every tool declares all three hints, and that no read-only tool is marked destructive, so a new tool cannot reintroduce the gap.
[1.20.2] - 2026-09-16
Fixed
- Every tool was rejected before it could run in any client that validates tool schemas
against JSON Schema 2020-12 (#83). Reported against 1.20.0 and confirmed on 1.20.1, on
Windows and Linux: the server connected, all tools were listed, and then every call came
back with "has an invalid outputSchema: JSON Schema declares an unsupported dialect
(
$schema:http://json-schema.org/draft-07/schema#)". Nothing reached Zotero and no Zoteus log line recorded the call, because the refusal happened in the client, at tool definition time. A tool handsregisterToola Zod schema and the SDK converts it for the wire itself; on the Zod 3 path that conversion stamps$schema: draft-07on what it produces, and there is no option to turn that off. The stamp had ridden along oninputSchemafor as long as this server has existed and no client had minded; 1.20.0 advertised anoutputSchemafor the first time, and an output schema is what this class of client compiles up front. Zoteus now sends tool schemas that declare no dialect at all, input and output alike. Dropping the declaration rather than rewriting it to 2020-12 is deliberate: measured against both validators with meta-schema checking on, a draft-07 stamp is refused by an Ajv 2020 client, a 2020-12 stamp is refused by a draft-07 one (which is what the MCP SDK's own client builds by default), and only a schema that declares nothing compiles under both. The schemas themselves are unchanged, every tool still advertises its output schema, and the regression test reads what a real client is actually sent rather than what the Zod object says, which is the gap that let this ship.
[1.20.1] - 2026-09-14
Changed
- Literature-review and citation-audit prompts retrieve source passages before judging claims, report available locators and evidence status, and distinguish abstract-only summaries from verified passages.
- Setup and configuration guidance explains embedding dependencies, PDF indexing defaults, search coverage, privacy data paths and the single-library index limit.
- Added first-research-task, search-readiness, missing-PDF and annotations-to-literature-notes guides, with current platform download guidance.
[1.20.0] - 2026-09-12
Changed
-
Every tool now advertises what it takes and what it gives back: parameter descriptions on all 31 tools, and an
outputSchemafor each of them. Smithery's quality breakdown scored this server 4 of 31 for complete parameter descriptions and 0 of 31 for output schemas, and both numbers are read offtools/listrather than off the prose, so an argument carrying nodescriptionis invisible to a directory listing (and to a model choosing arguments) however well the tool paragraph explains it. Eighty-five properties had none. The largest share of them werelibrary_typeandlibrary_id, spelled out again in twenty-five tool files with nothing said about either: the two arguments most likely to send a write into the wrong library were the two the schema described least. They now come from one shared definition, which says what each means, that a group is addressed by its numeric id, and thatlibrary_type: "group"on its own is refused.The output schemas describe the JSON mirror every tool already returns beside its summary line, so
structuredContentis declared rather than merely present. They are precise about the stable top-level fields and deliberately open underneath (additionalProperties: true, with Zotero's own records left as open objects), because an item's fields vary by item type and a handler may add a notice. The MCP SDK validatesstructuredContentagainst the advertised schema on every successful call, so a mismatch is a broken tool rather than a documentation slip: the test suite now puts every tool result it produces through that same check, which is what covers the write paths that must not be run against a real library. One handler had to change for it,zotero_fulltext action:"get", whose found branch had dropped theitem_keyits not-found branch carried.
Fixed
-
An older read's desktop catch-up check no longer clears the pending marker for a newer cloud write in the same library. Baseline probes also belong to the exact write that started them, so two writes to the same key cannot inherit one another's baseline.
-
Reads after a cloud update stay on the cloud while the desktop baseline probe is still pending or has failed. An existing desktop item no longer counts as a synced update merely because its key is present. If the baseline fails, the conservative override lasts until a later write replaces the witness or the process ends.
-
top: truereturned items that have a parent, so "only top-level items" was not true of either API (#79). Reported against 1.18.0 forzotero_search_itemswithitemType: "attachment", top: true: six of the first ten results had aparentItem, the same six every time, which left counting standalone attachments impossible without verifying every key one at a time. Measured on 2026-09-12 against a 1302-item library held by both a Zotero 10 desktop and the cloud, and the two APIs turn out to be wrong in two different ways. The desktop local API drops the top-level restriction the moment anitemTypefilter is present:/items/top?itemType=attachmentansweredTotal-Results: 363with every item of the first page carrying aparentItem, byte for byte the same answer as/items?itemType=attachment.itemType=annotationsettles what that is, since an annotation is never top-level and/items/topreported all 543 of them regardless. The cloud Web API keeps its promise aboutparentItemand breaks the other one: it maps every matching child up to its top-level parent, so the same request came back as 263 preprints, books and conference papers, not one of them an attachment. The true answer for that library is zero standalone attachments, and that is now what both backends give.Zoteus no longer asks either API to apply
topalongside anitemTypefilter, and works it out instead: the keys matching the filter, the library's top-level keys, and the intersection of the two, which is the exact result set in the caller's ownsortorder.totalResultsis that set's size, not the inflated count the API reports, so the number the tool prints and the pages it hands out come from one and the same list and paging stays coherent (walking a 66-item result two pages at a time returns the same 66 keys in the same order as one call for all of them). Only the page the caller asked for is read as items, by key, which is what keeps this affordable: a whole key set costs a fraction of the same items as JSON, 7 ms against 4.4 s for that library's 363 attachments, because the desktop resolves a storage path per attachment. Both APIs are correct abouttopwhen noitemTypeis in play, so that path is untouched, and the plain top-level listings the search index andzotero_tag_auditpage through behave exactly as before. Two numbers that were quietly wrong are now right as a side effect:itemType: "-attachment"withtop: truereported 396 items where the library has 320 top-level items, anditemType: "annotation"withtop: truereported 543 where the answer is none. -
The attachment map survives a slow or failing request, and no longer asks Zotero for a deep offset at all (#78). The map that attaches extracted body text to the item it belongs to used to be built by paging
itemType=attachmentthrough the whole library, a hundred rows at a time, and a single page that took longer than the per-request budget ended the entire map. On the reporting library (10,733 items, 8,957 attachments with extracted text) that happened reproducibly at the same offsets, at 1,414 and then 1,696 of 8,957, so roughly four fifths of the library's body text was never mapped and, before 1.18.0, a census-wide full-text cursor was stamped over the gap. Zotero 10.0.2 removed the concurrency latency amplification behind it, and the aborts continued: with nothing else touching the API, one deep page near the tail still occasionally exceeds the budget on its own, which is what a deep offset costs when the app has to walk the library to reach it.Two changes, and both were what the reporter asked for. A batch is now retried before it is given up on: three attempts with a short backoff, and if it still does not answer, the map records the keys it could not resolve and carries on with the next batch rather than stopping. And the map is now driven by the
/fulltext?since=0census it already fetches, resolved initemKey=batches of at most 50 (the cap both Zotero APIs enforce), so no request depends on an offset and nothing pages the attachment listing. Those batches keep theitemType=attachmentfilter the annotation lookups learned the hard way: the desktop API answers a keyed lookup with the named items AND every descendant they have, so without it a batch of fifty annotated attachments comes back as thousands of annotation rows and the attachments fall off the end of the page. Rows are also folded in once, which the page crawl could not guarantee: Zotero lists attachments newest-modified first, so an attachment edited mid-crawl could be served on two pages and have its body text concatenated under its item twice.The honest trade: on a 9,000-attachment library this is about 180 keyed requests where the crawl was 90 pages, so it is more requests, not fewer. The claim being tested is that a small keyed lookup, answered out of Zotero's own index, survives where a
start=8000page does not, and that one batch failing now costs one batch instead of the whole map. On the opposite library shape, a few hundred extracted attachments inside a large library, it is a clear win in both directions: eight requests instead of a walk of the entire attachment listing.The guarantee from 1.18.0 is unchanged and is what decides
incomplete: a full-text cursor is never stamped over a map that does not cover the library. The map is incomplete if, and only if, some batch of keys never got an answer at all after its retries, or nothing at all resolved. An answer that simply does not name a key it was given is Zotero saying it does not serve that attachment in this library view (a trashed attachment, on the desktop API), which is exactly what the old crawl concluded when it walked the whole listing and the key never appeared in it; treating that as incomplete would freeze the cursor forever on any library holding one. A map that resolves nothing while Zotero says the library has extracted text is the one answer never taken at face value: it refuses every read, so an update keeps the body passages it already holds. The 500-page ceiling, the deep-offset pagination and the "Zotero stopped serving the listing at N of M" case are all gone with the crawl that produced them.
[1.19.0] - 2026-09-12
Added
-
ChatGPT is a supported client, documented and verified against the hosted connector. Tested on 2026-09-12 from a ChatGPT Plus account in the web app:
zotero_whoami, keyword and semantic search, full item records, PDF outlines with page numbers and exact page ranges read from Zotero cloud storage, APA bibliographies, and adding and removing tags all worked, with ChatGPT asking before the writes it rates as risky and never before a read. ChatGPT connects to remote MCP servers only, so thenpxand.mcpbinstalls do not apply there; the two paths are the hosted connector atmcp.zoteus.comand a self-hosted OAuth remote, which serves ChatGPT unchanged (it registers a confidential client through Dynamic Client Registration and uses a per-connectorchatgpt.comredirect URI, so there is nothing to allowlist). The README,getting-started.md,remote-oauth.md,configuration.mdanddeployment.mdnow carry the ChatGPT steps: Developer mode on a paid plan, Plugins, Create app, OAuth, and the Refresh click that makes the tool list appear the first time. -
zotero_pdf_images: PDF pages and figures as images the model can look at. Every PDF feature returned extracted text, and text is exactly what a figure, a table, an equation and a scanned page lose: a figure arrived as its caption, a table as its numbers run together, an equation as a few stray glyphs, and a scan with no text layer as "extraction yielded nothing". A prospective customer asked whether the assistant could examine figures, tables, equations and scanned pages, and until now the honest answer was no.mode:"pages"renders whole pages and returns them as MCPimagecontent blocks between the summary line and the JSON mirror (the strict-args registry and the transports pass image blocks through untouched;ToolHandlerResult.contentis widened to say so). The default resolution fits the long edge to 1568 px, which is as much as the model is shown anyway; measured on a letter page (1212x1568, 143 dpi, a 196 KB JPEG rendered in about 250 ms) it keeps 9-point body text and inline maths legible, anddpiup to 300 andformatjpeg or png override it.mode:"figures"walks each page's operator list for image XObjects, inline images and stencil masks, tracking the transform the way pdfjs's own canvas does, decodes each to opaque RGBA (a translucent figure is flattened onto white rather than black), skips icons and rules under 32 px, folds an image repeated across pages into one, and reports each with its pixel size, its box on the page in points from the top left, and whether it covers the page (a scan). Measured against the test library: page 3 of "Attention Is All You Need" gives back its architecture figure as the 1520x2239 image it is embedded as, with a 2000 px preview inline and the full file on disk, and page 4 its two 445x884 and 835x1282 figures with their positions. On a local install figures are saved under<data dir>/pdf-images/<attachment key>/; a shared server refusessave, since the files would sit on the operator's disk. Vector figures are named for what they are, lines in the content stream that only rendering shows, and so is text scanned letter by letter: a 2006 conference paper in the test library paints 4389 stencil masks on its first page, one per glyph, with no text layer at all, and figures mode now reports that instead of returning the alphabet, while pages mode renders the page legibly.The caps are part of the feature, because the hosted tier runs on a 1 GB machine and the images travel as base64 inside JSON: 4 pages a call (8 at most, with the notice naming the next span), 16 figures (40 at most), a 3508 px long edge, an image pixel limit pdfjs enforces while decoding (16 megapixels on a shared server, 40 locally), one job at a time per process, files above 20 MB not parsed, and about 5 MB of inline image data per response, beyond which pages and figures are still rendered (and saved when asked) but not returned inline, with a notice saying which and how to ask for them. A password-protected PDF is refused with a message that says so; one whose encryption only carries an owner password opens normally; a truncated file is called a broken PDF; an EPUB is told it has no pages; a missing canvas package is named with its one-line fix.
No new dependency ships. pdfjs-dist draws under Node through its own optional dependency
@napi-rs/canvas, which a plainnpm installbrings in with it and which the desktop bundles already carry for every OS and CPU they name. What did need building was a canvas pool. Measured here,@napi-rs/canvas0.1.100 does not return a dropped canvas's memory to the operating system (width = 0, a forced GC and idle event-loop turns all leave RSS where it was), pdfjs allocates two or three scratch canvases per stencil mask, and rendering that 4389-mask page once took the process from 180 MB to 690 MB and twice to 1 GB. Canvases are now pooled by exact size through pdfjs's publicCanvasFactoryoption and reset with a same-size width write (the binding'scontext.reset()leaves a clip set outside asavein place); four such pages render in 344 MB of process peak over stdio instead of 797 MB, a four-page batch of an ordinary paper in 294 MB, and the pool is bounded at 16 megapixels and 8192 canvases. The attachment resolver thatzotero_get_fulltextused moved tofeatures/attachments/resolve.tsso both tools mean the same file by "the PDF", and the page-span parser topdf-pages.tsso both read the same syntax.
Fixed
- ChatGPT connections no longer expire on day 31. ChatGPT registers a confidential
client through Dynamic Client Registration and presents its client secret on every token
refresh. The SDK's default secret lifetime is 30 days, shorter than the refresh token, so
every ChatGPT user would have been sent back through the subscription key and the Zotero
sign-in on day 31. Secrets issued by
/registernow carry no expiry. claude.ai registers a public client and is unaffected; client records are still capped and swept by the store.
[1.18.1] - 2026-09-10
Added
zotero_whoamireports the running Zoteusversion. Nothing in a conversation could say which Zoteus was answering it. The update notice only appears when a NEWER release exists, so silence meant either "you are current" or "nothing ever checked", and a client pinned several releases back looked exactly like a healthy one. Found the hard way on the machine this was written on: Claude Code was pinned to 1.17.0 and the Claude Desktop extension was still 1.13.0, five releases behind, with nothing anywhere to say so. A manually installed.mcpbnever auto-updates, so this is the only place that answer can come from.
Fixed
-
Every PDF feature was degraded inside Claude Desktop, and the message blamed the wrong thing. Found by driving the shipped bundle through Claude Desktop itself; no issue was filed, because nobody outside could have diagnosed it from the message it produced. pdfjs decides once, while its module body runs, whether it is running under Node, and the last clause of that test is
!(process.versions.electron && process.type && process.type !== "browser"). Claude Desktop runs MCP servers inside Electron, whereprocess.typeis"utility", so pdfjs concluded it was in a browser, evaluated the browser half of its own module body, and threwDOMMatrix is not definedbefore reading a byte of any file. The import is wrapped in a barecatchthat returns null to degrade gracefully, so nothing surfaced:zotero_get_fulltext outline:trueanswered "could not be read (corrupt PDF, or the optional pdfjs-dist parser is missing)",page_rangeandprecise_pagesfell back topageSource: "approximate", andzotero_annotatelost the text anchoring that places a highlight from a quoted passage. Every one of those blamed a missing optional dependency that was installed and working. Measured on Electron 44.2.0, which ships Node 24.20.0, so neither the Node version nor a native ABI mismatch was ever involved: the same 1.8 MB PDF, the same installed pdfjs 5.6.205, read 14 pages and 9 outline entries under plain Node and failed to import at all withprocess.typeset.process.typeis now masked for the duration of the import and restored in afinally, which is the whole window that matters becauseisNodeJSis captured then and never read again; a non-configurable descriptor is left alone rather than forced, since deleting one throws and a thrown loader is worse than the degradation it prevents. pdfjs is imported in exactly one place now, so the four call sites cannot drift apart again. Verified end to end against the 1.18.0 bundle installed in Claude Desktop: under a process made to look like Electron's it reproduces the reported failure, and the fixed build returns the 19 outline headings andpageSource: "exact"in the same environment.The messages no longer offer a cause they have not checked. Where the parser really cannot load, the error it threw is quoted instead of asserting the dependency is absent; and
zotero_get_fulltextknows at the branch point whether it failed to read the bytes or failed to parse them, so it now says which, rather than handing back "PDF bytes or the optional pdfjs-dist parser missing" and leaving the reader to guess between two unrelated problems. -
zotero_saved_searches action:"list"reached the cloud too. The last read still callingctx.webdirectly, and the same defect aszotero_manage_tags action:"list"one release earlier, withzotero_list_tagsandzotero_syncbefore that. A desktop-only install asked api.zotero.org forusers/0, got "Invalid user ID" back, and the tool then dressed that up as advice to "check field names and itemType against the schema" on a call that carries no fields at all. Zotero 7+ serves the definitions locally (measured:GET /api/users/0/searchesanswers 200), so they were next door the whole time. The list action is routed now, like every other read, and an explicitlibrary_idstill picks the group it names. Installs holding a cloud key were never affected, so this broke exactly the keyless desktop user, which is the setup the documentation calls local-only read mode. -
zotero_tag_auditno longer reports the whole library as one collection's coverage.scope.collection_keyswas passed straight to the item listing with nothing checking that the library had those keys. The desktop app answers/collections/<unknown>/itemswith the WHOLE library rather than a 404, so a mistyped or stale key came back as that collection's coverage, counting every item in the library. Measured against a 285-item library with a required tier: the real collectionRANF9BFVreportsitemCount: 6, andZZZZZZZZ, which does not exist, reporteditemCount: 285under that same key, with nothing in the answer to say which question had been answered. In an audit that is the worst shape a wrong answer can take, because the same typo against a vocabulary with no required tier reports an emptymissingByTier, which reads as perfect compliance.zotero_search_itemsandzotero_exportalready refuse an unknown collection key for this exact reason and say so in the refusal; the audit now uses the same guard, and refuses before any listing work rather than after paying for a full-library scan. -
includeonzotero_get_itemno longer drops the item record it promises. The description offersincludeas a way to "additionally" request rendered output, but Zotero REPLACES the representation whenincludeis set:include=bibanswers with the rendered bibliography alone and nodataobject at all. Every bibliographic field the tool documents went missing, silently, and the summary line renderedItem SUU9EI96: (no title)for an item whose title the same call had just formatted into its bibliography. Measured against a real library before the fix: the string"title"appeared nowhere in the response.datais now requested alongside whatever was asked for, so the record and the rendered output both come back, and a caller that already nameddatais not charged for it twice.bib,citationandcsljsonall behaved this way and all three are fixed.
[1.18.0] - 2026-09-10
Fixed
-
A
tools/callthat omitsargumentsno longer fails.argumentsis optional in the MCP spec, and the SDK types it that way, so a client calling a tool that needs nothing may leave it out. Every one of the 30 tools answered such a call with a JSON-RPC -32602 about the shape of the envelope,zotero_whoamiincluded, which is the tool the documentation tells callers to reach for first. An absent argument object is now read as an empty one. A tool that does require an argument still refuses, but the refusal names the field it wanted and the values it accepts, rather than complaining that the envelope was undefined. -
A desktop write whose grant has gone now falls back to the cloud instead of failing. Zotero gates local-API writes behind a grant the user accepts in a dialog, and a "Allow" grant is single-use: Zotero deletes it on first successful use.
LocalWriteClientanswers the first 401 by re-authorizing and retrying, but a 401 that survived that reached the caller as a plain write failure, and the predicate deciding whether to try the cloud instead only recognised the Zotero 9 shape (404, 501, unreachable). So a run whose re-authorization dialog nobody answered stopped with "Invalid or expired API key" while holding a cloud key that could have served the write. It now falls back. An explicit denial deliberately still does not: Zotero answers "Deny" with 403 and its own error, and someone who has just refused a write is not asking for it to be routed somewhere else. -
Every tool now refuses an argument it does not know, instead of dropping it and answering a different question. Each tool hands the MCP SDK a
ZodRawShapeand the SDK builds a plainz.objectfrom it, which strips what it does not recognise, so an argument one letter out never reached a handler and the call ran as though it had never been sent. Measured against a 285-item, 211-tag library before the fix:zotero_search_items {q:"kalman"}answered "found 55 full-text match(es)", while{query:"kalman"}and{search:"kalman"}both answered "Found 1266 item(s); showing 3", which is the whole library reported as a success;zotero_list_tags {filter:"core"}returned 50 tags where{q:"core"}returns 1;zotero_list_collections {top_level:true}returned all 46 collections where{top:true}returns 3;search_tools {q:"bibliography"}returned the full 30-tool catalog where{query:"bibliography"}returns 5;zotero_schema {itemType:"journalArticle"}returned the whole 40-type schema instead of that one type's fields;zotero_get_fulltext {item_key, passages:1}returned 12000 characters instead of one passage;zotero_export {format, limit, query:"kalman"}exported the unfiltered library; andzotero_tag_audit {collection_keys:[...]}ran a full-library audit with the scope dropped.queryforqis the single likeliest mistake a language model makes against this API. The JSON Schema those tools advertise already saidadditionalProperties: false, and it is unchanged byte for byte for all twenty-eight of them; only the runtime had never enforced it, and no documented argument moved. A refusal names the argument that was not understood and, where its only fault was how it was spelled, the one it was probably meant to be (queryis answered withq,itemtypewithitemType,passageswithmax_passages,include_automaticwithinclude_auto); where it was a nested field hoisted to the top level, the path it belongs at (collection_keyswithscope.collection_keys,itemTypeonzotero_create_itemswithitems[].itemType). An argument that resembles two of them at once is answered with the list of arguments rather than with a guess. Objects that are deliberately open stay open: item data (patch,items[]) keeps its catchall, because Zotero item fields are an open set, andzotero_format_bibliographystill takes any CSL-JSON.zotero_whoamiandzotero_groupsdeclare no arguments and were left open here; the entry below closes them too. Same consequence as thezotero_tag_auditfix below: these refusals are raised while the arguments are being validated, so they come back as ordinary tool results withisErrorset but never reach the usage log or the metrics counters, which has always been true of a malformed argument. -
A key the protocol reserves for itself is accepted and ignored rather than refused, and the last two tools that dropped arguments in silence now refuse them. Two gaps left by the entry above. MCP carries its own bookkeeping in
_meta, on the request'sparamsand never inside a tool'sarguments, and nothing in this server puts anything else inargumentseither. But strict arguments meant that a client which ever did would have had every call refused at once, which is the whole hosted user base failing together over a key nobody meant as an argument. Any top-level key beginning with_is now removed before the arguments are checked. Measured over stdio against the desktop app:zotero_search_items {q:"kalman", _meta:{progressToken:7}}answered "found 55 full-text match(es)" where it had answered "unknown argument_meta", and the handler was handed{"q":"kalman"}with the key gone, so no handler can read protocol bookkeeping as though a user had sent it. Nothing else moved:{query:"kalman"}is still refused with "this tool spells itq", and all eight refusals measured for the entry above come back word for word, including one sent alongside a_metathat is now tolerated.zotero_whoamiandzotero_groupswere the two tools still dropping an argument in silence, because an empty raw shape is the one input here that reaches the SDK through Zod v4-mini rather than v3, and that dialect emits noadditionalPropertiesline at all, so those two had never advertised a strictness there was anything to enforce. Handing the SDK a built object instead of an empty shape puts them on the same path as the other twenty-eight: their published schema gains"additionalProperties": falseand nothing else intools/listchanges, verified by diffing full dumps from builds either side of the change, which differ in exactly those two lines.zotero_groups {library_type:"group"}stops working as a result, deliberately. That tool's own description nameslibrary_typeandlibrary_idas parameters of other tools, and the same sentence invites{library_type:"user"}and{library_id:5678}just as strongly; both of those returned every group as though the filter had been applied, which is the failure the entry above exists to end. A key cannot be tolerated for one of its values, so the choice was between one spelling that reads correctly by luck and every spelling corrected in a turn. Both tools now answer an argument they do not take with "unknown argumentlibrary_type: this tool takes no arguments. Nothing ran, because the key would have been dropped and the answer would have read as though it had been honoured. Call this tool with no arguments." -
zotero_tag_auditno longer audits something other than what it was asked about.scope, thevocabularyobject and each of itstagsandtiersentries were plainz.objects with optional members, so a key one letter out was stripped by Zod before the handler saw it and the audit ran on as if it had never been sent.scope: {"collections": [...]}, a near miss forcollection_keys, produced a whole-library audit with no per-collection coverage in it: measured against a 211-tag, 285-item library, the right spelling and the wrong one both answered "Audited 211 tag(s) over 285 item(s): 89 off-taxonomy, 119 auto, 284 required-tier gap(s)", the first carrying coverage for the two collections asked about and the second carrying none, with nothing in either to say which question had been answered. Three more keys did the same.Tieron a vocabulary tag lost that tag's tier, so every item holding it counted as missing the tier (284 gaps became 285).requireon a tier left the tier unrequired, so the audit reported no gaps at all.tierfortierson the vocabulary itself dropped the entire tier list, likewise no gaps. All four now fail the call, naming the key that was not understood and, where only its spelling was wrong, the one it was probably meant to be (collectionsis answered withcollection_keys,requirewithrequired). The JSON Schema the tool advertises already saidadditionalProperties: falseat every one of those levels and is unchanged; only the runtime had never enforced it, and no documented field moved. A vocabulary read fromvocabulary_pathis parsed by the same schema and now reports the same sentences with the file named, in place of a dump of Zod's issue objects. One consequence worth knowing: a refusal over an inlinevocabularyorscopeis raised while the arguments are being validated, so it comes back as an ordinary tool result withisErrorset but never reaches the usage log or the metrics counters. That has always been true of a malformed argument (an out-of-rangelimit, say) and is not new here; a vocabulary file rejected for the same reason is refused by the handler and is recorded like any other tool error. -
An item Zoteus had just written no longer reads back as missing. Writes go to the cloud Web API: always for a group library, and for the personal library too from
zotero_create_itemsandzotero_update_item, which both require a cloud key. Reads go to the running Zotero desktop app for any library it holds. So between the write and Zotero's next sync the two disagreed, and the call that had just answered "Wrote 1 item(s)" with a key was followed byzotero_search_itemsfinding nothing andzotero_get_itemanswering "Local API 404". Measured on both libraries, and worst for an agent that verifies its own work: told the item does not exist, it writes it again, and the library fills with duplicates. Reads of a library now go to the API that took the last write to it, until the desktop app can be shown to hold that write. Nothing changes for a library nobody has written to: those reads are routed exactly as before, and cost no extra request. Comparing the two APIs' library versions would have been the tidier fix and does not work, because they number their libraries independently (on the machine this was measured on, the same personal library was at version 3476 on the cloud and 681 on the desktop); the desktop is compared against its own earlier answer instead. Still stale until Zotero syncs: full text stored withzotero_fulltext action:"set", which the desktop files beside the attachment rather than in it, so there is nothing to watch for. -
zotero_manage_tags action:"list"reached the cloud too. The same defect aszotero_list_tagsandzotero_sync, one tool further along: the list action readctx.webdirectly, so a desktop-only install asked api.zotero.org forusers/0and got "Invalid user ID" back, while the desktop had been serving the tags all along. It is routed now, and an explicitlibrary_idstill picks the group it names. -
A desktop write that landed nothing no longer reports success (#77). Every write path collects per-item outcomes instead of throwing, so a payload Zotero refused came back as
Trashed 0 item(s) via the Zotero desktop app.with no error flag and the 400 buried infailed.zotero_trash_itemsandzotero_annotate action:"delete"both did this on their local branch, which is the default on a desktop setup, and both are what an agent reaches for to undo its own work: it would read the summary, believe the item was gone, and move on. The cloud branch ofzotero_trash_itemsalready appended; N failed.; the local one said nothing. A write that attempted items and landed none of them is now an error quoting the first reason, and a partial write stays a success but names the failure count in the summary rather than only in the payload. The check is one shared helper, sozotero_importand these two cannot drift apart again. -
A build whose attachment map stopped early no longer stamps a full-text cursor over the attachments it never reached (#78). The map that turns Zotero's full-text keys into item keys is a paged crawl of every attachment in the library, and on a large library it can stop partway: one listing request past the per-request budget ends it, and it says so on
zotero_index action:"status". Anaction:"update"already withheld its cursor when that happened (#26, #67). A build did not. It narrowed its full-text worklist to the keys the map had reached, so nothing ever asked about the rest and no read failed, and it then stamped the high-water mark of the whole full-text census. The attachments the map never listed were left named by no?since=on either sequence, ever: their items had not changed in Zotero, so no later update went back for them, and the index claimed coverage it did not have with only a status sentence to say otherwise. The cursor is now withheld whenever the map did not reach the end of the library (a failed request, the crawl's page ceiling, or a listing that stops serving pages before its own total), and the status says both what stopped and what was withheld because of it. The item version stamp is deliberately still recorded: the metadata pass really does finish, and withholding it would turn every later update into a full rebuild on exactly the libraries that cannot finish one. -
The update that recovers from such a build now reads the items it skipped, instead of sealing them (#78). With no cursor to work from, an update asks
/fulltext?since=0and gets the whole census back, and it narrows that to the index's coverage gap: the items holding no body passages at all. Over a map that stopped early that filter is wrong. Zotero lists attachments newest-modified first, so one item's attachments are not adjacent in the crawl, and an item with one attachment on a mapped page and another on a page the map never reached already holds passages, so it was skipped, and the same update then stamped the census-wide cursor and cleared the reason, making that attachment's text unreachable for good. A build (or an update) that indexes body text over an incomplete map now records that its coverage is partial, in the index and on disk, and while that stands the firstaction:"update"asked for full text whose own attachment map reaches the end of the library runs its catch-up in full rather than gap-only mode: every indexed item that update's census names is re-read, once. While the mark stands that catch-up asks Zotero's full-text sequence from the start, whatever cursor the index holds, because the text a truncated map missed was extracted before that cursor and no?since=delta will ever name it. That is also what makes a mark raised by an update actionable: a delta that re-indexes a changed item's body text over a truncated map writes only part of that item's text, and it does so on an index that already carries a cursor. Only a run that was paid in full, over a map that was opened and did reach the end of the library, with every read succeeding and nothing cancelled, stamps the cursor and clears the mark: a pass that read nothing (an empty full-text census, say) recovers nothing and says so, rather than retiring a mark that is the index's only record of the missing text. An update whose map stops short again falls back to filling the coverage gap alone (the items holding no body text at all), says so onaction:"status", and leaves the mark and the withheld cursor standing, so the full re-read is paid once rather than on every update. It costs one full body crawl, which is the work a rebuild would do anyway, without re-crawling the metadata. If you have runaction:"build"with full text on a library large enough for the map to stop early, your index is holding a cursor from before this release, and nothing in the index can tell: anyaction:"build"that starts over clears it, which is whataction:"refresh"is (it is exactly a build withfresh:true) and what a plainaction:"build"does too unless it is resuming an interrupted build's checkpoint. From this release on, such a build records no cursor and the next ordinaryaction:"update"fills the missing body text in by itself. -
That recovery is bounded, so one unreadable attachment cannot cost a body crawl on every update (#78). Body-text reads are caught per attachment, so a file that can never be read (moved out from under Zotero, a 403, a linked file the server will not serve) leaves the whole-census re-read unfinished however often it runs. With the mark standing and the map complete, that meant re-reading the entire census on every single update, forever: on the reporting library, a full body crawl of 8,953 attachments per update. After three such re-reads have been paid and still ended on unreadable text, the body crawl stops being paid and each update fills in only the items holding no body text at all. The cheaper attachment listing crawl does continue, because the mark keeps the cursor withheld and every later update therefore asks the sequence from the start. Nothing is claimed for that: the mark stays standing, the cursor stays withheld, and the status says the re-read keeps failing, so the index still never reports coverage it does not have. A transient failure still recovers, because the count only rises on a re-read that was actually paid for;
action:"refresh"withfulltext:true(or any build that starts over) clears both the count and the mark. -
action:"status"says when an index holds partial full-text coverage (#78). The mark is persisted and outlives the process that recorded it; the per-passfulltextReasondoes not, so after a restart an index that still owed a full body re-read said nothing about it. Status now reportsfulltextPartial: truefor exactly those indexes, with afulltextReasonsaying what the mark means (including which of the two shapes it is: no cursor was ever earned, or one earned before the gap still stands) and what the next full-text update may cost (one whole body crawl), or that the re-read has stopped being attempted. Indexes whose coverage is whole report neither. -
An update that indexed no body text no longer stamps a full-text cursor (#78).
action:"update"deliberately leaves a metadata-only index alone rather than turning into the hours-long full-text crawl nobody asked for, but it handed the whole census's high-water mark back on its way past, and that was stamped. Everything Zotero had already extracted was then behind the cursor, named by no?since=on either sequence, and the first PDF opened afterwards would be indexed alone over a library whose body text never was. The cursor now stays where it was. That is not free on a large library: while it stays at 0, every later update asks/fulltext?since=0, gets the whole census back and re-opens the attachment map to resolve it, so it pays that listing crawl (roughly 90 requests on a 9,000-attachment library) on every update rather than once. It reads no body text, and the alternative was a cursor that lied. What this costs you: an index holding no body text at all now gains none fromaction:"update", ever, including one built withfulltext:trueover a library Zotero had extracted nothing in yet. Before this release such an index picked up whatever was extracted after its first update, on the strength of a cursor stamped for coverage it had never indexed, and everything extracted before that point stayed missing with nothing to say so. Such an update now also says so onaction:"status", which it did not: at exactly the moment this describes, the reason had been cleared and the status said nothing about full text at all.fulltextReasonnow tells the user what to do about it (open the PDFs they want searchable in Zotero, which is what makes Zotero extract the text, then runaction:"build"withfulltext:true), and one build fills in the whole library rather than the tail of it. -
Time queued behind Zoteus's own other requests is no longer charged to a request's budget, or blamed on Zotero (#78). The per-request time budget is a statement about how long Zotero took to answer, and its error says so; the clock started when the call was made, before the request had a slot on the four-permit semaphore every Zotero read shares. A busy fetcher could therefore spend a request's whole budget waiting for its turn and then report a desktop app answering in under a second as one that had hung. The clock now starts when the request does. Abort behaviour is otherwise unchanged.
-
zotero_importwrote an item type Zotero does not have, and called it success (#77). With no translation-server running, a DOI resolves through OpenAlex and the Zotero item type was chosen from whether OpenAlex reported a venue:journalArticleif it did,genericif it did not.genericis not one of Zotero's 40 item types, so Zotero refused every such save with400 Unknown itemType 'generic'. OpenAlex reports no venue for most conference papers and many books, so this was the common path, not a corner: ResNet, XGBoost, the DSM-5 and the CRC Handbook all fail to import on any release from 1.1.0 to 1.17.0. OpenAlex's owntypenow picks the Zotero type (conference-papertoconferencePaper,booktobook,dissertationtothesis, and so on), a venue still meansjournalArticle, and anything unmapped falls todocument, which is a real Zotero type. The arXiv path was never affected. -
A save that wrote nothing no longer reports success. The write paths collect per-item outcomes rather than throwing, so a payload Zotero rejected outright came back as
Imported 0 of 1with no error flag, and a model reading that summary reported success while the library stayed empty. That is why the item type above went unnoticed for a month. An import that resolves items and creates none of them now returns an error and quotes the first reason. Partial success is still success:failedalready carries the rest. -
zotero_groupslists the groups the Zotero desktop app holds, instead of demanding a cloud key it does not need (found while investigating #77). Group libraries have been readable without a cloud key since Zotero 10 began serving/groups/<id>locally: the router sends a read for a group the desktop holds to the desktop, keyless, and only a group it does not hold goes to the Web API. The one tool that would tell a user a group's id refused outright withoutZOTERO_API_KEY, so a local-API-only user could not learn the id that every other tool needs, and the refusal named a cloud key as the requirement. With no key the tool now lists what the desktop serves:id,name,descriptionand the desktop's own item count. The cloud'stypeandlibraryEditingare membership facts the desktop never stores, so they are absent from those rows rather than guessed, and the desktop's count includes child attachments, notes and trashed items, so it is not the cloud's figure; the answer carries a note saying both. Where a key and a local Zotero are both present the two lists merge into one row per group, each markedsource: "cloud","local"or"both", with the cloud's richer fields kept for a group that appears in both. The answer for a key with no local Zotero is unchanged, down to its wording. The refusal survives only when neither source has anything to list, and it now says which one was missing. Listing also refreshes the set of locally held groups, so a group joined (or a Zotero started) after the server was launched becomes readable without a restart. Writing to a group still goes through the cloud and still needs a key with write access to it. -
A collection key the library does not have is refused, instead of being answered with the whole library.
zotero_search_itemsandzotero_exportpasscollectionKeystraight to Zotero, and the desktop app does not refuse an unknown one: measured against a running Zotero 10,/collections/ZZZZZZZZ/itemsanswers200 Total-Results: 723, which is the entire library, where a real collection answers 14. A scoped search was therefore byte-for-byte identical to an unscoped one (collectionKey:"ZZZZZZZZ", q:"attention"returned exactly the same two items asq:"attention"alone), and an export scoped to a mistyped or deleted key returned the whole library's RIS with nothing to say so. It hit exactly the key-free desktop user: the cloud Web API 404s that same sub-route, so the cloud path was never wrong. The collection itself does 404 on the desktop, which is the one place the app admits the key is unknown, so a read that names a collection asks that first and refuses by name when it is absent, pointing atzotero_list_collectionsfor the right key. The extra request is paid only when a collection key was given and only when the desktop app serves that library, so unscoped searches, cloud reads and reads of a real collection are unchanged. A check that itself fails (the app going away between the two calls) is not read as absence: the read goes ahead and reports its own failure. -
An empty DOI no longer fabricates a successful lookup.
zotero_scholar {action: "lookup", doi: ""}answeredisError: falsewith an untitled work, no authors and "0 citations". Traced:https://api.openalex.org/works/404s, the Crossref fallback then fetcheshttps://api.crossref.org/works/, and that is Crossref's works-LIST endpoint, which answers 200 with"message-type": "work-list"and 186 million results. The list envelope was parsed as though it were one work. Both halves are fixed: a blank or whitespace-onlydoiis refused before any provider is asked (a padded one is trimmed rather than refused), and the Crossref parser now requires a single-work payload, so a 200 from the wrong endpoint is never read as a result.doi: "hello world not a doi"already errored; the empty string was the hole. -
references,citationsandrelatedreport an upstream miss in the same words aslookup. They reach OpenAlex directly, so a DOI it does not hold came back asOpenAlex 404 for https://api.openalex.org/works/doi:..., a raw request URL wherelookupsaysNo scholarly record found for DOI .... They now give that same sentence. A status other than 404 is reported as the provider failing rather than as an absent record, because a throttled or broken OpenAlex is not evidence that a paper does not exist. -
An export that rendered nothing says so, instead of handing back a blank body as success.
zotero_export {format: "bibtex", item_keys: ["ZZZZZZZZ"]}returnedisError: falseand the text"\n\n", with no summary and no notice, so an agent could not tell an empty export from a failed one. Nameditem_keysthat render not one entry are now an error that quotes the keys and says what else would explain it (only top-level items are exported, so a child attachment, note or annotation renders nothing). An export narrowed by a collection, aqor anitem_typethat renders nothing is a real answer of "no entries" and is reported as one, carryingempty: trueand anoticeinstructuredContent, the wayzotero_format_bibliographyreports(empty bibliography). A non-empty export is unchanged, down to the bytes. -
A 403 now depends on the request that drew it, instead of describing a situation the caller is not in. Zotero spends one status on several unrelated refusals whose remedies contradict each other, and the response says nothing about which applies, so the message listed them all. A read of the caller's own personal library, by its real user id, on an install with no API key at all, was answered with four clauses about group write permissions and one about a read-only key: every one of them wrong, and the true answer (in key-free local mode the personal library is
library_id: 0, and any other id is sent to the cloud) was not among them. The three facts that decide the remedy now travel with the failure: which library the request addressed, whether it was a read or a write, and whether a key was sent at all. A key-free failure names local addressing and never mentions key permissions; a keyed read says the key cannot READ that library and does not offer write access as the fix; a keyed group write keeps the three gates it has named since #74; and a 403 on/keys/currentsays the key itself was rejected. Where the context is unknown the message says only what is certain of any 403. -
zotero_bibliographyreports the entries Zotero rendered, not the keys it was asked for.itemCountwasitem_keys.length, so it echoed the request: a key the library does not have came back asitemCount: 1over an emptycsl-bib-body, a number no rendering could contradict. Zotero drops a key it cannot render and still answers 200, so the count now comes from the rendering (entryCount, aszotero_format_bibliographyalready reported it, alongsiderequestedCount), and a shortfall is stated: how many of the requested keys produced no entry, from which library, and the two reasons a key renders nothing (the library does not have it, or it names an attachment or note rather than a regular item). An empty result reads(empty bibliography)like its sibling instead of an empty wrapper.itemCountis gone from the structured content. -
An empty
identifieris no longer blamed on the translation-server.zotero_import {action:"by_identifier", identifier:""}answered "No Zotero translation-server reachable at http://127.0.0.1:1969" and suggested installing Docker: a missing identifier and a blank one are both falsy, so the empty argument fell through the guard meant for the case where there is nothing to resolve with. Arguments are now checked before the server is probed, and an emptyidentifier(orurl) says which argument was empty, what a valid one looks like, and which action to use instead. The generic translation-server refusal it used to reach was reachable only through this bug and is gone; the URL one, which has no built-in fallback, is unchanged. -
zotero_fulltext action:"set"says why it cannot run locally, instead of calling a personal-library call cloud/group. With no key, aseton the personal library was refused with "This operation writes to a cloud/group library and requires a cloud API key", which described neither the library asked for nor the reason. The reason is the operation: storing full text is a PUT to the Web API, and neither desktop write path (the Zotero 10+ local API, the connector protocol) has a full-text endpoint for it to route to, so a running desktop app cannot stand in for the key here as it does for item writes. The refusal now names the library it was given, the operation's cloud-only nature, and what is unaffected: the desktop app keeps its own full-text index, whichaction:"get"andaction:"since"read with no key. The group refusal, which was already accurate, is unchanged. -
zotero_list_tags,zotero_tag_auditandzotero_syncread from the Zotero desktop app instead of failing with "Invalid user ID" (same cause as #64, #26 and #67). All three called the cloud Web API directly rather than the router, and with no cloud key the personal library is addressed asusers/0, which api.zotero.org answers400 Invalid user ID. On the majority setup, a running Zotero and no key, every one of them was unreachable with ordinary arguments while the desktop app next door was serving the same data onhttp://127.0.0.1:23119. Three separate faults, all fixed: the tools work; the error they produced told the user to check their field names against the schema, which was neither the cause nor anything they could act on; and a local-only install no longer makes an unexpected request to zotero.org (it carried no key and no library content, but it is not what a local-only user expects). Tags, the version census and the deletion log now route like every other read, so a group the desktop holds is served locally too. -
What the desktop app cannot answer is named, never returned as an empty result. Zotero 10.0.1 serves item and collection versions but answers
/tags?format=versionswith{}while the same response's header counts every tag in the library, and it has no/deletedendpoint at all (404). Reported as-is that is "0 tags changed, nothing deleted" for a library where both may have changed: a success that did nothing.zotero_syncchecks each version map against the count the response itself gives, reports what could not be served inunavailablewith the reason and where the answer does live, and errors outright when nothing it was asked for can be answered. It also reports which API served the delta, inbackend, and takes the whole delta from that one API: the desktop app and the cloud number their library versions independently, so a delta answered half from each would be handed back under a singlesincebelonging to neither sequence. -
A hand-placed highlight sorts where it sits, instead of at the top of its page.
zotero_annotatederivesannotationSortIndexfrom the topmost rect's distance to the BOTTOM of the page, so it cannot be computed without the page height, and the only thing that ever reported one was the passage-anchoring pass. A caller who passedpositionskips that pass by definition, so every hand-positioned highlight was stored with00000|000000|00000and jumped to the top of its page in the reader sidebar, whatever page coordinates it carried. Measured against a real Zotero desktop: rects[[72, 696, 300, 712]]on a US Letter page stored00000|000000|00000where the reader wants00000|000000|00080. The page height is now read out of the PDF for that path too, which costs opening a file the call would otherwise not open, so it is asked for only for the annotations whose sort index actually turns on it, never for a call that is about to be refused, never whensort_indexorpage_heightwas given, and it shares one read with the anchoring pass. Only page viewports are parsed, never page text: on four real papers (1 to 17 MB) that is 1 to 5 ms, against 142 to 449 ms for the anchoring pass on the same files, plus the file read. Where the PDF cannot be read at all the annotation is still written with its position intact, and the summary says which annotations sort to the top of their page and thatpage_heightfixes it, rather than leaving it to be discovered in the sidebar. -
An annotation field this tool does not know is refused, not silently dropped. The annotation schema was a plain
z.object, which strips unrecognised keys before a handler ever sees them, while the JSON Schema it advertised saidadditionalProperties: false. SopageLabel: "xx"andsortIndex: "09999|000999|00999"(Zotero's own camelCase spellings; this tool's arepage_labelandsort_index) producedisError: false, a cheerful summary, and an annotation stored with a page label of"1"and a computed sort index. Measured on a real Zotero desktop, and a test harness had been writing exactly that and reading the success as proof it worked. A silently ignored argument is the one failure a caller cannot detect, so an unknown key now fails the call, names itself, and names the field it was probably meant to be:pageLabel,page-labelandannotationPageLabelall resolve topage_label, and a key that resembles nothing gets the list of fields. The advertised schema is unchanged, and so is every documented field. -
A malformed
positionis refused, and the refusal says so. A rect that was not four finite numbers was filtered out ofposition.rects, which left the position empty, which the handler read as "no position given" and answered by locating the passage in the PDF and writing entirely different coordinates, as a success. Measured:rects: [[10, 20, 30]]stored a text-anchored rect nothing in the request had asked for. With text that is not in the PDF the same input was refused withpassage not found in the PDFand told the caller to re-quote it, and the generic form of that message says "nopositionwas given" when one had been. A position that was given and cannot be read is now a caller error naming the annotation index and what was wrong with it (which rect, and whether it had the wrong number of values or a value that is not a finite number), and nothing is written and no passage is looked up. The same now holds for the other unreadable positions that used to vanish: a non-JSON string, an array that is not the[pageIndex, [x1, y1, x2, y2]]shorthand, and apageIndexthat is not a whole page number.{"pageIndex": N}with no rects is accepted as the page-only position it reads as, rather than discarded.
Added
ZOTEUS_ZOTERO_DEADLINE_MS: the per-request budget for desktop reads is configurable (#78). The 25 s default is right for a local API that normally answers a listing in under a second, and it is what turns a stuck read into an actionable message instead of a hang until the MCP client's own timeout. It is not right everywhere: on a 9,000-attachment library some machines answer an attachment listing slowly enough that an index build's map aborts every time, at the same page every time. Accepted between5000and600000ms; a value outside that is ignored with a warning and the default stands. It applies to listings and item reads against the desktop app only, so raising it cannot make a cloud Web API call hang that long, and it leaves the 1.5 s liveness probe, file uploads (which pass their own, longer budgets) and attachment downloads (which stay on the 25 s default) alone.
[1.17.0] - 2026-09-09
Added
-
The threat model is written down, and library text says where it came from (#71). A Zotero library is not a trusted corpus: titles, abstracts, creator names, tags, note HTML, annotation text and extracted PDF/EPUB body text arrive from PDFs downloaded off the open web, from group libraries, and from items other people shared, and they reached the calling model unmarked.
docs/threat-model.mdnow states the boundary Zoteus does and does not draw, names the three deployment postures, and links fromSECURITY.mdand the README.zotero_search_items,zotero_get_item,zotero_get_fulltextandzotero_semantic_searchcarry one addedprovenancefield (source: "library-content",trust: "untrusted") alongside their unchanged payload, which ridesok()'s text mirror so it reaches clients that surface only text. The marker makes the boundary expressible; it does not sanitise anything and it does not stop prompt injection. -
ZOTEUS_CONFIRM_BULK_WRITES: an optional bulk-write threshold (#71). Abovenitems in one call,zotero_trash_items(trashing, not restoring),zotero_manage_tagsadd/remove, andzotero_manage_collections action:"remove_items"refuse unless the call also passesconfirm: true, following theZOTEUS_ALLOW_DELETE+confirmidiom. Single-item edits stay fluent. Default0, meaning off, so no existing call changes. It is a deliberation step at the scale where a bad decision does real damage, not a human in the loop: a model can re-call withconfirm: true. -
The citeproc-js attribution travels with every artefact that redistributes it (#70). The
.mcpbbundles and the container image carrynode_modules/citeproc, which is licensed CPAL-1.0 or AGPL, and the CPAL asks for a copyright notice, an attribution phrase and an attribution URL to be displayed when a session begins.THIRD_PARTY_NOTICES.mdat the repository root now carries the three Exhibit B lines and states that Zoteus takes the CPAL option; it is in the npm package'sfiles, staged into each bundle, and copied into the image, and the bundle gate checks it is there rather than trusting the script. An MCP server's only screen is its text, so the attribution line is written to the log once at startup and returned byzotero_whoami, in its summary and in a structuredattributionfield. The npm package itself never vendored citeproc and is unaffected. -
ZOTEUS_OPENALEX_API_KEY, andzotero_scholarsays when a list was cut (#76). OpenAlex replaced its "polite pool" with free API keys before February 2026 and now ignores themailto=parameter Zoteus appended to every request, so the parameter is gone from the OpenAlex calls (Crossref still reads it) and an optional key takes its place, sent as a bearer header and never in a URL that an error message would quote. Keyless calls still work on OpenAlex's small daily budget.references,relatedandcitationsanswers now carrytotal, the size of the list the results were cut from, andtruncatedwhenlimitdropped some: a review with 150 references used to come back as twenty works and nothing else, and a citation-gap pass had no way to know it saw a seventh of the list. The tool description also says what the tool is not, a thin helper around one DOI, and points at the OpenAlex API for full querying.
Changed
pdfjs-distis pinned to exactly 5.6.205 (#69). It is the last release that runs on the Node 20.19 floor the package declares: 5.7.284 and the whole 6.x line require>=22.13.0 || >=24. The declared range was^5.6.205, which admitted all of them, so any resolve that ignored the lockfile (npm update, a deleted lockfile, a project depending on@oscardvs/zoteusand resolving its own tree) could take a build that cannot start on Node 20, and exact-page extraction would have degraded to approximate pages with no error to read. The pin closes that,tests/node-floor.test.tsfails if the pin, the lockfile,mcpb/manifest.jsonand the CI matrix ever stop naming the same floor, andSECURITY.mdnow records why the parser is frozen and why the advisorynpm auditreports against it (GHSA-hq66-cqwq-w95j, a viewer-scripting flaw) does not reach a text extractor that builds no viewer.
Fixed
-
The first
action:"update"after a build no longer costs every later semantic query the two-stage vector path (#30). An update runs two catch-up passes that replace one item's passages wholesale: the full-text pass that picks up newly extracted attachment text (#26), and the own-words pass that re-reads an edited note or annotation (#33). Both removed those passages and their vectors and left the binary codes taken from them behind, where a whole-item delete had always taken them with it. One code per replaced passage then described a row the index no longer held; the coverage check that reads the codes counts one per stored vector, found more codes than vectors, and sent every semantic query back to the exact scan the codes exist to avoid (the 42x this path was landed for) until someone noticed and ran a fullaction:"build". Nothing said so beyond the scan notice onzotero_index action:"status". A semantic query that arrived while a catch-up was still running could also fail outright, on a resident code cache naming a rowid SQLite had just handed back to a replacement passage that carried no vector yet. Both clears now drop the codes of the passages they remove, scoped by source so the item's other passages keep theirs. -
A transient attachment read during
action:"update"no longer drops an item's body passages and stamps past them (#67). A changed item whose PDF could not be read got the same answer as an item with no extracted text at all, so the upsert replaced its indexed body with nothing and the version stamp advanced anyway: the attachment had not changed in Zotero, so no later delta named the item and/fulltext?since=would not either, and its body stayed unsearchable until a fullaction:"refresh". On a saturated library (#39) or a rate-limited key that happened to every changed item on the page at once, with only a lowerfulltextItemsto show for it. The full-text source now says when a read failed instead of folding it into "no text", a failed read replaces nothing (the body passages the index holds are put back through the upsert), and the sequence that would have to offer the item again is held back: a gap on the delta's own items withholds the version stamp, and a gap in the catch-up withholds the full-text cursor. The status carries onefulltextReasonsentence saying so, and the nextaction:"update"retries. This is the full-text sibling of #63, and it follows the same rule. -
A second Zoteus process no longer undoes a finished index on its way out (#68). Two processes sharing one
ZOTEUS_DATA_DIRis the recommended setup for a first build: run one headlessly and let Claude Desktop read the result. The desktop app's index handle, open since before that build started, wrote its whole in-memory meta row back over the finished one when the app quit, putting the library version to 0, the library and embedder identity to empty, the full-text cursor to 0 and the checkpoint to nothing. Nothing errored and nothing logged, and the nextaction:"update"then found no stamp, fell back to a full build, found no checkpoint to resume from, and cleared the store: hours of crawling and embedding gone, by the same end state as #59 through another route. A flush now writes only the fields that handle changed itself and leaves every other field as it is on disk, and a durable pause writes its one flag instead of the whole row. The same handle also re-reads the store before deciding that a delta is impossible, so an index another process finished is no longer rebuilt from scratch by the session that never saw it; on the JSON backend a handle that indexed nothing leaves an artifact it did not write alone. A reset still writes its zeroed row whole, because the rows the old stamp described have just been deleted, and each case that keeps another process's values says so once in the log. -
A group library write now says what is missing instead of failing at the cloud (#74). Group writes have always been cloud-only (the desktop app's local API and the connector protocol both address the personal library and nothing else, even for a group the app is holding and reading key-free), but three things made that hard to act on. A call naming
library_type:"group"without alibrary_idfell through to the default library, so a request that plainly said "the group" read from, or wrote to, the personal library and reported success; it is now refused, pointing atzotero_groupsfor the id. A key that cannot write the target group only found out at api.zotero.org, as a 403 reading "your API key may lack permission for this library or operation"; the key's own access map is now checked first, so a read-only or group-less key is refused locally by name, with the settings page to fix it. And the 403 that remains (the group's own Library Editing setting, which no key overrides) now names all three causes rather than none. -
The
listsub-actions of the manage tools read the library they were given (#74).zotero_manage_collections,zotero_manage_tagsandzotero_saved_searchesall acceptlibrary_type/library_idand all ignored them whenaction:"list", answering from the personal library: a model looking for a group's collection keys got the wrong library's, and the write that followed used a collection key that does not exist there. -
A file moved to or from cloud storage gets a file's time budget, not a query's (#74). Attachment uploads and downloads over the Web API ran on the 25 s budget meant for a JSON request, so a large PDF (the cloud path is the only one a group library has for files) timed out with "narrow the query, lower the limit". They now allow five minutes, matching the desktop upload path.
-
mode:"semantic"no longer answers "No matches" when there is no embedder to turn the query into a vector (#7). Semantic ranking needs both ends of the comparison (vectors in the index, and a provider to embed the query), but the refusal only tested the first. An index built with an embedder and reopened withZOTEUS_EMBEDDINGS=offkeeps its vectors (they are unusable, not known-wrong), sohasVectorsstayed true, the refusal did not fire, and the query fell through to a vector ranker with no query vector and a keyword ranker closed bymode:"semantic". The result was a bareNo matches for "…", indistinguishable from a library that genuinely holds nothing on the subject;embedderNoticeis deliberately silent about a provider switched off on purpose, so nothing else explained it either. Semantic mode now refuses whenever no provider is configured at all, names which of the two halves is missing, and, when the vectors carry their provenance, names the provider that built them.autoandkeywordare unaffected. So is a provider that is configured and merely failed: it is still reported through the existing notice, and the next query ranks again as soon as it recovers, without an index rebuild. -
Stock
zotero_exportreads a desktop-served library with no cloud key (#75). Every stock format, and thebiblatexthatbetter-biblatexdegrades to, went to api.zotero.org unconditionally, which in key-free local mode isusers/0and a refusal. Both calls now take the same routed read aszotero_format_bibliography(#64): the desktop app renders the export for any library it serves, the Web API for everything else, with selectors and limit forwarded unchanged. The Better BibTeX branch and explicit cloud libraries behave as before.
Documentation
- A "Group libraries" section in
docs/writing.md: that group writes work but are cloud-only whatever the desktop app is doing, the three separate permissions a group write needs (a key, group write access on the key, and a group that lets you edit its library), how a numeric library id differs from a collection key, and that a group's collection keys must come from that group.
[1.16.0] - 2026-09-07
Fixed
- A paper with many annotations no longer crowds every other result off the page (#65).
Search ranks passages and answers with items, and between the two sat a fixed pool of
three passages per result asked for: an item whose forty annotations all ranked first
(#33) filled a pool of fifteen by itself, so
limit:5returned that one paper whilelimit:20returned the five relevant ones, in semantic, keyword and hybrid mode alike, for child notes as much as for annotations. The pool now doubles until the page holds the requested number of distinct items or the candidates are used up: a ranker that returns fewer than it was asked for is not asked again, the pool never exceeds the passages the index holds, and the query is embedded once for every round. A page the first pool fills costs exactly what it did before. - Writes and annotation reads follow the configured or explicit library, never silently
the personal one (#61). With
ZOTERO_LIBRARY_TYPE=groupandZOTERO_LIBRARY_IDset, every cloud write resolved its target from the API key's user id rather than from the configured default, sozotero_create_itemswith no per-call library wrote to the personal library;zotero_annotatewith an explicit group still looked the parent up in the personal library and failed with a 404 before writing; and the desktop shortcuts (local-API and connector writes, which can only ever reach the personal library) were gated on the absence of a per-calllibrary_id, so a configured group default went to the desktop's personal library too. The effective library is now resolved once per call (explicit arguments, then the configured default, then the key's own library) and passed through the parent and children reads, the PDF lookup and the write; the desktop paths apply only when that library is the personal one, and a group target with no cloud key fails clearly before anything is fetched or written. - A failed notes-and-annotations catch-up no longer advances the version stamp (#63).
When the child census behind
action:"update"failed (the keys-only versions request, the body crawl, or resolving annotated attachments to their items) the update swallowed the failure and stamped the newer library version anyway, so an edited note kept its old text searchable and an added note was never indexed until a rebuild; a census that stopped early even answered "no own words" for the affected items, and the update indexed that over the text it held. Own-words work that does not complete now withholds the stamp, exactly as an unreconciled deletion pass does, and a degraded census replaces nothing. The status carries oneownWordsReasonsentence saying so, and the nextaction:"update"repeats the delta and retries. - The Claude Desktop bundle is now one file per operating system, each carrying its own
native PDF dependency (#62).
pdfjs-distdraws through@napi-rs/canvas, whose skia binary is a separate npm package per OS and CPU that npm installs only for the machine it runs on, so the singlezoteus.mcpbpacked from a plainnpm cion the Linux release runner carried the two linux-x64 binaries under a manifest that promised darwin and win32 as well; there, importing pdfjs failed withDOMMatrix is not definedand exact-page extraction quietly fell back to approximate pages. The release now stages a tree per OS withnpm ci --os --cpuagainst the lockfile (both CPUs of each OS, no version repeated anywhere), narrows each manifest to the one OS it is for, and refuses to publish unless every archive holds a.nodebinary for each CPU the OS is promised on:zoteus-macos.mcpb,zoteus-windows.mcpbandzoteus-linux.mcpb, 32 to 57 MB each, where one file for every target would have been 103 MB. Verified by archive inspection and loader simulation on Linux; a run on a native macOS or Windows machine is still owed. - Item-key bibliographies render from the desktop app in key-free mode (#64).
zotero_bibliography item_keysandzotero_format_bibliography item_keysboth read straight from the cloud client instead of the router every other library read goes through, so withZOTEUS_LOCAL=onand noZOTERO_API_KEYthey asked api.zotero.org forusers/0, which it rejects as an invalid user id, although the same items were readable locally. Zotero 10 servesformat=bibitself, honouringstyle(fetching from the style repository whatever it lacks),localeandlinkwrapexactly as the cloud does, and servesformat=csljsonas well, so both reads are now routed: the desktop renders or exports for any library it serves, the personal library or a group it holds, and an explicit cloud library still goes to the cloud with style and locale intact. The supplied-CSL path ofzotero_format_bibliographynever touched a transport and is unchanged, and so is style and locale retrieval for citeproc. Two desktop differences are absorbed in the local client: its CSL-JSON is a bare array where the cloud's is wrapped in{ items }(both shapes were already accepted), and its?itemKey=answers with the named items and their children, so the PDF of a cited paper would have rendered as adocumententry of its own; a keyed export reads/items/top, which is exactly the items named, as on the cloud. ZOTEUS_LOG_FILEnow receives index build and update lines, not only HTTP requests (#59). The server created one logger with the file attached and handed it to the HTTP transport, but the tool context built a second one from the level and format alone, and that second logger is the one every index job, the embedder and the Zotero clients write through. So the file held request lines and nothing else, while build progress and the error that ended a build went to a stderr nobody was reading. The context now logs through the server's logger, and a context built without one still attaches the file itself.- A build no longer aborts with
UNIQUE constraint failed: passages.idwhen the library is edited while it is being paged (#59). Both Zotero APIs page newest-modified first, so an item anyone edits mid-crawl (another client's annotation, a tag change, a sync) moves to the front and shifts the page boundary: the item at the boundary is served again at the top of the next page, and its second copy reached a plain INSERT that the unique passage id refused, ending the whole build about 1,300 items into a 10,500-item library. The crawl now steps over items it has already indexed in this run, the children census behind the reader's own words dedupes by key the same way, and the insert itself isOR IGNORE, so a duplicate id can never abort a build: it is counted, skipped, and reported in one log line at the end, with the reminder that anything edited during the crawl belongs to the nextaction:"update". On SQLite each item is written under a savepoint as well, so a failure between an item's first and last passage rolls the item back whole instead of leaving a half-written item that a resume would step over as finished. action:"build"says when it replaces an existing index (#59). A build over an index that already holds rows is a rebuild from scratch and replaces those rows at its first commit. That is by design (the partial index is searchable at once and resumable from its checkpoint), but it surfaced in #59 as a 1,300-item partial index where a complete 97,000-passage one had been. The tool now states it when the build starts, with the size of what is being replaced, and namesaction:"update"as the path that leaves a complete index in place.
[1.15.0] - 2026-09-07
Added
- A durable pause for index work (#56).
zotero_index action:"pause"stops a running job and persists a hold even when the index is idle;build,refresh,update, and the semantic-search automatic build then refuse untilaction:"resume"explicitly clears it. Queries remain available, and resume clears the hold without starting work by itself. - An uninstall procedure,
docs/uninstall.md(#55). Zoteus has no host uninstall hook, so the removal surface is documentation: the one directory that holds everything it derives, per platform, what that directory contains, the pre-v1.10.0 case where model weights landed outside it, what is deliberately left alone (the Zotero library, cloud keys) and what Zotero itself remembers (the local-access setting, an "Always Allow" grant). ZOTEUS_LOG_FILE, a file every log line is appended to as well as stderr. The server in #59 ran under a Windows scheduled task whose stderr went nowhere, so when it stopped answering there was no record of what it had been doing. Same format as stderr (textorjson); a file that cannot be written is reported once and never stops the server from starting.
Fixed
zotero_get_itemnow honoursstyleandlocale(#58). Both arguments were accepted, documented and dropped on the floor: the request Zotero saw carried onlyinclude, soinclude:"bib"withstyle:"apa"and withstyle:"chicago"rendered the same entry, in Zotero's default style, every time. They are forwarded now, andstylegoes through the same alias tablezotero_bibliographyuses, so "apa", "chicago author-date", a CSL id and a CSL URL all work. When the desktop app serves the request this also means the styles it has installed, and any it can fetch from the repository, are usable from here without further plumbing.- "chicago" resolved to a CSL id the style repository no longer has (#58). The
repository renamed its Chicago styles for the 18th edition and
chicago-note-bibliographyis gone:zotero_format_bibliography style:"chicago"failed with a 404 andzotero_stylesreported Chicago unavailable, while the suite asserted the stale id. The alias follows the repository's own rename record tochicago-shortened-notes-bibliography(what zotero.org redirects the old id to, and what the desktop app renders by default), "chicago notes" and "chicago full note" name the full-notes variant, and a 404 on any id now consultsrenamed-styles.jsonbefore giving up, so an id copied from Zotero's preferences survives the next rename too. - The local embedding model no longer freezes the server while it runs (#59). With
ZOTEUS_EMBEDDINGS=local, every batch the model embedded blocked the whole process for as long as the inference took: onnxruntime-node'srun()is a synchronous native call behind asetImmediate, so the event loop got exactly one turn between batches. On a large model at full precision that is seconds to tens of seconds per batch, and for as long as a build, an update or a catch-up was embedding, the HTTP server answered nothing: a plainGET /mcpcould slip into the gap between two batches, aninitialize, which needs several turns, timed out, and the process sat at every core the runtime could take while reporting no progress. It looked like a hang after a completed build; it was the next job embedding, with nothing able to say so. The model, the pipeline and every inference now live on a worker thread: the same call blocks only that thread, the server keeps answering, and a query that lands mid-build waits for the batch in flight and no longer. Concurrent callers share one model instead of each loading their own, a worker that dies is reported and replaced on the next call, and a runtime that cannot start one falls back to the old in-thread loader with a warning. Verified inside an Electron 42utilityProcess, which is how Claude Desktop runs the server, as well as under plain Node.
[1.14.0] - 2026-09-04
Added
-
An opt-in usage log, so an operator can see how their own server is used (
ZOTEUS_USAGE_LOG). Until now a running Zoteus said almost nothing about itself: the only trace a tool call left was a line in the catch block when it failed, and/metricsoffered four unlabelled counters that reset on every restart. One of them,tool_calls_total, did not even count tool calls: it countedPOST /mcp, so aninitialize, atools/listand a batch of five calls were all worth one.Set the knob and every tool call and request is recorded in
<data dir>/usage.sqlite: tool name, outcome, duration, Zotero user, client and session. Raw events are pruned afterZOTEUS_USAGE_RETENTION_DAYS(30); the daily per-tool, per-user rollup they fold into is about a kilobyte a day and is kept.scripts/usage-report.tsprints it, from the file or from a running server overGET /usage.json.It is off by default and nothing is ever transmitted: there is no upload, no third party, and no endpoint that serves it without a token. Argument values are never recorded, only their names, types and sizes, so a search string cannot be reconstructed from the log; error messages are not recorded either, only a class such as
zotero_4xx, because a message can quote library content.ZOTEUS_USAGE_IDENTIFYchooses between the Zotero user id, a salted hash of it, and no caller identity at all. -
ZOTEUS_METRICS_TOKEN, a bearer token for/metricsand/usage.json./metricshas always been unauthenticated, which on a reachable deployment publishes exactly how much the service is used to anyone who asks. Unset keeps the old behaviour, and the server now warns at startup when metrics are enabled on an OAuth deployment without one. -
Per-tool counters and a latency histogram.
zoteus_tool_calls_total{tool,outcome},zoteus_tool_duration_ms(buckets at 5/25/100/500/2000/10000 ms),zoteus_http_requests_total{route,status_class}and# TYPEheaders. Latency was measured before and thrown away:mswas logged and never aggregated. -
The local model's weight precision is selectable, and it is part of the vector identity (#43).
Xenova/multilingual-e5-smallis the answer for a multilingual library, but at full precision it is 465 MB on disk, which is the difference between comfortable and marginal on a Chromebook where ChromeOS, a browser and the Linux container share a few gigabytes.ZOTEUS_EMBEDDING_DTYPE=q8loads the quantized graph instead: 129 MB, measured, of which 113 MB is the ONNX file and 16 MB the sentencepiece tokenizer that is the same at either precision.Xenova/all-MiniLM-L6-v2goes from 87 MB to 23 MB the same way.fp16,int8,uint8,q4,q4f16,q2,q2f16,q1,q1f16andbnb4are accepted too, because a repository can publish any of them.Above
fp32the precision joins the persisted embedder identity, which was the stated precondition for offering this at all:local:Xenova/multilingual-e5-small@q8is a different vector space fromlocal:Xenova/multilingual-e5-smalland can never be mistaken for it, so switching precision drops the old vectors with a notice and costs onezotero_index action:"build", exactly as switching model does.fp32stays unsuffixed on purpose: it is what every local index ever built holds, and spelling it@fp32now would declare all of them stale over a setting nobody touched. Unset also passesfp32to the pipeline explicitly rather than leaving the choice to the package, solocal:<model>keeps meaning one precision even if a future transformers.js changes its own default.Measured on the same German/English probe as the model change above:
q8ranks the German answer first for all four questions, as fp32 does, and its English twin 2.5th on average against fp32's 2.0th. MiniLM ranks that twin 9.5th, so the precision costs a fraction of what the model buys. This agrees with the six-model benchmark in #43, where the E5 family was the only one whose negative controls stayed clean at every quantization level (and wheregranite-97m, by contrast, collapsed on several lanes at 8-bit).A dtype is a file the repository has to publish, not a conversion Zoteus performs:
q8asks foronnx/model_quantized.onnx, and theXenova/mirrors carry the full suffixed set while a model's own repository frequently carries the fp32 graph alone. Askingintfloat/multilingual-e5-smallforq8therefore fails, and it now fails with a message naming the setting, the file and the mirror that does serve it, rather than a bare 404 on a URL.ZOTEUS_EMBEDDING_DTYPEis on-device only; setting it under an API provider logs that it is ignored, because that provider's precision is decided on its own hardware. There is a matching field in the desktop extension's settings pane.
Changed
-
JSON logs carry real fields.
ZOTEUS_LOG_FORMAT=jsonused to stringify the structured object intomsg, so a line read{"level":"info","msg":"http {\"status\":500,...}"}andjq 'select(.status >= 500)'matched nothing. A trailing object is now spread into the record as top-level keys. -
A logged
Errorsays what it was.redactArgswalked an Error's enumerable own properties, of which there are none, so every error passed as an object reached the log as{}. It is now rendered asname: message. -
zoteus_tool_calls_totalnow counts tool calls. What it counted before (POST /mcp) is still there under its right name,zoteus_mcp_requests_total. A dashboard reading the old name needs updating. -
404s on paths this server does not have are counted as scans, not errors. A public instance takes a steady trickle of bots probing
/credentials.json,/key.jsonand friends; they were the bulk of the 4xx, which made the error rate a measure of the internet's weather. They now incrementzoteus_http_scanner_requests_total, log atdebug, and are kept out of the usage log. -
Every tool now says whether it destroys anything, and several were saying the wrong thing by omission. MCP's
destructiveHintdefaults to true when a tool is not read-only, so leaving it unset told every client to assume the worst about eleven of the thirteen writing tools,zotero_create_itemsandzotero_importincluded. Each was read against its handler rather than its name, which changed three answers from what the obvious guess would have been.zotero_annotateis not destructive: itsdeletesetsdeleted: 1and lands in the reversible trash rather than issuing the erasing DELETE.zotero_create_itemsis: its own description offers "include itskeyand currentversion" to update, so one call can overwrite an existing item's fields in place.zotero_attachmentis too, but for a reason outside Zotero entirely:downloadwrites to the local filesystem.zotero_semantic_searchkeepsreadOnlyHint: true; its auto-build writes only Zoteus's own derived index, which is the same judgment that putzotero_indexon the read-only allowlist, and flipping it would drop semantic search out of everyZOTEUS_READ_ONLYdeployment. -
The update check is off by default, and the desktop bundle can switch it on (#54).
ZOTEUS_UPDATE_CHECKdefaulted totrue, so a default install did one unauthenticatedGET https://api.github.com/repos/oscardvs/zoteus/releases/latestat startup, once a day, before anyone had been asked. Nothing user-identifying went with it, and nothing about the library did either, but it was still the only request Zoteus made on its own initiative, and the one class of user this project is built for is the one who runs it against their own library precisely so that nothing leaves the machine. It now defaults tofalse.The check exists for a real reason and keeps it: a manually installed
.mcpbhas no auto-update channel, so without the check it never learns a newer version exists. So the desktop bundle gains a Check for updates switch in its settings pane, defaulting to off, which is the first time that knob has been reachable from a.mcpbinstall at all. Reported by MinhHaDuong, with the whole causal chain cited line by line.
Security
-
A caller-supplied filesystem path reached the operator's disk on a shared deployment. Four tool arguments took a path and used it as given:
zotero_attach_file'spath,zotero_attachment'sfile_pathandsave_path, andzotero_tag_audit'svocabulary_path. On a single-user install that is the tool working as intended, because the caller owns the machine. On an OAuth deployment the caller is not the operator, and those paths pointed at the server's filesystem.So an authenticated user of a hosted instance could read any file the process could read, by attaching it into their own library and downloading it again, and write any file the process could write, by uploading bytes and then naming a destination. On a multi-tenant server that reaches the encrypted per-user token store under the data directory and the process environment, which is where the secret that encrypts that store lives; the write side reaches the server's own
dist/, which is what the entrypoint executes.Paths from a caller are now resolved through
resolveCallerPathand, whenever the caller is not the operator (any per-user context, and any deployment with OAuth enabled), must land inside the data directory. Resolution follows symlinks, so a link planted inside the data directory cannot step out of it, and prefix matching is on a path boundary, so/data-evildoes not pass for/data. A caller-suppliedsave_pathalso refuses to replace a file that already exists unless the newoverwrite: trueis passed; the default download location is exempt, because that one is Zoteus's own cache for the attachment key and re-downloading over it is the ordinary case. Nothing changes for a stdio install:zotero_attachmentcan still write to~/Desktop, which is the point of the tool.The container also no longer runs as root. It ran as uid 0, which is what turned the write into code execution rather than a nuisance; it now runs as the image's unprivileged
nodeuser. Upgrading an existing deployment needs one manual step: a volume mounted at/dataholds root-owned files written by the old image, sochown -R 1000:1000it once before starting the new container.Found while auditing every tool's MCP annotations for the Claude Connectors Directory submission, not from a report, and there is no evidence it was exploited.
Fixed
-
/metricsand/usage.jsonwere reachable from the public internet with one trailing slash. The 404 block added in 1.13.0 matched exact paths, and Express routes non-strictly: it serves/metrics/from the same handler as/metrics. Socurl https://host/metricsanswered 404 whilecurl https://host/metrics/returned request counts, tool-call volume and issued-token counts to anyone who asked./usage.json/reached the application the same way and only escaped notice because 1.13.0 has no such route, which means shipping the usage log would have turned that one into a 200 as well. The matcher is nowpath /metrics* /usage.json*, matching by prefix; Caddy's path matcher is case-insensitive, so/METRICS/is covered too. Verified against a live instance across every path shape: bare, trailing slash, double slash, dot segment, query string, uppercase and a trailing path segment./healthzand/readyzstay public, as before. -
npm i github:oscardvs/zoteusinstalled a package whosebindid not exist.dist/is gitignored and onlyprepublishOnlybuilt it, and npm does not runprepublishOnlyfor a git dependency: it runsprepare, which the package did not have. So a git-URL install, the one this project has pointed people to for trying an unreleased fix, produced azoteusbinary pointing at a file that was never built.prepareis now defined. Bothnpm cilayers in the Dockerfile take--ignore-scripts, because each runs beforetsconfig.jsonandsrc/are copied into the image and would otherwise try to compile a source tree that is not there yet. -
The privacy policy denied that the project runs a hosted service.
PRIVACY.mdclaimed Zoteus has "no servers operated by the project" and that "the Zoteus project does not operate any hosted instance for the public". Both were true when they were written and neither has been sincemcp.zoteus.comwent live as a paid tier. The policy now says what it actually governs, which is the software you install, and points at zoteus.com/privacy as the controlling policy for the hosted service. The desktop bundle lists both, hosted-aware one first. -
server.json's description fits the MCP registry's 100-character cap. A README rewrite had pushed it to 150, which the registry rejects with a 422 at publish time rather than at validation, so it only surfaced mid-release. -
deploy/Caddyfileno longer publishes/metricsto the internet. The shipped proxy config was a blanketreverse_proxy, so every ops endpoint was public: on a live instancecurl https://host/metricsreturned request and tool-call volume to anyone who asked./metricsand/usage.jsonnow answer 404 from outside, inhandleblocks so the ordering does not depend on Caddy's directive ranking;/healthzand/readyzstay open.docker-compose.ymlalso caps container logs at 3 x 10 MB, which Docker's defaultjson-filedriver does not do at all. -
The local pipeline pools each model the way it was trained, instead of mean-pooling every model it is handed.
ZOTEUS_EMBEDDING_MODELcan name any transformers.js feature-extraction model, and the one pipeline call pooled all of them withmean: right forXenova/all-MiniLM-L6-v2and the E5 family, wrong for roughly half the multilingual field, which is trained on the[CLS]token. The wrong pooling never fails, it retrieves worse: measured on a 257-passage, 68-query cross-lingual set with pooling as the sole variable at fp32,meancostsgranite-embedding-97m-multilingual-r227.5% of its MRR and 34.6% of its hit@1,gte-multilingual-base12.7% and 10.3%,arctic-embed-m-v210.3% and 14.7%.The value cannot be detected the way an E5 prefix is: it lives in
1_Pooling/config.jsonon a model's source repository, and the ONNX mirrors the pipeline loads (Xenova/*,onnx-community/*) do not republish it. So it is curated: a table inembeddings.tsmaps each known model id, mirror and source alike, to its pooling, and every row names the repository the value was read from. A model the table does not list keepsmean, which is exactly what it got before, so no existing install changes and no model is refused; the default model's vectors were byte-compared before and after.ZOTEUS_EMBEDDING_POOLING(auto,mean,cls) is the escape hatch for a mirrored or renamed checkpoint the table cannot speak for, in the same positionZOTEUS_EMBEDDING_PREFIXESoccupies for the prefixes.A pooling that is not the default joins the embedder identity, exactly as a precision above
fp32does and for a sharper reason: two poolings of one model are as different a vector space as two models are, and unlike two models they share a dimension, so the width check that catches a foreign vector cannot see this one.local:<model>#clsis what the tenclsmodel families in the table now stamp, over seventeen ids counting each mirror.Every model the table pools the default way keeps the identity it always had, the default model included, so an index built with one of those is untouched. The exception is an index built with one of the
clsmodels under 1.13.0, the only window in which that was possible: it holds mean-pooled vectors under an identity without the suffix, so it is dropped with the notice the server already emits and onezotero_index action:"build"re-embeds it.ZOTEUS_EMBEDDING_POOLING=meanreproduces the old identity for anyone who would rather defer that. Setting the override is therefore not free either: it makes a different vector space and costs the vectors that were there.
[1.13.0] - 2026-09-03
Added
-
The local embedding model is now yours to choose, and multilingual libraries have a model that works (#43).
ZOTEUS_EMBEDDINGS=localalways loadedXenova/all-MiniLM-L6-v2, a constructor default no setting could reach, and that model was trained on English sentence pairs. On a mixed-language library it ranks by language before topic: measured here over a 12-passage German/English corpus and four German questions, MiniLM put the German passage that answers the question first every time, and its English twin 9.5th of 12, below every unrelated German passage. A German question therefore never surfaced the English paper on its subject.ZOTEUS_EMBEDDING_MODELnow names the model of whichever provider is active,localincluded, instead of only the API ones. That knob already meant "the model of the active provider", it already lives in the desktop settings pane and the manifest, and the index already refuses to rank vectors from one model against queries from another, so a separateZOTEUS_LOCAL_EMBEDDING_MODEL(as the issue proposed) would have been a second spelling of a setting that exists, with its own field, its own docs and its own way of disagreeing with the first. Unset still meansXenova/all-MiniLM-L6-v2, so nothing moves under an existing install. Setting it toXenova/multilingual-e5-smallmoved that English twin from 9.5th to 2.5th, at the same 384 dimensions and so at exactly the same index size. A variable namedZOTEUS_LOCAL_EMBEDDING_MODELis not silently ignored either: the server logs that it is not a setting and names the one that is. -
E5 models get the prefixes they were trained with, without anyone having to know that. The E5 family embeds asymmetrically: a question is
query:plus the question, a document ispassage:plus the document. Leave the markers off and nothing fails, the retrieval is just quietly worse, which is the kind of loss nobody ever traces back to a missing string. Zoteus now applies them for you when the model id carriese5as a segment (Xenova/multilingual-e5-small,intfloat/e5-base-v2, notsentence-t5-base), which meant teaching the embedder API which side of a search a text is:embed()takes aquery/passagekind, the index build passespassage, a semantic query passesquery, and the passage re-ranker insidezotero_get_fulltextnow embeds its question and its candidates separately rather than in one batch. The prefix reaches the model and nothing else: it is not stored with the passage and not part of the embedder identity, solocal:Xenova/multilingual-e5-smallstill identifies the vector space by model alone.ZOTEUS_EMBEDDING_PREFIXESoverrides the detection in both directions (off,e5, defaultauto) for a mirrored checkpoint whose name does not say what it is. -
The rate math a build is running at, where the person watching it can read it (#48). Whether an API embedding provider will throttle a build is a sum of the batch size, the pause between requests and the tokens a passage carries, and until now nothing Zoteus printed mentioned a rate at all: the reporter worked it out from OpenAI's dashboard against a reading of
dist/config.js.zotero_index action:"status"now carriesembedRatefor an API provider (batch size, pause, estimated tokens per request at four characters per token, and the tokens per minute the build is actually sustaining), the server log prints the same line when the full-text pass begins, and the status summary speaks up when the measured rate reaches 800,000 tokens/min or a single request approaches the 300,000 OpenAI rejects whole. Status also reportspassagesWithoutVectors, so a half-embedded index is no longer indistinguishable from one with no vectors at all, and bothzotero_indexandzotero_semantic_searchname the remedy:action:"build", which resumes, and neveraction:"refresh", which pays for every vector a second time. -
ZOTEUS_EMBED_BATCH_SIZEandZOTEUS_EMBED_BATCH_DELAY_MSare documented where a failing build sends you (#48). Both were indocs/configuration.mdanddocs/semantic-search.mdalready, and the reporter still found them only by readingdist/config.js, which says the tables were not the problem. They are now in the README's own configuration table, in a README note about embedding a large library through an API, in the descriptions of the desktop-extension settings that set them (each naming the other, since neither dial works alone), and in thezotero_indextool description the model reads before it explains a failure.docs/semantic-search.mdgains a When a build gets rate-limited section with the retry policy, the resume behaviour, and theZOTEUS_EMBED_BATCH_SIZE=256/ZOTEUS_EMBED_BATCH_DELAY_MS=8000pairing that carried the reporter's library through in one uninterrupted 45-minute run at about 400,000 tokens/min.
Changed
-
The documented download size for local model weights was low by a factor of three. "~25 MB" described a quantized build;
@huggingface/transformers4.2.0 fetches the full-precision ONNX weights, which measure 90 MB for the default MiniLM and 470 MB forXenova/multilingual-e5-small(the issue's 118 MB figure is the quantized variant).docs/semantic-search.mdnow gives the measured numbers, and says why Zoteus does not pick a quantized variant for you: a dtype does not appear in the embedder identity, so an index silently rebuilt at another precision could not be told apart from one that was not. -
The keyword index keeps diacritics. It used to strip them from every token on both sides (
remove_diacritics 2), which in a multilingual library merges distinct words rather than normalizing spelling: Vietnameseán,bé,thểandthếall landed on Englishan,beandtheand could not be searched for at all. Each word is now indexed exactly as written (remove_diacritics 0), an accented query is answered exactly, and an unaccented query still reaches accented documents by expanding to the accented spellings the library's vocabulary holds (theorieruns astheorie OR théorie) — but only where those spellings dominate the typed one in this library, so a common word is never dragged toward its rare accented siblings. Nothing extra is indexed, so ranking is untouched for queries that need no expansion. Expansion is optional (ZOTEUS_ACCENT_EXPANSION, defaulttrue): it compensates the recall that keeping diacritics removed for unaccented queries, and disabling it opts into strict as-typed exactness, at query time only — no rebuild either way. Search semantics change accordingly:théno longer answers asthe, andsorenstill does not answer tosøren(øis a letter, not an accent). Existing SQLite indexes are migrated in place on first open (schema 1 → 2: the keyword table is re-tokenized; no vectors are re-computed and nothing re-reads Zotero). A migrated index cannot be opened by an older build — downgrading sidelines it and starts an empty one, so the library would need a rebuild there. -
The common-word list is measured from the library instead of shipped with the code. The 29 hard-coded English function words are gone. At the end of a full build the SQLite backend scans the keyword index's own term vocabulary (
fts5vocab) and stores, inmeta, the terms appearing in 30% or more of the passages; a delta update rederives the list only when the passage count has drifted by more than 10%. The list is applied to queries only — both backends keep indexing every term — and the in-memory backend answers from its resident document frequencies, storing nothing. An index built by an earlier version prunes nothing until its next build or update, at which point it adopts a list of its own: nothing is stranded, no rebuild is forced, and the schema version does not change. One behavior changes with the list's provenance: a query in which no term survives the prune now runs as typed instead of returning nothing, because a measured list can hold the library's own subject words, and silence would be a worse answer than a slow one.
Fixed
-
The vector salvage no longer reuses vectors another library wrote (#44). The salvage a schema sideline arms (#34) matches a rebuilt passage against the moved-aside index on passage id plus byte-identical text, and a passage id is an item key and a chunk number. Item keys are unique within a library rather than across libraries, so that match is an identity only once both sides are known to be the same library's rows, and nothing in the salvage path established that. It is armed inside
sideline()at file open, before any build has said which library it is crawling, and the fresh index that replaces the moved-aside file is deliberately unstamped, which is exactly the stateassertLibraryexempts: two gates on one file, and only one of them knew about libraries. Reaching a wrong vector took a conjunction (a schema-triggered sideline of one library's file, a build for a different library against the fresh file that replaced it, the same embedder, an item-key collision across the two libraries, and byte-identical passage text), which is remote enough that nobody has hit it, and it was untested rather than known-safe. The sidelined file's own library stamp now travels with it as a vector source, and the first passage a build offers the salvage is judged against it: a mismatch disarms the salvage for the rest of that build, says so once on theINFOchannel naming both libraries, and those passages are embedded instead, which is the cost the rebuild would have paid anyway. Nothing else about the sideline changes: the moved-aside file is still kept, still complete, still named in the notice. A sidelined file carrying no stamp keeps salvaging, deliberately, on the same reasoningassertLibraryuses for a pre-stamp index: it says nothing about whose rows it holds, and refusing on that unknown would charge every index written before the stamp existed a full re-embed to guard against a collision nobody can demonstrate. -
One OpenAI
429no longer ends an index build (#48). The embedding request path had no retry at all: a single rate-limit answer flipped the embedder off, and the build carried on writing passages BM25-only to the end. A429, a5xx, a timeout or a dropped connection now waits and tries again, exponentially from 1 s with jitter, honouringRetry-Afterin either form the header takes, capped at 60 s per wait and about three minutes of waiting per request;ZOTEUS_EMBED_MAX_RETRIESsets how many attempts that is, and0restores the old behaviour. A400is still fatal on the first answer, deliberately: that is OpenAI's reply to a request carrying more tokens than it accepts, and the batch would be exactly as oversized on every retry, so retrying it would turn an instant, actionable failure into a slow one. The default pause between requests stays0. Backoff is what a library that never approaches a limit needs, and no one standing pause is right for both a Tier 1 account and a Tier 5 one; the pause is for a build that has been told it is riding the ceiling, and now it gets told. -
A build whose embedder gave up keeps its place instead of starting over (#48). The reporter lost the full-text pass of a 10,428-item library six times running, and every retry re-embedded all 87,000 passages, which is the expensive half of the job. Two faults produced that. A provider failure never made the build unfinished: it reported
state:"done"and deleted the very checkpoint that would have let the next build carry on. And even with a checkpoint, nothing could find the passages that were committed without a vector, because they are indexed, so the crawl steps over their items by key and the full-text pass steps over them byhasFulltext, both correctly. Now a build the embedder died in keeps its checkpoint and withholds the library version stamp (so anaction:"update"falls back to the build that resumes, rather than running a?since=delta that finds nothing to do and freezes the index half-embedded for good), and a resumed build finishes by asking the store for committed passages carrying no vector, 500 at a time, and buying exactly those. No page is re-fetched, no PDF is re-read, no passage is re-chunked.action:"refresh"still starts over, as it always has. The persist cadence is unchanged and was measured rather than assumed: at about 8 passages per item, the 60-second trigger commits roughly 1,900 passages of work, and a SQLite commit of a 372 MB full-text index costs 56 ms against the 4.2 s the JSON backend spends re-serializing one, so halving the item trigger would double the dominant cost on one backend and bound the loss no more tightly than the clock already does. -
A query made mostly of common words returned a confident wrong answer instead of an honest one.
tokenize()dropped 29 English function words from every query, andto be or not to beis all of them exceptnot— so the search that ran was a single-term OR on a word that means nothing, and what came back was whatever prose happened to contain it. Not an empty result, which would at least have been honest. Pruning now stops when it would change the question rather than shorten it, and the list moved off the document side:tokenize()is also the in-memory backend's document tokenizer, so the list was deleting those terms from the index, and a term that is not indexed cannot be searched for even deliberately. Both backends now index every term and only queries prune; ordinary queries are unaffected. The list a query is pruned by is measured from the library in this same release, so what survives that rule is now a property of the corpus rather than of English (see above). -
A migration that failed for a transient reason discarded an intact index. Any error inside the schema-upgrade ladder — a full disk as much as a corrupt page — used to be treated as a foreign schema: the database was moved aside and a fresh empty one silently took its place. A non-corruption failure now leaves the file untouched at its old version and search refuses with the reason; the upgrade is retried on the next open. Only corruption still sidelines the file. That refusal also declines the rebuild that would undo it:
zotero_index action:"build"repairs an unreadable index by deleting it, so a refusal whose remedy is a restart names no file to delete, and the call a user makes after reading it cannot discard the intact database. Deriving the query-expansion map is guarded on the same rule, being derived state like the binary vector codes: a vocabulary scan that fails for a transient reason leaves the map as it was and costs unaccented queries their expansion, where it used to stop the server from starting at all. -
npm run typecheckwas green on 15.7k lines of tests it never compiled (#49). The build project is the only project this repo had, and it is shaped for emit:rootDir: "src",include: ["src/**/*"],exclude: [..., "tests"]. So the gate that CONTRIBUTING asks every contributor to keep green, and that runs in CI and on the deploy path, saw none of the test suite: 100 files and 15,754 lines at v1.12.0. Vitest did not cover the gap either, because under its SSR transform a missing export arrives asundefinedinstead of throwing at import time: a test that imports a symbol a rename deleted passes the compiler and the runner both, and only fails if the symbol is actually called. A renamed type, or a symbol read but never invoked, could have stayed broken indefinitely. There is now a second project,tsconfig.test.json, extending the first withrootDir: ".", emit off andtests/included, behindnpm run typecheck:tests; it is a blocking step inci.ymlanddeploy.ymlfrom this release, and CONTRIBUTING names it in the gate. The two scripts stay separate on purpose. Widening the build project to reach the tests would have meant giving up itsrootDir, andnpm run buildwould then scatter.js,.d.tsand.js.mapfiles next to the test sources. -
The 89 real type errors that gate was hiding are fixed (#49). Nineteen test files, none of them wrong at run time: the suite passed identically before and after. Five were genuine drift, fixtures explicitly annotated
: Capabilitiesor: ToolContextthat were never updated whenlocalGroupIds,reopenSearchIndex,fetcherandsearchIndexPathjoined those types. The rest were untyped JSON bodies offres.json(), which isunknown, andvi.fnmocks declared with no parameters and then read back by argument index, wheremock.calls[0][2]is a compile error against an inferred zero-length tuple.noUncheckedIndexedAccessis off in the test project and only there. Under it the suite reports 204 errors instead of 89, and 115 of those are index accesses liketext.split(' ')[0]inside an assertion, where an undefined index throwing is the check being made; answering all 115 with!would have added noise and no safety.src/keeps the flag on. -
toJSON/loadFromJSONstay off theSearchIndexinterface (#49). Six tests read them through a factory annotated: SearchIndex, which does not declare them, and the tempting fix was to widen the interface. That would have forced the SQLite backend, which writes rows and has no snapshot to hand back, to stub two methods it cannot mean. The narrow contract already exists and is already named:JsonIndexinfeatures/search/persistence.ts. So the test factories now returnMemorySearchIndex, the concrete JSON backend they were constructing all along, and no runtime type moved. -
Full-text index builds work inside Claude Desktop again, and the crash that stopped them is diagnosed (#37). Through 1.12.0 an
action:"build"that reached the attachment full-text pass took the whole server process down inside the desktop app, with no thrown error, no stack, no out-of-memory report and nothing on stderr. That was reproduced outside Claude Desktop and taken apart: a prebuilt Electron 42.10.0, the desktop app's ownmcp-runtime/nodeHost.js, the sameutilityProcessfork (--utility-sub-type=node.mojom.NodeService) and the same JSON-RPC bridge over aMessagePort. The child dies of SIGTRAP, which is Chromium's deliberate crash rather than a fault, and the crash report the desktop app itself filed for the original failure says the same thing on the same utility sub-type. Chromium replaces the process allocator, and an allocation it will not serve is not handed back as null for the caller to deal with: it takes the process down immediately, before any handler runs. That is the whole reason the death was silent, and why nouncaughtExceptionhook would ever have caught it.What asked for that allocation was the on-device embedding model, and three runs separate it from everything else. The same full-text build under Electron with no embedder ran to completion, all 262 items and 18287 body passages written to SQLite at a peak RSS of 283 MB, which clears the crawl, the concurrent attachment reads, the SQLite write path and the persist cadence in one run. With the local embedder it died in 14 seconds. And with no Zotero and no SQLite anywhere near it, loading
@huggingface/transformersin a bareutilityProcessand calling the feature-extraction pipeline on batches of growing length reproduces the crash on its own: it embeds 32 passages of 512 characters and 32 of 1200 characters happily, then dies insideextractor()on a batch whose sequences reach the model's 512-token limit. The identical loop under standalone Node finishes, at 2 GB RSS, having never been refused an allocation.So the size of one pipeline call is the whole story, and that size is batch times sequence squared.
all-MiniLM-L6-v2computes a batch by 12-head by sequence by sequence attention tensor: 32 passages at 512 tokens is about 400 MB in a single block, which onnxruntime's arena asks for in one piece. Metadata passages are chunked at 512 characters, roughly 128 tokens, so the same batch of 32 needs about 25 MB and never comes close. That is why the metadata pass embedding thousands of passages first proved nothing about the native layer, and why the full-text pass, chunked at 1200 characters and dense enough to reach the token cap, was the one that always died.The fix is a bound rather than a gate: under Electron the local embedder takes 8 passages per call instead of 32, putting the largest tensor it can ask for at roughly 100 MB, a quarter of the size measured to crash. Only the local provider is capped and only under Electron, because an API provider's batch is an HTTP request body and allocates nothing large in this process. A
ZOTEUS_EMBED_BATCH_SIZEbelow the cap is honoured; one above it is lowered, with a line in the log saying so, since there is no throughput past a process that has died. With that in place the build this issue was filed about runs to completion inside autilityProcess, on the same library, with the model in the same process.
Removed
- The Electron full-text refusal and
ZOTEUS_ALLOW_ELECTRON_FULLTEXT(#37). 1.12.0 refused the pass outright and offered that variable as an escape hatch. It was the honest thing to ship while the cause was unknown, and it is the wrong thing to keep now that the crash has a mechanism and a bound: refusing the headline feature in the primary distribution channel would cost more than it saves. The setting is gone from the extension manifest and the variable is no longer read; an install that still sets it just starts normally. Building headlessly against the sameZOTEUS_DATA_DIRremains the faster route on a large library, since a build inside the app is somewhat slower than the same build in a terminal, and it produces exactly the same index either way.
[1.12.0] - 2026-08-31
Fixed
- The desktop instructions for on-device embeddings named an install Claude Desktop
cannot see (#38). The "Local embeddings path" field told users to run
npm i -g @huggingface/transformersand paste whatnpm root -gprints. Claude Desktop does not run the server with the Node on the user'sPATH; it uses its own built-in one (isUsingBuiltInNodeForMcp is true, in the app'smain.log). So under nvm, which is how most people have Node, the global root belongs to a runtime that never executes this server and holds onnxruntime binaries compiled for that other runtime: the documented path steered users straight into the failure branch the code already anticipates, and a later nvm switch silently broke a path that had been working. The manifest, the README,.env.exampleand both docs now recommend a directory of its own instead (mkdir -p ~/.zoteus-deps && cd ~/.zoteus-deps && npm init -y && npm i @huggingface/transformers, then point the setting at~/.zoteus-deps/node_modules), which belongs to no version manager and survives extension updates and Node upgrades alike. - The size warning was low by about 300 MB (#38). "onnxruntime's native binaries run to
~380 MB across platforms" priced the binaries alone; the resolved dependency tree measures
686 MB on Linux x64 against
@huggingface/transformers4.2.0. Anyone planning disk space got a number off by nearly half. Every copy now says about 700 MB, and says it is the whole installed tree rather than the native part of it. - A local-embeddings failure now names the path it was given.
ZOTEUS_TRANSFORMERS_PATHlives in a settings pane and appeared in no message the reader could see, which made "not installed" unfalsifiable: an absent package and one sitting in another directory produced the same sentence. The unavailable reason (zotero_index action:"status",zotero_whoami, and everyzotero_semantic_searchnotice) now quotes the configured directory and thelib/node_modulesreading of it, and the branch for a package that resolves but throws on import reports the file it loaded plus the Node version, platform and architecture it loaded it under. That last pair is the whole diagnosis for a package installed under the wrong Node: it resolves perfectly, then fails on a binary built for a runtime that is not this one. - "Set 0 for no cap" looked like a rejected value in Claude Desktop (#38). The number
input will not render or retain a displayed
0, so "Full-text characters per item" blanks itself the moment you leave it and users reasonably concluded the setting had not taken. It had: the value persists and the server reads it as "no cap". The field description anddocs/configuration.mdnow say so, and offer a very large number to anyone who would rather read back the value they set. Blank deliberately keeps meaning the default, 40000, whether or not full-text indexing is on: reading it as "no cap" would have uncapped every install that turned full text on and never touched the dial, turning a bounded build into a crawl of whole books. - A full-text build no longer drives Zotero's local API into the ground, and says so if
it ever does (#39). The full-text pass fetched attachment bodies four at a time
whichever API was serving it, and the two do not tolerate load the same way. The cloud
Web API is a fleet that answers a burst with a
429and aBackoffheader the fetcher already honours; the desktop local API is a single process, sharing itself with Zotero's UI, its sync engine and its own PDF indexer, with no rate limiter at all. Four continuous body reads were enough to stop Zotero 10 answering on port 23119 within 60 to 90 seconds on a 358-attachment library, and because local-API reachability is a session-wide capability, that dropped every read and write onto the Web API: slower, rate-limited, needing a cloud key, and liable to leave the startup capability probe rate-limited too. So the default is now chosen by the API serving the crawl, 2 for the desktop app and 4 for the cloud, withZOTEUS_INDEX_FULLTEXT_CONCURRENCYto override both. That number is still a guess about somebody else's machine, so it is not the whole fix: a build that watches the local API go down while it is reading from it backs off to one fetch at a time for the rest of the job, without restarting, so the app can recover instead of being held down for however many hours the crawl has left. - A build that degraded to the Web API stops being invisible. The fallback works, which
is exactly the problem: nothing errors, nothing fails, and all the user sees is a build
that has quietly become several times slower, explained only by one
INFOline on stderr that desktop hosts discard.zotero_index action:"status"now reportslocalApiDegradedAt, the moment this job saturated the desktop app, and the summary says in words that the session fell back to the Web API, that the crawl has throttled itself, and which dial to reach for if it keeps happening. It is scoped to the running job: a crawl the cloud was serving never reports it, an app closed between builds is nobody's degradation, and each build reports on itself rather than inheriting the last one's.
Changed
- A full-text index build is refused inside Claude Desktop instead of killing the server
(#37). Claude Desktop runs a bundled
.mcpbextension inside its own process, an ElectronUtilityProcesson Electron's embedded Node, and there anaction:"build"that reaches the attachment full-text pass takes the whole server process down partway through: no thrown error, no stack, no out-of-memory report, nothing on stderr, justServer transport closed unexpectedlyin the host's log. The identical build over the identical library, index file and environment runs to completion under standalone Node in about twelve minutes, and the metadata pass, which embeds thousands of passages through the same on-device model first, is never the one that dies. The cause is not known. It sits below the JavaScript layer, on a runtime Zoteus does not ship and cannot reproduce against, so this is a mitigation and not a fix: rather than guess at the native layer, Zoteus now refuses the one pass known to take the process down. Under Electron a build or refresh that asks for full text (byfulltext:true, or byZOTEUS_INDEX_FULLTEXT/ the "Index PDF full text" setting) returns an error naming the ways forward and changes nothing: the refusal happens before the build clears anything, so an index built headlessly survives being asked for again from in there. The workaround is documented and now named in the refusal itself: build once outside the desktop app against the sameZOTEUS_DATA_DIR, then let Desktop read the finished file.action:"update"is never gated, and it keeps that index current from inside Desktop, body text included, because an update re-reads only the delta and the attachments Zotero has extracted since the stored cursor. A metadata-only build (fulltext:false) is unaffected.ZOTEUS_ALLOW_ELECTRON_FULLTEXT=truelifts the refusal for anyone who wants to try it anyway; what a build indexed before the process died is kept, stays searchable, andaction:"build"resumes from it. The gate is deliberately not narrowed toZOTEUS_EMBEDDINGS=local: the reported suspicion (onnxruntime-nodeunder Electron's Node ABI) is explicitly unconfirmed, and the full-text pass differs from the metadata pass in several other ways that also reach native code, so refusing on the one signal that actually correlates says only what is known.
[1.11.0] - 2026-08-31
Added
- The index covers the words you wrote: child notes and PDF annotations (#33). Every
index crawl asked for
top: true, so the corpus was the library's top-level items and nothing hanging off them. Sincezotero_annotateshipped in 1.10.0 that was no longer only a coverage gap but a disagreement inside the server: Zoteus wrote an annotation onto an attachment and could then never find it again, on any query, ever. Notes and annotations (the highlighted passage together with its comment) are now indexed as passages carrying the parent item's key, labelledsource:"note"/source:"annotation"on a hit, with notes stripped of their HTML. Because they carry the item's key, an item with forty annotations still takes one result slot: your own words extend what an item can be found by rather than crowding the page. On by default (ZOTEUS_INDEX_OWN_WORDS,own_words:falseper build) where full text is opt-in, because the whole corpus is one paged crawl of hand-written text plus one batched lookup per fifty annotated attachments — an annotation names the attachment it sits on, never the item, and that hop is what attributes it.action:"update"keeps it current for the cost of one keys-only request: notes and annotations carry ordinary versions, so comparing the library's note keys against the ones the index holds finds edits, additions and — the case no?since=can report, because deleting a note moves no version anywhere — deletions. The crawl that reads note bodies is opened only when there is something to re-index. An index built before this existed fills its gap on its first update, once, and says so. - The search index has a migration path, so the next schema bump does not re-embed every
library from zero (#34).
SCHEMA_VERSIONhas been 1 since the SQLite backend landed, and the open path accepted exactly two states: no tables, or this build's own stamp. Everything else was moved aside and rebuilt — including a database stamped with an older version of our own schema. That has never fired for anyone, which is exactly why it was worth fixing now: the first bump is the one that charges every index in the field a full rebuild, and the expensive half of a rebuild is re-embedding (a measured 5.5 hours of local CPU for 255k passages, or a hosted provider's bill). Two things change. A ladder of upgrade steps now carries an older index forward in place: each step runs inside the one transaction that stamps the new version, so a database is either fully upgraded or fully untouched, and a step that throws rolls back and falls through to the sideline. And where a sideline is still the right answer — a newer build's database, an unstamped file, a gap in the ladder — the moved-aside index becomes a read-only vector source for the rebuild that replaces it: any passage that comes back with the same id and byte-identical text takes its stored vector instead of being embedded again, so only genuinely new or edited text costs embedding time. Reuse is refused when the embedder identity (provider and model) differs, andstorageNoticenow prices the rebuild it prescribes — how many passages, how many vectors, and whether they must be paid for — instead of only saying where the old file went.
Fixed
- A build for one library no longer erases another library's index. The index file is
keyed by the data dir, never by the library — which is right, and had a sharp edge: the
build path clears the store before crawling, so
zotero_indexpointed at a group library silently replaced the personal library's index (or any other), reporteddone, and said nothing. Resume gave that a second shape: a build that finds a checkpoint carries on from it instead of clearing, and the resume conditions never look at the library — so the same mistake against an interrupted index appended one library's items to another's rows and still reporteddone. The index now stamps the library it holds (the personal library is one identity however it is addressed,users/0locally or by user id on the cloud, so the local/cloud seam never trips it), and a build or update for a different library refuses up front, naming both and the way forward. Indexes written before the stamp existed refuse nothing — their first stamped build adopts them.
[1.10.0] - 2026-08-29
Added
-
zotero_get_fulltextreads the attachment file itself, from wherever it actually is (#29). The fallback that parses an unindexed PDF used to have exactly one way to get the bytes: download them from Zotero cloud storage. That is the one route that does not work for the case the fallback exists for. A PDF added minutes ago has not synced, a local-only library has no cloud copy at all, and an account that never bought storage quota never will, so "summarise this paper I just added" failed on precisely the papers the user had just added. The bytes are now taken from three sources in order: the running Zotero desktop app (which reads them off its own disk), the local Zotero storage folder at<Zotero data dir>/storage/<key>/(which needs no cloud key and no running Zotero, only a Zoteus on the same machine), and a cloud download last. The answer says which one produced the file, asfileSource, alongside the existingfulltextSource, so a caller can always tell text Zotero indexed from text Zoteus extracted a moment ago, and from where. Where nothing can produce the file, the error names each source it tried and why each one could not answer, instead of reporting only the last failure.ZOTERO_DATA_DIRpoints at a moved Zotero data directory; a directory that is not there is skipped, so a hosted Zoteus loses nothing by looking.zotero_annotateshares the same loader, so passage anchoring picks up the storage-folder source too. -
EPUB attachments extract locally, with no new dependency. An EPUB is a zip of XHTML documents plus a package file that puts them in reading order, so Zoteus unpacks it with Node's own
node:zlib, follows the spine (the reading order the book declares, which is not the archive's alphabetical order), and strips the markup. An item whose only attachment is an EPUB is no longer a dead end: the attachment picker prefers a PDF, then an EPUB, then anything else, and the text comes back markedfulltextSource: "epub". An EPUB reflows and has no fixed pages, sopage_rangesays so rather than inventing a span. -
zotero_get_fulltext outline:truereturns a PDF's table of contents with a page number and nesting level per heading, read from the document's own bookmark tree and never from Zotero's index. It is the cheapest possible map of a long document: reading the outline and then asking for the pages it names is two small calls, where the alternative is one call that returns a book. A PDF with no bookmarks answers with an empty list and a notice rather than an error, and a heading whose destination cannot be resolved is still listed, without a page.
Changed
-
page_rangenow reads the real pages. Asking for pages 3 to 7 used to slice the indexed character stream proportionally unlessprecise_pages:truewas also passed, which answers a different question: roughly this share of the characters, not these pages. A page range now re-extracts the PDF by default and returns the span itself, degrading to the old proportional slice (with a notice) where the file or the parser is out of reach.precise_pages:falseopts back out and does no file read at all.queryand document modes are unchanged: they still cost nothing beyond the index. -
On-device model weights now cache under the data directory (
<ZOTEUS_DATA_DIR>/models) instead of inside the transformers package's own install, so deleting the data directory removes everything the index ever wrote — including its largest artifact, and including weights that previously landed in a globalnode_modulesoutliving even an extension uninstall. Existing installs re-download the model (~25 MB) once, into the new location; the old copy stays where the package left it. -
action:"build"andaction:"refresh"are no longer aliases, in one respect:buildresumes an interrupted build where a checkpoint is on disk, andrefreshalways starts the crawl over. Both still rebuild from scratch on an index whose last build finished, which is every index that was not interrupted. -
The on-disk index format grows by two fields, and stays readable both ways. A
checkpointrecord and afulltextVersioncursor are added to the JSON artifact and to the SQLitemetatable, with no schema-version bump: an index written by 1.9.0 loads unchanged (no checkpoint means nothing to resume, and no cursor means the first update that wants full text closes its coverage gap once), and an index written by this version still opens in 1.9.0, which ignores the two keys it does not know. -
A semantic query no longer reads every vector in the index (#30). On a 255,703-passage index at 3072 dimensions, every
zotero_semantic_searchtook 90 to 105 seconds whatever was asked, because ranking meant decoding 3.1 GB of float32 vectors one row at a time. The SQLite backend now keeps a binary code beside each vector, one sign bit per dimension after the corpus mean is subtracted, and ranks in two stages: a Hamming scan over the codes (384 bytes a passage instead of 12,288, XOR and a SWAR popcount overUint32Arrays) picks a candidate pool, and only those candidates' real vectors are read and ranked by the exact cosine as before. Measured on a synthetic index of exactly that shape, a query goes from 2,107 ms to 50 ms on the same machine, 42x, and the codes cost 94 MB beside 3.1 GB of vectors.What the codes decide is which rows get scored, never how they rank: every score returned comes from a float32 vector, so the page is ordered by exact cosine and nothing needs rebuilding. What an approximation can cost is recall, and the candidate pool is the dial for it: against the exact ranking on real embeddings, a pool of 4x the result set recovers 0.884 of it, 8x recovers 0.953 and the default 16x recovers 0.986, and the codes get better as vectors get wider, because a wider vector is a longer code. Centring on the corpus mean is what buys the last few points, the same move Zotero's own semantic search makes (
modelCalibration.meanVector).Existing indexes are neither rebuilt nor re-embedded: the codes are derived from vectors already on disk, the schema stamp does not move, and an index that has none is searched exactly as before until the first semantic query builds them in one pass, which is the pass that query was going to make anyway. Builds and updates keep them current from then on.
zotero_index action:"status"reports which path served the last query (vectorScan:codesorexact) and why, when there is a why (vectorScanNotice). Three new knobs:ZOTEUS_INDEX_ANN(true;falseforces the old exact scan and writes no codes),ZOTEUS_INDEX_ANN_OVERSAMPLE(16) andZOTEUS_INDEX_ANN_MIN_CANDIDATES(500), which is also the size below which an index is small enough that the exact scan is simply kept.bench/two-stage-search.tsmeasures both paths over a synthetic index of any shape.
Fixed
- An interrupted index build now resumes instead of starting from 0 (#24). The only
progress a build recorded was the library version stamp, and that stamp is deliberately
withheld from a build that did not finish, because it covers an unknown slice of the
library. The desktop local API frequently issues no version at all, so on that path there
was nothing to resume from in any case: stopping a build and starting another one cleared
the store and re-crawled, re-chunked and re-embedded items already committed to disk. A
build now writes a checkpoint (the crawl offset, the pass it was in, the library
totals it saw, the API that served it, the embedder identity, and the handful of passages
queued but not yet embedded) into the same write as the rows it describes, and
action:"build"carries on from it on either API. Committed passages stay searchable throughout and are never re-fetched or re-embedded; what is redone is bounded by the last save; the resume point is a stored offset rather than a scan; and the offset is verified against the library's own totals on the first page read, falling back to a walk from the top that steps over what the index already holds rather than to a rebuild. A resume is refused under a different embedding model, and stamps the version the interrupted crawl began from, so nothing modified in between is missed.statusreportsresumedFrom, and the tool says outright that a resume is what started. action:"update"now sees full text Zotero extracted after the build (#26). An update keyed its whole view of "what changed" on the item version, but Zotero versions extracted text on a sequence of its own: opening a PDF for the first time makes Zotero extract it and touches no item version, so that item appeared in no?since=delta, ever, and an index's full-text coverage stayed frozen at build time with a rebuild as the only remedy. A build now records the highest full-text version it consumed (fulltextVersioninstatus), and an update asks/fulltext?since=<that cursor>and attaches the new text through the same attachment-to-parent map the build uses, replacing only that item's body passages. On a library where nothing was extracted the probe is a single request and the attachment map is never built. The cursor advances only when the update fully succeeded, like the version stamp.
Security
- The Gemini API key no longer travels in the URL. Gemini embedding requests carried
the key as a
?key=query parameter; it now goes in thex-goog-api-keyheader, like the OpenAI key'sAuthorizationheader. A URL is the part of a request that gets logged — by proxies, by error causes, by anything that prints which endpoint failed — and a header is not. Google accepts the header everywhere?key=works, so nothing changes about which requests succeed.
[1.9.0] - 2026-08-28
Added
-
Highlights can be made from the passage alone, with no page coordinates. Zotero anchors a highlight by page rects, and nothing that reads extracted text can know them: the text carries content and page numbers, not positions. So a client that had read a PDF and wanted to mark a claim in it had one honest move left, a sticky note pinned to the corner of the page, because inventing rects draws a box over the wrong lines.
zotero_annotatenow takes a highlight or underline given astextwith nopositionand finds that passage in the PDF itself, computing the rects Zotero stores. The comparison ignores everything two renderings of the same passage disagree about: line and column breaks, words hyphenated across a line, spacing, case, ligatures, smart quotes, and accents a PDF sets beside a letter rather than over it, so a passage quoted back fromzotero_get_fulltextmatches the page it came from. The result is one rect per visual line, superscripts widening their line rather than fragmenting it, which is the shape the reader itself produces: verified against 465 highlights drawn by hand in the Zotero reader, the reconstructed rects sit a median 1pt from the reader's own, and the vertical extent is exact wherever the font carries metrics (`[baseline + descent × size, baseline- ascent × size]`, both read from the font). Passages quoted from Zoteus's own extracted text anchored at 290/290 across 25 papers.
Nothing is written on a doubtful match. A passage that is not in the document, and a passage that occurs more than once, are reported as themselves: the ambiguous case lists every occurrence with its page and surrounding words, and a new per-annotation
occurrence(1-based, reading order) or the existingpagepicks between them. The located offset and page height now feedannotationSortIndex, so an auto-anchored highlight sorts into the sidebar in reading order like any other. An explicitpositionstill takes precedence and skips the lookup entirely, so nothing about the existing calling convention changes.Reading the PDF is what the feature costs. A Zoteus running beside Zotero reads the file from the desktop app's own storage through the local API's
/fileendpoint (newLocalApiClient.downloadFileBytes, which follows thefile://redirect the app answers with), so unsynced attachments and libraries with no storage quota work with no cloud key at all; a hosted Zoteus downloads from Zotero storage as it already does for full text. Where neither can reach the bytes, where the file exceeds the 20 MB parsing cap that keeps a small host from being OOM-killed, or where the optionalpdfjs-distparser is absent, the reply says which and suggests an explicitpositionor a page-anchored note.
Fixed
- A search index written by a newer Zoteus is moved aside, never written into (#25,
thanks @MinhHaDuong).
createSchema()stampedschemaVersionwithINSERT OR REPLACEbefore anything read what the file already said, and nothing anywhere read it back: the stamp was written and consulted by no one. So the ordinary result of a downgrade, a database created by a later build, was silently re-stamped with this build's version and then used under a schema it might not have, destroying the one piece of evidence the stamp exists to carry at exactly the moment it mattered. The stamp is now read through a read-only handle before any DDL or connection pragma touches the file (journal_mode = WALis itself a write to the header, so even that waits). A database at a version this build does not understand, one whose stamp will not parse, or one that carries tables but no stamp at all, is renamed tosearch-index.sqlite.incompatible-<timestamp>with its write-ahead sidecars, nothing deleted, and a fresh index is created in its place;action:"status"reports what moved and where instorageNotice. The moved file stays a complete database that the build which stamped it can still open. A zero-byte file is treated as a first open rather than an incompatibility, since that is exactly what a handle opened and dropped before any DDL leaves behind, and a lock or an I/O error propagates instead of being read as a missing stamp, so a healthy index is never moved merely because another process held it. If the file can be read but not moved, the server survives, search refuses while naming the file, and an explicitzotero_index action:"build"clears it.
[1.8.0] - 2026-08-27
Added
- A build indexes every item's metadata before it crawls any attachment full text
(#23, thanks @MinhHaDuong). A build used to walk the library once, indexing each item's
own text and then fetching its attachment bodies before moving on to the next. So on a
large library with
fulltext:truenothing was searchable until the body crawl had finished, and that crawl can run for hours or days. The build now runs in two passes: titles, abstracts, creators and tags for the whole library first, then bodies. The gap between them is the point — the library is fully searchable on its metadata for the entire length of the body crawl.action:"status"reports which pass is running asphase, and the second pass's progress asfulltextItemsScannedoffulltextItemsTotal. The version stamp anaction:"update"diffs against is still written only when both passes have finished: stamping after the first would make a build interrupted mid-crawl look complete, and the items whose attachments were never read are unchanged in Zotero, so they would appear in no delta, ever. Items with no extractable attachment are skipped outright rather than asked about one at a time, and the metadata pass keeps the fast save cadence that full text used to slow down.
Fixed
zotero_index action:"build"now repairs an index that cannot be read (#21, thanks @MinhHaDuong). 1.7.2 gave a damaged index the right floor: refuse, keep the rest of the server working, and name the files to delete. That is not the right ceiling. The people most likely to meet a damaged index are.mcpbdesktop installs, andrm ~/.../search-index.sqlite{,-wal,-shm}is not a recovery path for someone who has no shell open and no reason to want one. An explicit build is consent — the caller has asked for the expensive thing and knows it — so that call, and only that call, now deletes the unreadable file and its write-ahead sidecars and opens a fresh index in their place before rebuilding. Nothing repairs the index at startup or inside a query: a server that silently takes ten minutes to start is worse than one that explains why it will not search.action:"update"refuses and points atbuild, since a delta needs the index it cannot read. The refusal text leads with the tool call and keepsrmas the fallback for the case the files cannot be deleted. Deletion rather than truncation remains deliberate: the version stamp lives inside the same database, and a repair that dropped the passages and kept the stamp would leave an empty index reporting itself as up to date.- Four more ways a broken index could read as an empty library (#21). The catch in the
SQLite backend's keyword search was written for one condition — SQLite rejecting the
match string it had just built — and implemented as swallow-everything, so
disk I/O error,no such table: passages, a locked database and an interrupted statement all came back as no matches rather than as a fault. It is now narrowed to genuine query rejections, which are the only errors a search should absorb; everything else says what went wrong. Damage discovered mid-query is also recorded rather than merely thrown, so the refusal sticks and the next call does not go straight back to the same broken file. - A
search-index.jsonthat cannot be parsed no longer loads as an empty index, or gets overwritten (#21). A truncated artifact was swallowed into a silent empty index that reported itself healthy — and because loading resets the index before it parses, the next clean shutdown wrote that emptiness straight back over the file, destroying the index the failure was about. The JSON backend now refuses to read or write a store it could not load, leaves the file exactly as it found it, and is repaired by the sameaction:"build". A file that is simply not there is still a first run, not a fault. - Starting Zotero after your MCP host no longer leaves it invisible until you restart
(#22, thanks @StianOby). Whether Zotero's local API was reachable was probed once at
startup and frozen for the life of the process, so
zotero_whoamireportedlocalApi: falseforever whenever the desktop app had not been up at that exact moment — a result that depended on launch order and nothing else, curable only by quitting and relaunching the host. The answer is now kept live: it is re-checked lazily as tools are called, cached with a short TTL, backed off toward one check a minute on a machine where nothing ever answers, and shared between concurrent calls, so it costs nothing where no desktop app can apply and no round trip per call where one does. A Zotero that quits mid- session is noticed too.zotero_whoamialways probes afresh, reports when it last checked, and names the Zotero setting to turn on when the answer is no. - Desktop writes recover with it (#22). The local-API and connector write clients were built only when the startup probe had succeeded, so on a server that started before Zotero they stayed undefined for the process lifetime: the re-probe could flip the capability to true and every write still fell through to the cloud, because the client it needed had never been constructed. Both are now created whenever the local API is configured at all. They authorize lazily and every call site still checks the live capability first, so nothing can reach a Zotero that is not running.
- The startup probe is bounded (#22). Each of its three attempts inherited the shared fetcher's 25-second budget, so a firewall that drops packets on 127.0.0.1:23119 rather than refusing them could spend over a minute deciding the answer was no. It now gets two seconds per attempt.
Changed
- An index build holds the local-API answer still for its duration. The routing decision is re-read for every page, so a desktop app appearing or vanishing mid-build would splice pages from two APIs into one index and stamp it with a single library version — and the desktop app and the cloud number their versions independently.
[1.7.3] - 2026-08-27
Fixed
- A setting left empty in the desktop pane no longer stops the server from starting
(#18, thanks @StianOby). A
.mcpbhost substitutes every environment entry its manifest declares, including the ones whose settings field the user never filled in. Where that field also carries nodefaultin the manifest, Claude Desktop 1.37937 substitutes nothing at all and passes the reference through verbatim, so the server is handed the literal text${user_config.embed_batch_size}. Blank has meant "use the default" since 1.7.0, the release that added these four numeric fields; an unresolved reference did not, soz.coerce.number()readNaNandloadConfigthrew aZodErrorabout a second into startup, before the logger exists. That is aFATALline and a dead process, which the host reports as a failed version negotiation: the negotiation was fine, there was simply nothing left alive to negotiate with. Every one of those four fields is empty on a fresh install, so 1.7.0, 1.7.1 and 1.7.2 could not start as a desktop extension on this host version unless all four were filled in by hand. Read out of/proc/<pid>/environof the running extension rather than inferred; an unresolved reference, a blank string,undefinedandnullnow all mean the setting's own default applies. - An unset marker can no longer become the data directory (#18).
ZOTEUS_DATA_DIRwas the one setting whose fallback re-read the raw environment instead of the parsed value, so a marker the schema had just rejected was handed straight back bydefaultDataDir. With an unexpanded reference in that variable the server created a directory named${user_config.data_dir}in whatever the working directory happened to be, silently, and put the search index, the OAuth store and saved attachments in it. The check that decides what counts as unset now lives in one place that both the schema anddefaultDataDirread. - The stdio shutdown no longer ends the process it is running in (#18). 1.7.2 finished
every ending with
process.exit(0), which assumes the server owns its process. The host in #18 reportsUsing built-in Node.js for MCP serverand a probe thatrequires the SDK's base StdioClientTransport, neither of which is obviously a plain subprocess, and exiting somebody else's process is a worse fault than the one being fixed. Now only the signal handlers exit, because installing them is what removes node's default termination, and they are installed only when nothing else is already handling those signals. On stdin EOF and on a transport closed from inside the process, Zoteus flushes the index, releases the transport and lets the loop drain, which exits 0 on its own in a process it does own. The stdio binding's own escalation (close stdin, wait, SIGTERM, SIGKILL) remains the backstop.
Changed
-
A tuning knob can no longer stop the server from starting (#18). Configuration used to be all-or-nothing: any value a schema rejected, whether a host marker nobody anticipated or a typo, threw out of
loadConfigbefore there was a logger to explain it. A rejected knob is now reported by name on stderr and replaced by what its absence would have given, soZOTEUS_INDEX_MAX_ITEMS=lotsstarts the server on 5000 items and says why, rather than taking downzotero_get_item, bibliographies and citations, none of which reads that setting. It is #20's reasoning (a damaged index stopped being fatal) applied to configuration, and it is what keeps the next unanticipated substitution a warning instead of another silent startup crash.Not every setting is a knob, and the ones that are not still refuse.
ZOTERO_LIBRARY_IDandZOTERO_LIBRARY_TYPEchoose which library is read and written, soGroupno longer quietly becomesuser. With OAuth enabled,ZOTEUS_OAUTH_MODEandZOTEUS_OAUTH_STOREchoose a security model: falling back would have served every client from the operator's own Zotero key, or skipped the encryption key that file-backed tokens require. An unexpanded${...}inZOTEUS_CIMD_ALLOWED_HOSTSrefuses too, because an empty host list there means no restriction at all, anddocker --env-filedoes no interpolation. None of these appears in the desktop manifest, so none of them can be reached by a settings pane a host fills in. A refusal now also carries the warnings collected before it, because throwing discarded them: a rejectedZOTEUS_PUBLIC_URLused to report only that the variable was required, which is misleading when it is plainly set.
Documentation
- Where a desktop install's logs actually are (#18). Recent Claude Desktop versions run
a bundled extension in an Electron
UtilityProcess, somcp-server-Zoteus — Zotero MCP.logcarries only what the host says about the server, and every[zoteus]line, including the crash above, goes tomain.logprefixed[UtilityProcess stderr]. Three rounds of #18 were spent reading a file that could not have held the answer. Troubleshooting now says which file, and adds theZodErrorsymptom.
[1.7.2] - 2026-08-27
Fixed
- Accented queries reach the passages they name (#19, thanks @MinhHaDuong). The FTS5
document side is folded by SQLite (
unicode61 remove_diacritics 2), but the query side matched[a-z0-9]+over lowercased text, sothéoriereached the index as"th" OR "orie": two tokens it does not hold. Because terms are OR-ed, that is not an empty answer but a confident wrong one, retrieving whatever OCR'd full text happens to contain those fragments. One normalizer now sits in front of the tokenizer both sides share, so the symmetry is structural. It reproducesremove_diacritics 2and nothing more: folding harder would break Norwegian, Polish and Vietnamese the same way. Tokens are\p{L}\p{N}now, soΘεωρία,теорияand日本語stay whole. No reindex needed. - A damaged search index no longer stops the server from starting (#20, thanks
@MinhHaDuong). One bad page in a derived cache file threw SQLite's own sentence out of
open(), which nothing caught, soinitializewent unanswered and item lookups, bibliographies and citations went down with it: none of which reads the search index. A corrupt store is now detected by result code as well as message, the handle is released, and search alone refuses, naming the file, its sidecars and the way back. Every other tool keeps working. Repairing it automatically is deliberately not in this release (#21). - The end of a stdio session is no longer silent (#18). A stdio server dies with its
input stream, and the MCP SDK's transport does not watch for that: it subscribes to
dataanderroron stdin and nothing else, so EOF closed no transport, fired noonclose, and the process ran out of work and exited 0 having written nothing. Hosts report that asServer transport closed unexpectedly ... process exiting early, which is indistinguishable from a crash. Zoteus now names what ended the session on stderr before it goes, and uses the moment for a flush stdio never had: only the HTTP path installed shutdown handlers, so a stdio session left SQLite's write-ahead log for whichever process opened the file next.
[1.7.1] - 2026-08-26
Fixed
- The desktop extension no longer crashes on startup in recent Claude Desktop versions
(#18, thanks @StianOby). The shared Cowork/Code server pool expects
initializeto be answered promptly and tears the server down when it is not, and zoteus was taking two seconds or more to reply: it built its whole context first, which retries the desktop local API while Zotero starts, checks the cloud key, and opens the search index. The stdio transport now connects before that build rather than after it, so the handshake and the tool list (both of which need only the configuration) are answered in milliseconds while the context builds behind them. Tool calls still wait for it, so none of them ever sees a half-built context. - A second zoteus process no longer kills the first (#18). SQLite fails a contended
lock instantly by default, so two servers sharing a data dir — which is what a host
does when it probes by spawning a disposable server alongside the real one — could both
abort at startup with
database is locked. The index now waits for the lock, and tolerates a journal-mode switch another connection is already holding. - A failed startup no longer takes the stdio server down with it. The failure is logged, reported as the error on any tool call that needs the context, and retried by the next one, instead of exiting the process and leaving the host to report a bare disconnect.
[1.7.0] - 2026-08-25
Added
- Group libraries are served from the desktop app when it holds them (#12, #14, thanks
@MinhHaDuong). Zotero 10 serves
/groups/<id>on the local API, so zoteus now probes which groups the desktop holds at startup (and again if Zotero starts later), routes those reads locally with no cloud key, and still sends groups the app does not hold to the Web API. One server and one index cover the personal library plus groups. - The index item cap is configurable (#11, #13, thanks @MinhHaDuong).
ZOTEUS_INDEX_MAX_ITEMS(default 5000) replaces the hardcoded cap. A build that truncates now says so, naming the real library size next to what was indexed, in the build status, inzotero_semantic_searchresults, and after a restart. - SQLite full-text index backend (#10). On Node 22.13+ the search index lives in
SQLite with FTS5 (built-in
node:sqlite, no new dependency), removing the 512 MB persistence ceiling and the multi-gigabyte memory residency of the JSON index: builds are faster, reloads are instant, and keyword search never materializes the corpus in memory.ZOTEUS_INDEX_BACKENDselectsauto|sqlite|memory; the JSON backend remains the fallback on older Node. Small existing JSON indexes are imported automatically, oversized ones get an explicit rebuild notice. - Incremental index updates (#16).
zotero_index action:"update"fetches only items changed since the last stamped library version, re-embeds only their passages, and reconciles deletions with a cheap key census. It falls back to a full rebuild, saying why, whenever the stamp, serving backend, or embedding model cannot be trusted. - Configurable embeddings (#15).
ZOTEUS_EMBEDDING_MODEL,ZOTEUS_EMBED_BATCH_SIZEandZOTEUS_EMBED_BATCH_DELAY_MStune the OpenAI/Gemini embedding calls for large builds and per-tier rate limits. The model is stamped into the index; switching models drops the stale vectors with a visible notice instead of mixing vector spaces. - Desktop settings (#9): the extension settings screen now exposes full-text characters per item (0 = no cap), the item cap, and the embedding model, batch size and delay.
Fixed
- A blank environment variable (an empty field in the desktop settings screen, or a bare
KEY=line in.env) no longer crashes boot or, forZOTEUS_DATA_DIR, silently relocates the data directory: blank now means unset everywhere. - Index persist failures are recorded on the build status (
persistError) and surfaced wherever status is read, instead of vanishing into a log warning while the build reports done (#10).
[1.6.0] - 2026-08-20
Added
- Semantic search can now cover the full text of your PDFs, not just metadata and
abstracts (#8). The index has always been built from title, abstract, creators and
tags, so a claim that appears only on page 9 of a paper was unfindable by meaning. Full
text is now an opt-in extra pass over the same build:
zotero_index action:"build" fulltext:true(orZOTEUS_INDEX_FULLTEXT=trueas the default for every build) indexes the body text Zotero extracted from each item's attachments as additional passages.fulltext_max_chars/ZOTEUS_INDEX_FULLTEXT_MAX_CHARScaps how much of each item is indexed (default 40000 characters, about 13 pages;0= no cap).- Body passages carry their parent item's key and title, so a hit lands on the paper
rather than the attachment, and one paper cannot flood the results. Hits whose snippet
came from a PDF body are marked
source: "fulltext"so the passage can be quoted and located withzotero_get_fulltext. - Key-free, local-first. Zotero 7+ serves the
/fulltextendpoints from the desktop app, so this works with no cloud API key, like every other read. Group libraries (and everything when the app is closed) go to the cloud Web API. - The resolution costs two library-wide reads instead of per-item probing: one
/fulltext?since=0call names the attachments that have extracted text, and pagingitemType=attachmentmaps them to their parents. Only that intersection is fetched. - Off by default because it is genuinely expensive: measured on a 212-item library with 151 extracted PDFs, roughly 9x the passages (687 -> 6246) and 20x the index file (0.4 MB -> 7.9 MB), with the embedding stage growing by the same factor.
zotero_index action:"status"reportsfulltextEnabled,fulltextItemsandfulltextPassages; when full text was requested but produced nothing (no extracted attachments, unreachable endpoints) the build still completes as a metadata index andfulltextReasonsays why, rather than looking complete.
Fixed
- A redeploy no longer wedges a connected client. A session id this process never
issued (any restart drops the in-memory transports, and sessions are also reaped) was
answered with
400, which clients treat as a plain bad request: every later call kept failing, plain reads included, until the user reconnected the connector by hand. The Streamable HTTP spec makes404the signal a client MUST answer by re-initializing, so it is now a404and clients heal themselves. A request with no session id at all is still a400. - Full-text reads no longer require a cloud API key.
zotero_get_fulltextandzotero_fulltext(get/since) went toapi.zotero.orgunconditionally, so in local-only mode (no key, personal library addressed asusers/0) they failed outright even though the running desktop app serves the very same endpoints. Both now route like every other read: desktop app first, cloud when it is closed or the library is a group.zotero_fulltext action:"set"is a write and stays on the cloud Web API.
[1.5.0] - 2026-08-20
Fixed
- File attachments now work without the Zotero desktop app. Every write path that
stores a file was gated on desktop access, so on a remote or hosted Zoteus (a claude.ai
custom connector, or any server not on the user's machine) attaching a PDF was
impossible:
zotero_attach_filerefused with "storing files needs Zotero desktop write access", andzotero_importsaved the metadata but reportedattach_url is only supported for desktop-app saves; the file was not attached. Both suggested granting Zotero write access, which cannot help: the desktop local API listens on the user's127.0.0.1:23119, and a server elsewhere has no route to it. The cloud Web API does support file uploads, and Zoteus already implemented that protocol forzotero_attachment action:"upload", but only from a file on the server's own disk. Now the bytes can come from a URL and the upload runs from memory:zotero_attach_fileuses the desktop app when it is reachable and the cloud Web API's File Storage protocol when it is not, sourlworks on every deployment. It also takeslibrary_type/library_idfor attaching in a group library.zotero_import'sattach_urlis no longer desktop-only; on a cloud save it uploads the file into Zotero storage. As on the desktop paths, a failure degrades to awarningrather than failing an import that already saved.zotero_attachment action:"upload"acceptsurlalongsidefile_path, which on a remote server refers to a disk the caller cannot write to.- A file fetched from the web is stored as
imported_urlkeeping its source URL (what Zotero itself records for a downloaded PDF), and an extension is appended from the served content type, since arXiv-style PDF URLs carry none. - On Zotero 9 and earlier (read-only local API) the desktop attempt fails before anything is created, so the call now retries on the cloud instead of dead-ending. A failure after the attachment item exists is reported rather than retried, so a partial write cannot silently produce a duplicate.
[1.4.2] - 2026-08-19
Fixed
- Semantic search no longer fails silently when the embedder cannot run (#7). With
ZOTEUS_EMBEDDINGS=localand@huggingface/transformersabsent, Zoteus fell back to keyword-only search but kept reportingembedder: "local", andmode:"semantic"returned{"hits": []}, indistinguishable from a library with no matches. The only signal was one stderr line, which desktop clients discard. Now:zotero_index action:"status"reports the effective embedder plusembedderConfigured,embedderActiveand an actionableembedderReason, soembedderreadsnone (local requested; @huggingface/transformers is not installed).zotero_semantic_search mode:"semantic"returns an explicit error naming the cause when the index holds 0 vectors;autostill answers from BM25 and says vector ranking is off.zotero_whoamireports embedding health alongside identity.- The provider is preflighted at startup (resolve-only, nothing executed), so the degradation is known before a build silently produces an empty vector set, and a failure recorded mid-build survives into every later status call instead of living in one build's local scope.
Added
ZOTEUS_TRANSFORMERS_PATH: resolve@huggingface/transformersfrom outside the install. Desktop-extension bundles resolve modules only from inside themselves and cannot ship the package (onnxruntime-nodeis statically imported and its prebuilt native binaries total ~384 MB across platforms), which left.mcpbusers with no route to on-device vectors at all. Install it anywhere (npm i -g @huggingface/transformers) and point this at the directorynpm root -gprints; it accepts that path, the package directory, or an npm prefix, and it survives extension updates. Exposed in the bundle as the "Local embeddings path" setting.
[1.4.1] - 2026-08-19
Changed
- Desktop extension migrated from
.dxtto MCP Bundles (.mcpb) for official directory submission (#6). The manifest is nowmanifest_version0.3, packed with@anthropic-ai/mcpb(validated in CI before packing), and declares the newPRIVACY.mdviaprivacy_policies. Releases now attachzoteus.mcpbinstead ofzoteus.dxt; the update notice names the new bundle.
Added
PRIVACY.mdprivacy policy and a README privacy section: Zoteus collects nothing, has no telemetry, and only contacts the services you configure, directly from your machine.
[1.4.0] - 2026-08-19
Added
- Update notices for manually installed builds (#6). Claude only auto-updates
desktop extensions installed from the official directory, so a hand-installed
zoteus.dxtnever learns about new versions. Zoteus now checks the latest GitHub release once a day (cached on disk, a single unauthenticated GET, no user data sent) and surfaces a notice throughzotero_whoamiand the stderr log. On.dxtinstalls (the manifest now setsZOTEUS_DIST=dxt) the notice includes download-and-reinstall instructions. Opt out withZOTEUS_UPDATE_CHECK=false.
Fixed
- The README install table pointed at a
zoteus.mcpbrelease asset that releases do not ship; the actual asset iszoteus.dxt.
[1.3.1] — 2026-08-18
Fixed
- Semantic-search indexing no longer requires a cloud key while Zotero runs (#5).
The index build fetched items through the cloud Web API unconditionally; it now pages
through the same local-first router as every other read, so a running desktop app
serves the build key-free (group libraries and app-closed builds still use the cloud).
Also fixes a local-API pagination bug where a missing
Total-Resultsheader was read as0, silently truncating a local build after the first page. README and docs now state the actual key rule. zotero_importattachesattach_urlon both desktop write paths. The connector protocol (Zotero 9 and earlier) streamed the file into its save session, but the Zotero 10 local-API save path ignoredattach_urlentirely and left the imported item with no attachment. That path now downloads the file and stores it as animported_filechild of the saved item through the local API's 3-phase upload — the same flowzotero_attach_fileuses — honouringattach_titleand deriving a bare file name (with the extension the content type implies) from the URL. A failed download or upload degrades to awarningon an otherwise-successful import instead of failing the save, since the items are already in the library.
[1.3.0] — 2026-08-18
Fixed
- Zotero 10 local-API writes. Local-API write support shipped in Zotero 10 (Zotero 9
and earlier expose a read-only, GET-only local API), and the shipped protocol differs
from what the write client was built against. Verified against
zotero/zotero @ 10.0.0,chrome/content/zotero/xpcom/server/server_localAPI.js:POST /api/local/authorizeis itself a write method and does not opt out of the server-ID precondition, so the grant request must carryZotero-Server-ID. Zoteus sent it on writes but not on the grant, which made every first-time grant fail with428 Precondition Required. The server ID is now probed before authorizing, and a stale one (412) is re-probed once.- Multi-object
DELETErequiresIf-Unmodified-Since-Version, and key-based writes require it or a per-objectversion(428 otherwise). The client now tracks the library version alongside the server ID and refreshes both on 412/428. - There is no
/items/deletedwrite endpoint; permanent deletes go toDELETE …/items?itemKey=…, chunked to the local API's 50-object batch limit.
- Trash no longer erases on the desktop path.
zotero_trash_itemsandzotero_annotate(action:"delete") previously issued the local API'sDELETE, which — exactly like the Web API's — purges items outright rather than trashing them. They now writedeleted: 1(anddeleted: 0to restore), which is what "trash" means and what the tool descriptions promise. isLocalWritesUnavailable()now also recognises 501Endpoint does not support methodand the "noZotero-Server-IDheader" signal, so a Zotero 9 desktop correctly falls back to the connector protocol instead of surfacing a hard tool error.zotero_attach_filestrips any path separators from the requested file name, which Zotero rejects outright.
Added
zotero_delete_itemsroutes permanent deletes for the personal library through the running Zotero desktop app when it accepts local-API writes, falling back to the cloud Web API otherwise.
Changed
- Docs and tool descriptions now say Zotero 10+ for desktop local-API writes (they said "Zotero 9+", written against pre-release behaviour) and "Zotero 9 and earlier" for the read-only local API that falls back to the connector protocol.
[1.2.0] — 2026-08-18
Added
- PDF annotation tools. New
zotero_annotateadds and deletes Zotero PDF annotations — highlights, underlines, and notes — the same objects the PDF reader creates (annotationType,annotationText,annotationComment,annotationColor,annotationPositionas{"pageIndex":N,"rects":[[x1,y1,x2,y2],...]}in native PDF points (bottom-left origin), and a reader-compatibleannotationSortIndex). It resolves the PDF attachment from any parent item, or accepts an attachment key directly. Newzotero_attach_filestores a local file or URL as animported_fileattachment under an item where the desktop app supports local-API writes. - Desktop writes, two paths.
zotero_annotate,zotero_attach_file,zotero_trash_itemsandzotero_import(save) now write straight to the running Zotero desktop app for the personal library — no cloud key required:- On Zotero builds with local-API write support, writes use the user-granted local
key (
POST /api/local/authorize; cached under the data dir aslocal-api-key.json, pre-provisionable viaZOTEUS_LOCAL_API_KEY), carrying the requiredZotero-Server-IDheader and transparently re-authorizing on 401 / re-probing on 412/428. - On Zotero versions whose local API is still read-only (≤ 9.0), writes fall back to
the desktop connector protocol (
saveItems/saveAttachment/updateSession) — no grant dialog; created keys are recovered by polling the local API.zotero_importgainedattach_url/attach_titleto stream a file (e.g. an arXiv PDF) into the same save session, andcollection_keytargeting for desktop saves. - The cloud Web API remains the fallback when the desktop app is not running or a group/other library is targeted.
- On Zotero builds with local-API write support, writes use the user-granted local
key (
- PDF full-text fallback.
zotero_get_fulltextnow serves text even when Zotero has not indexed a PDF: it downloads the attachment and extracts text on the fly (optional pdfjs-dist), with exact page locators (fulltextSource:"pdf"). Opt out withfallback:false. OOM size guard shared withprecise_pages. - Semantic-search first-use UX.
zotero_semantic_searchauto-starts the index build on first use (auto_build, default true) and reports progress instead of failing silently;zotero_indexbuilds now run as a background job (pollaction:"status", cancel withaction:"stop"), persisting partial progress atomically.
Fixed
- Child-item listing on the desktop read path returned the entire library: the
local API silently ignores the
parentItemquery param, sozotero_get_item(include_children) andzotero_annotate's PDF-attachment resolution scored every item in the library. Children are now fetched via/items/<key>/children. - Import save errors now surface as actionable results instead of unhandled
rejections (
await maybeSave); pdfjs no longer detaches caller buffers.
[1.1.0] — 2026-08-14
Added
zotero_importno longer dies when the translation-server is unreachable: DOI and arXiv ids now resolve through a built-in server-side fallback (OpenAlex/Crossref for DOIs, the export.arxiv.org Atom feed for arXiv ids), with asourcefield on results for provenance. ISBN/PMID/bibcode and URL scraping still require a translation-server and say so explicitly. Seedocs/resolver.md. arXiv calls are paced (~1 per 3s, per arXiv API etiquette) and back off on HTTP 429/503; a persistently throttled id now raises a rate-limit error instead of being misreported as "no record".- Tool descriptions/examples for
zotero_create_itemsandzotero_update_itemnow embed a complete, correct payload example, and validation errors for a missing/wrongitemTypeshow exactly what was sent (e.g. "got a wrapper object {itemType: "report"}") so clients can self-correct instead of re-sampling the same bad shape.
Fixed
zotero_create_items/zotero_update_item: item-data payloads sent with nested "wedding-cake" field wrappers ({"itemType": {"itemType": "report"}, "title": {"title": "…"}}) are now repaired server-side into the flat shape Zotero expects, turning a confusing "Missing required itemType" into a successful write. This is the same class of degraded-encoding repair added for array fields in 1.0.3, applied to scalar fields. Repair also covers corrupted field NAMES observed in the same transcripts:Quote-suffixed keys (creatorsQuote,collectionsQuote,collectionQuote) and singular spellings (collection,creator,tag) are normalized to the real Zotero field names instead of failing the whole batch with "Invalid property".
[1.0.4] — 2026-07-20
Fixed
/healthzand MCPserverInfonow report the real package version. Two hardcodedVERSIONconstants were missed by every release bump since 1.0.1, so deployed servers self-reported a stale version and made deploys look outdated. The version is now read frompackage.jsonat runtime.
[1.0.3] — 2026-07-20
Fixed
zotero_update_item/zotero_create_items: writing array-valued fields (creators,tags,collections) no longer fails with Zotero's "property must be an array" when the client sends them in a degraded shape (a JSON-encoded string, a single un-wrapped object, a numeric-keyed object, or a wrapper object around the real array). The structured fields are now explicitly typed in the advertised tool schema so clients know the expected shape up front, and the common degradations are repaired at the tool boundary before the write reaches Zotero. Reported in #1.
[1.0.2] — 2026-06-01
Fixed
zotero_bibliographyandzotero_exportnow mirror their rendered output intostructuredContent, not onlycontent. MCP clients that read the structured channel (e.g. the claude.ai connector) were surfacing just a summary ({style, itemCount}/{format, length}) and dropping the actual bibliography/export text.zotero_format_bibliographyalso returns the joinedbibliographystring alongsideentriesfor consistency.- Zotero fetcher: a slow single request that exceeds the time budget is no longer reported
as rate-limiting. The 408 now distinguishes genuine throttling (a 429/503/
Backoffwas observed → back off and retry sequentially) from an expensive query that was simply slow (e.g. a full-textqmode=everythingscan over a large library → narrow the query or lower the limit), so the guidance matches the real cause. - OAuth (
MODE=zotero): removedidentity=1from the Zotero authorize URL, which forced identity-only mode and prevented a real API key from being issued.
[1.0.1] — 2026-06-01
Changed
zotero_search_items: a quick search (q) with no pinnedqmodethat returns nothing now auto-retries once ineverythingmode (notes + attachment full text) before reporting absence, so "is X in my library?" checks no longer false-negative on terms that appear only inside PDF text. Emptyeverythingresults are reported as strong-but-not-conclusive (un-indexed/scanned/un-synced PDFs aren't full-text searchable). The response gainsqmode(effective) andbroadened; only previously-empty searches change behavior.zotero_fulltext: description now states it is not a search and points tozotero_search_items(qmode=everything) for finding which items contain a term.- Zotero fetcher: bounded per-request time budget (~25s, overridable) with an
AbortController, so a rate-limited (429/503) retry loop or a stalled connection fails fast with an actionable 408 ("retry sequentially, avoid parallel batches, keep responses concise") instead of hanging until the MCP connector's own per-call timeout fires. The budget is per request (not per operation), so multi-request batch flows are unaffected. 429/503 messages and the serverinstructionsnow also steer the model toward sequential calls.
[1.0.0] — 2026-05-31
First public release: published to npm as a scoped public package, listed in the MCP registry, and shipped as a Claude Desktop DXT.
Added
- Published
@oscardvs/zoteusto npm (npx -y @oscardvs/zoteus), scoped public withpublishConfig.access=public. - MCP registry listing via
server.json(npm package + hosted remote endpoint). - Refreshed Claude Desktop DXT one-click package (local-API toggle, icon, self-contained bundle).
- CIMD (Client ID Metadata Document) support: resolve a URL
client_idto a registered client without DCR, advertised viaclient_id_metadata_document_supported. Prerequisite for the claude.ai connector directory (single shared app instead of per-connection DCR). CHANGELOG.mdand a maintainer distribution runbook (docs/distribution.md).- README launch polish: npm badge, connect matrix, directory/CIMD note.
[0.12.0] — 2026-05-30 (M13)
Added
- Production hardening:
/healthz/readyz/metrics, secret-redacting logger (text/JSON),/mcprate limiting, graceful shutdown (drain → flush store + indexes → close). - Deploy IaC: docker-compose + Caddy, systemd + Fly alternatives, backups, GHCR release workflow,
docs/deployment.mdrunbook.
[0.11.0] — (M12)
Added
zotero_get_fulltext(passage retrieval with page locators),zotero_tag_audit,zotero_list_tags,zotero_list_collections,zotero_export format:"better-biblatex",zotero_update_item dry_rundiff, query-centred search snippets.
[0.10.0] — (M11)
Added
- Multi-tenant per-user Zotero login (OAuth
zoteromode); per-user encrypted token store.
[0.9.0] — (M10)
Added
- OAuth 2.1 + PKCE authorization server in front of
/mcp; passcode-gated consent; HTTP transport + DXT + initial MCP registry entry; turn-key claude.ai custom connector.