Install or upgrade: uv tool install --upgrade "zotero-mcp-server[all]" (or pip install -U zotero-mcp-server).
Highlights: local write support for Zotero 10 (zotero-mcp authorize-local), pyzotero 1.15 compatibility, local reads that see recent changes, and deleted items cleaned out of the semantic index. Full list below.
Added
-
zotero_update_collectionrenames a collection or moves it under another parent (#517). The collection keeps its key, subcollections and item membership, so renaming no longer means creating a new collection, refiling every item and deleting the old one. The parent can be given by key or name,to_top_level=Truemoves a collection out of any parent, and a move under the collection itself or one of its own subcollections is refused rather than sent to Zotero. Also available aszotero-cli collections update KEY --name ... --parent ... --top-level. -
The open-access PDF cascade now tries the
citation_pdf_urlthe article's own landing page advertised (#229).html_metadata.EmbeddedMetadatahas parsed that tag since the embedded-metadata reader landed and nothing had ever read the field — grep forpdf_urloutsidehtml_metadata.pyand the only hits were local variables and arXiv's separately-constructed URL. It is how the browser connector finds a PDF: OJS/PKP, Atypon, Silverchair, Highwire and Springer all publish it, and for a paper no aggregator has indexed it is the only source there is. It goes after Unpaywall, not before. The case for first was that it names the version of record; the case against is stronger, because it is also the URL most likely to answer with an access-denied page, a cover sheet or a first-page preview — a real PDF, of the right content type, above the size floor — and the cascade returns on the first success, so a stub would win outright over the full text Unpaywall had. Losing a known open-access copy to a paywall stub is the worse failure, and behind Unpaywall the source still does the thing it was added for.citation_pdf_urlis stored as deposited and OJS/DSpace/EPrints all deposit it relative, so it is resolved against the page's post-redirect URL — unresolved it could never download, and, being the first URL found, it displaced the absolute Unpaywall URL in the "a URL was found" message users are told to try through their library. The "no open-access PDF found" message now derives its source list from the table it describes, which had already drifted: the table said "PubMed Central", the sentence said "PMC".Two sinks had been relying on their inputs being curated, and this is the first source that comes from arbitrary third-party HTML rather than a known API over a known host, so both are fixed here.
_attach_pdf_linked_urlnever fetches, soattach_mode="linked_url"never reached the SSRF guard at all — afile:///etc/passwdin a meta tag would have been written into the library verbatim; it is scheme-checked now, which closes the same hole for the aggregator sources._download_and_attach_pdftrustedContent-Type, and Cloudflare interstitials and "PDF viewer" endpoints — exactly what a publisher'scitation_pdf_urlpoints at — serve HTML underapplication/pdf, which was attached as if it were the paper; it now checks the bytes for a%PDFheader. The download path's SSRF protection is otherwise inherited correctly:_guarded_pdf_getrejects non-http(s) schemes and non-global addresses, and re-validates every redirect hop.Scope is the DOI path only.
add_by_url's embedded-metadata branch creates items with no attachment at all and would benefit from the same URL, but_add_from_embedded_metadataruns under@with_zotero_api_lockon the stated grounds that it makes no third-party call, and adding a download inside it would hold the process-wide lock across a publisher fetch — the starvation #A5b exists to prevent. Doing it properly means restructuring that function to create under the lock and attach outside it, which is a separate change rather than a rider on this one.
Changed
-
A single-DOI add now reads the article's landing page, where before it only did so for a CrossRef record that could not stand alone. This supersedes the last sentence of the
zotero_add_by_urlentry in 0.11.0 ("The fetch is gated on that condition, so a DOI whose CrossRef record is complete never pays for it"), which was true as shipped and is no longer. The gate was sound given what "complete" was taken to mean, and 10.1006/bulm.1999.0141 is the counterexample: a title, a journal, a volume, pages and an author, passing every thinness test — while naming one of the paper's four authors and carrying no abstract. Nothing in the record says so, so the only way to find out is to look. The cost is one bounded GET (512 KB cap, 15 s timeout, outside the Zotero API lock) and every failure mode degrades to the record CrossRef gave us.Callers adding more than one item are exempt, because the cost is per item. Passing several DOIs to one call is exempt automatically; a caller that loops over single items has to say so, and
add_by_doi/add_by_urltakepage_check="thin_only"for that. Two such callers exist and both pass it:add_by_url's multi-URL recursion — which handles the mixed DOI/arXiv/webpage batches this project advertises, one URL at a time, so it would otherwise have turned a 200-link import into 200 publisher requests — andadd_from_file, whose directory mode is a loop over single PDFs.
Fixed
-
Items deleted from Zotero stayed in semantic search results indefinitely when the index was updated from the MCP tool in local mode (#457). The pass that removes deleted items ran only on the incremental path, and the MCP tool always takes the full-scan path in local mode because it extracts fulltext. The full scan then promoted the sync watermark anyway, so a later
zotero-mcp update-dbfound the library unchanged and never reconciled either. The deletion pass now runs on both paths (not on a forced rebuild, which starts empty, or alimittest run), with the same library scoping and mass-deletion guard; a full scan that cannot list the library's items keeps the watermark where it was. The tool's result also reports how many items were deleted and why a deletion check was skipped. -
Text from a deleted or replaced attachment kept matching semantic searches after an incremental update (#428). An indexed item whose text-bearing attachments had all been removed fell through to "already up to date", so its PDF-derived passages stayed in the index until a forced rebuild; a replaced PDF with the same priority was treated the same way. Such items are now re-indexed: metadata-only when no attachment is left, re-extracted when the stored attachment set differs from the current one. Documents indexed before attachment keys were recorded are not compared, so upgrading does not re-extract the whole library. Diagnosis by @feima3333.
-
Attaching a file in hybrid mode reported success even when the file could never reach this computer (#463). With local reads and web writes, an uploaded file goes to cloud storage (Zotero's or WebDAV) and appears in
~/Zotero/storageonly when Zotero syncs files. With file syncing turned off it never did: the attachment looked fine, and every local tool that later resolved its path failed. When the upload went through the web API, the server reads locally, and every Zotero profile on the machine has file syncing off, the success message now says so and points to turning file syncing on or, on Zotero 10,zotero-mcp authorize-local, which writes files straight to the local library. -
add_by_urlcould create a duplicate from a page's embedded metadata even withif_exists='file'or'skip'(#515). When a page carried citation tags but no DOI, the item was built from those tags without checking the ISBN the page declared, and without re-checking the URL after the page fetch, so an item saved under a different URL, or one a parallel add created while the page was loading, was added again. That branch now looks for an existing item by the page's ISBN and then by URL, under the same identifier lock the plain-webpage branch uses. URLs are still compared exactly (after trimming); matching by title is left to #506. -
~/.config/zotero-mcpwas created with the process umask, usually0755, so other accounts on the machine could read the semantic index (#401). That directory holdschroma_db, which stores the indexed metadata and full text of the library, next toconfig.json. It is now created owner-only (0700) by every code path that can be first to create it: the setup wizard,update-db, the semantic search client, local write authorization and the update lock. An existing directory is not changed, since its mode may be deliberate; if other users can read it, a warning says how to restrict it. Based on a PR by @michaelzehetleitner. -
Local reads could not see anything Zotero had written since its last checkpoint, so tools reported "No PDF attachment found" for a paper added minutes earlier (#536). Zotero keeps
zotero.sqlitein WAL mode under an exclusive lock. The reader opened it withimmutable=1, which gets past the lock by ignoring the-walfile, so new items, their attachments and recent edits stayed invisible until Zotero restarted; for linked files there was no download fallback either. When the WAL holds changes, the reader now opens a private copy of the database together with its WAL, which SQLite applies on open. The copy lives in an owner-only temp directory, is shared across readers in the process until either file changes, is retried if a checkpoint lands mid-copy, and is removed at exit. With no WAL the database is read in place as before; if a copy cannot be made the reader falls back to the in-place read, andZOTERO_MCP_DB_SNAPSHOT=0turns the copy off for very large databases. -
A
uv toolor pipx install outside the default directories was told topip installa missing extra (#534). The installer was detected from the package's path alone (.../uv/tools/...,.../pipx/venvs/...), so an environment under a customUV_TOOL_DIR,PIPX_HOMEor relocated data directory fell through to the generic hint, which leads withpip. Detection now also checks for the marker each installer writes at the environment root,uv-receipt.tomlorpipx_metadata.json, so those installs get the command that works for them. -
Better BibTeX and pdfannots diagnostics were printed to stdout, which broke
zotero-cli --json annotations listand could corrupt the MCP stdio stream (#529). When Better BibTeX rejected a cite-key search, the client printedError searching for cite keys: ...before falling back to the Zotero API, so the command's stdout was that line followed by a valid envelope, andjson.loads/jqfailed on a lookup that had succeeded. The pdfannots helpers on thezotero_get_annotationspath printed install and extraction messages the same way, and on the stdio transport stdout is the JSON-RPC channel. All of these now go throughlogging, which writes to stderr. -
zotero-cli --json get childrenwith more than one parent key reportedcount: 0(#505). Several keys render through the grouped children listing, whose- [KEY] Attachment: ...lines matched none of the shapes the JSON path reads keys back from, so every child was dropped before the fetch. That shape is now recognised too, the same kind of fix #504 made for the single-parent listing. -
Items with no text-bearing attachments were reported as "PDF extraction previously failed", and a rebuild made the count grow (#446). Any item whose extraction pass produced no text was marked
has_fulltext="failed", including notes-only entries, webpage stubs and books with no file, which never had anything to extract. Every later run listed them as failed extractions, and--force-rebuildre-marked all of them, so the number went up (928 to 2273 in the report) while the rebuild itself said0 errors. Items with no text-bearing attachments now get no marker: they index metadata-only, count as up to date, and re-index when a first attachment appears. Legacy markers on such items are reported as metadata-only rather than as failures, and the run that does the extraction now says how many items had attachments that produced no text. -
zotero-mcp update-db --batch --openai-batchread the config and built the embedding client before rejecting the flag combination. The conflict is a usage error, so it is now checked first, before anything reads or writes the config. The old order also madetests/test_generic_batch_flags.pydepend on the developer's real config and on that config's embedding provider being installed. -
The test suite was red on a clean checkout against pyzotero >=1.15 (#511). pyzotero 1.15 moved off httpx onto httpx2 — httpx 2.x, published under a separate distribution name, and a disjoint class hierarchy.
tests/test_rate_limit_guard.pydidimport httpx, which resolves to the httpx 0.28 thatmcp/httpx-ssepull in regardless of pyzotero, and built its 429 fixtures ashttpx.Response. pyzotero's_post_checkcatches onlyhttpx2.HTTPError, so those responses escaped it uncaught: the retry loop never saw the 429,error_handlernever recorded the backoff, andTooManyRetriesErrorwas never raised. Five tests failed — three leakingHTTPStatusErrorwhere they expect a retry or the typed error, two degrading to[]becausefind_existing_items' genericexcept Exceptioncaught the leaked error instead of the typed clause above it. Since CI installs barepyzoteroagainst a floor with no ceiling and there is no lockfile, it resolves whatever is newest, so every PR opened after 1.15.0 reached PyPI showed the same five red checks. The fixtures are now built from whatever HTTP library the installed pyzotero speaks — asked of pyzotero rather than guessed at import time, since both libraries can be installed at once — which keeps the declaredpyzotero>=1.13.5range green rather than narrowing it. Nothing the tests pin actually changed: 1.15.1 still retries a rate-limited read three times and still waits out a server-supplied backoff. The same mismatch has a runtime consequence on the local-API path, where our own httpx 0.28 client bypasses pyzotero's error handling for real; that is #512 and is not addressed here. -
Errors from the local Zotero API bypassed pyzotero's error handling, and a throttled dedup search silently created duplicates (#512). Local mode is the one path where we build the HTTP client rather than letting pyzotero build its own — it exists to pin HTTP/1.1 for Zotero 8's local server (#160) — and it built that client from
httpx. pyzotero 1.15 moved tohttpx2(httpx 2.x, published under its own package name), and the two are disjoint class hierarchies, so pyzotero'sexcept httpx2.HTTPErrornever fired for a response our client produced.error_handlernever ran, and every error status reached our call sites as a rawhttpx.HTTPStatusErrorinstead of the typed pyzotero exception they match on — verified against pyzotero 1.15.1 by replaying canned responses through a client constructed exactly as the local path constructs one: 429, 403, 404, 412 and 500 all arrived asHTTPStatusError. The costly one is 429.find_existing_itemsspecial-cases a rate-limited search precisely so it does not degrade to "not present":except (TooManyRetriesError, TooManyRequestsError): raise, sitting above the genericexcept Exception: return []. A leakedHTTPStatusErrormisses the first clause and is caught by the second, so the dedup search reported "nothing found" andif_exists='file'created a duplicate of an item that was already there — the exact failure that clause exists to prevent, now happening silently. Two further consequences on the same path: no retry and no backoff wait happened at all, sinceerror_handleris what records the server's backoff and the retry loop only continues on a recognised 429, so a throttled local client hammered rather than backing off; and the other typed handlers were bypassed the same way, including thePreConditionFailedErrorretry that closes the version-conflict window on writes. The local client is now built from whichever HTTP library the installed pyzotero is built against, resolved from pyzotero itself rather than from a version comparison, so thepyzotero>=1.13.5floor keeps working on both sides of the 1.15 switch — both libraries spell the HTTP/1.1 transport pin identically, so #160's fix crosses unchanged. Web API calls were never affected: pyzotero builds its own client there. -
zotero-cli --jsonno longer drops most of a search's results in local mode (#499). The JSON path reuses the markdown path's selection verbatim — it reads the item keys back out of the rendered markdown and re-fetches them — so the two modes are meant to differ only in rendering. The re-fetch asked foritemKey=<chunk>withlimit=len(chunk), which is exactly right against the web API and wrong against the local one: the local API answers anitemKeyfilter with the requested items plus their children, and it lists the children first. A cap sized to the number of keys asked for therefore truncated the response before any parent appeared. Requesting a single key atlimit=1returned that item's attachment and nothing else, leavingfoundholding only child keys, and the projection step — which drops keys it cannot resolve rather than faking them — returned nothing. On a live library a search that markdown mode answered with 14 items came back ascount: 6, and single-key selections came back ascount: 0; no error was raised in either case, so a caller reading only the JSON saw a smaller library than it has. The re-fetch is now paged through_paginateand filtered to the requested keys, which is whatzotero_export_bibliographyalready does for the same quirk (#371). Web API users are unaffected and pay no extra request: that API filtersitemKeycorrectly, so the first page comes back short of a full page and paging stops there. -
A failed page read is reported as a failure, not returned as successful output (#528).
read_pdf_pagesreturned its errors as prose —No PDF attachment found for item: …,Could not read PDF for item …, and the range/key validation messages — which makes them indistinguishable from content. The--jsonenvelope is built from that return value, sozotero-cli --json read TESTKEY1 --start-page 2 --end-page 1answered{"ok": true, "data": {"text": "Error: end_page must be greater than or equal to start_page.", "chars": 60}}and exited 0, and the MCP tool answeredisError: falsewith the same string understructuredContent. A caller had to parse English to tell "here are the pages" from "there are no pages"; a pipeline consuming either could carry the error text forward as content. The failures are now raised asPdfReadError, aToolErrorsubclass carrying a stablecode, so FastMCP marks the tool result as an error andcli_standalone.main's existing handler turns it into anok: falseenvelope with a nonzero exit — neither of those paths needed a change, which is the point: the tool was the only thing not using them.error.codeis one ofempty_item_key,invalid_page_range,no_pdf_attachment,pdf_unreadable,page_out_of_rangeorpage_limit_exceeded, so a caller can branch on the failure without matching on the message. Message text is unchanged, and the successful read path returns exactly what it did before. -
zotero_get_attachment_pathaccepts an attachment's own key. Handed the key of a PDF attachment — which is exactly whatzotero_get_item_childrenlists — it returned "No attachments found for item", because it only walked the parent's children viaget_item_by_key, whose query excludes the 'attachment' item type (the #372 root cause again). It now falls back toget_attachment_by_keyand resolves that single attachment; parent keys behave as before, and an unknown key still reports nothing. -
Tool descriptions and error text no longer name tools removed in 0.9.0.
zotero_delete_itemsent callers tozotero_delete_note,zotero_attach_filetozotero_add_from_file, and BibTeX/CSL JSON results were headed# zotero_add_by_bibtex/# zotero_add_by_csl_json; all now namezotero_manage_note/zotero_add_item. Thezotero_semantic_searchfiltersexample used ayearkey the index never stores together with a second key, which ChromaDB rejects ("Expected where to have exactly one operator"); it now shows a single stored key and explains$and. A test reads the shipped descriptions and fails if any names a tool that cannot be registered. -
zotero-cli configprintedOPENAI_API_KEYandGOOGLE_API_KEYin full. The mask list only named Zotero and WebDAV secrets. Any key ending in_API_KEY,_PASSWORD,_TOKENor_SECRETis now masked by default (--show-secretsstill reveals them), andGEMINI_API_KEYis included in the listing. The packaged agent skill tells agents to runzotero-cli configfirst, so the full keys were landing in agent transcripts. -
zotero-mcp updatecould report a successful update while the old version stayed installed. The uv path ranuv tool upgrade zotero-mcp-serverand took exit 0 as "updated". But a tool installed with an exact pin (uv tool install "zotero-mcp-server[all]==0.9.0") has that pin recorded in itsuv-receipt.toml, anduv tool upgraderesolves against the receipt: it prints "Nothing to upgrade", exits 0, and changes nothing. The updater then verified the still-installed module (which imports fine, being the version it was already running) and printed "Successfully updated from 0.9.0 to 0.11.0" — a message assembled from the PyPI lookup, with nothing re-read from disk. The outcome now comes from the version actually installed afterwards: distribution metadata read by a fresh interpreter (-E, from a neutral working directory) — the uv tool environment's own when that is what was updated — so neither the already-imported module, the working directory norPYTHONPATHcan answer for it, while apip install --userinstall in the user site is still seen. An unchanged version is reported as the failure it is, naming the pin as the thing to check; a downgrade is a failure too unless--forceasked for it; landing on a newer release that is not the latest says so. For uv tools, an exact==pin is reinstalled withuv tool install --force --python <receipt python> "zotero-mcp-server[<receipt extras>]@latest"(the escape uv's own hint suggests), which also rewrites the receipt without the pin so lateruv tool upgraderuns work normally; the same reinstall runs when an unconstraineduv tool upgradeexits 0 without moving the version. The extras and Python come from the receipt because a bareuv tool install --force zotero-mcp-serverwould drop[all]and let uv pick a different interpreter — so when a receipt exists but cannot be read, nothing is reinstalled and the manual command is printed instead, rather than guessing. A range constraint (>=0.9,<1) is left touv tool upgradeand never overridden. On Windows the reinstall is left to the user, sinceuv tool install --forcerecreates the environment holding the runningpython.exe.tomliis a dependency on Python 3.10, wheretomllibis not in the standard library. -
batch-importnow submits the chunks the enqueued-token throttle parked as pending. Throttling is always on (the provider Tier-1 caps are the defaults), so anyupdate-db --batchrun larger than one budget's worth left chunks with no batch id, and only--auto-loopever submitted them: a plainbatch-importskipped them every time, the help text and the run summary said otherwise, and a throttled--force-rebuildrun could never import (it demands every chunk complete) while the only way out, a newupdate-db --batch, re-billed the whole library.batch-importnow imports what has completed, then submits what fits the run's budget, and says so ("Pending chunks submitted: N"); a force-rebuild run that is still waiting on batches reports that instead of failing. A run that a newer run for the same library has superseded is left alone: no further chunks are submitted for it, and it does not advance the sync watermark either, since the newer run covers those items and will advance it itself. Importing named batches (--batch-id) never submits pending chunks — runbatch-importwithout ids to resume a throttled run. Runs are now ordered by the timestamp they were submitted with rather than by manifest file mtime, so inspecting an old run withbatch-statusno longer makes it the run a laterbatch-importresumes. -
Adding a SpringerLink article by URL scraped nothing, because the publisher-page fetch was posing as a browser (#229).
_fetch_embedded_metadataidentified itself asMozilla/5.0 (compatible; zotero-mcp/1.0; …), and SpringerLink's WAF reads a Mozilla-prefixed User-Agent as a browser claim, then challenges it when the connection behind it does not look like a browser's — so the disguise lost pages that an honest string is served without complaint. Measured 2026-09-03 againstlink.springer.com/article/10.1006/bulm.1999.0141, counting occurrences ofcitation_author: the Mozilla-prefixed string got a ~3 KB challenge page with none of them, a verbatim Chrome 126 string got the same challenge page, and bothzotero-mcp/1.0 (+https://…)andpython-requests/2.32.0got the ~330 KB article with 57. That is one publisher, one IP, one day, so the measurement is the argument for the change rather than something the tests assert — the mechanism it suggests (TLS/HTTP fingerprint against User-Agent claim) is a hypothesis, and "any Mozilla-prefixed UA from a non-residential IP is challenged" fits the same numbers. No counter-regression on the set this fetch was originally verified against: PLOS and Frontiers return byte-identical responses under either string.Four copies of one User-Agent literal is how the drift went unnoticed, so they now share
utils.USER_AGENT. It lives inutils.pyrather than the tools layer becausetools/_helpers.pycannot import fromtools/write.pywithout a cycle — and_helpers._guarded_pdf_get, which fetches publisher-hosted PDFs, is the closest sibling of the bug being fixed and was sending no User-Agent at all; it is wired up too. CrossRef, OpenLibrary and Google Books already sent an honest string, though not this exact one: they sentzotero-mcp/1.0 (https://…)against the constant's(+https://…). The+is conventional in bot User-Agents and changes nothing measurable — all three return byte-identical responses either way, and CrossRef's polite pool keys on amailto:that neither form carries — but it is a change for those three. This does not reach every outbound client in the package: OpenAlex, Unpaywall, Semantic Scholar, PMC, arXiv, scite and the updater still send no User-Agent, and converting those is worth doing but deliberately not done here. -
A DOI added here now reads like the same DOI saved from the browser (#229).
add_by_doimapped the raw CrossRef/worksmessage straight onto a Zotero item. Zotero doesn't: its bundled "Crossref REST" translator repairs the record first, and skipping those repairs was a large part of why the same DOI read worse here than through the connector. Three repairs are ported. Creator names deposited in capitals stayed shouted —10.1006/bulm.1999.0141deposits{given: "R", family: "SOLE"}where Zotero renders{firstName: "R", lastName: "Sole"}— socapitalize_nameportsZotero.Utilities.capitalizeName, which repairs a wholly-upper or wholly-lower word and leaves a mixed-case one alone, meaningMacGregorsurvives andO'nealis not second-guessed intoO'Neal. Titles were silently truncated at the colon, because CrossRef registers the half after it separately assubtitle:10.1108/01409171011011571splits "Procedural justice, participation and power distance" from "Information sharing in Chinese firms", andtitle[0]alone dropped the second half. And deposited strings carried the registry's own damage — CrossRef serves JATS and MathML inline, escapes markup as<i>, stores literal newlines mid-title, and occasionally serves UTF-8 decoded as Latin-1. None of that is exotic: in a 1200-record sample 1.25% of titles carried an XML entity and 1.4% a newline, and a raw newline in a title also breaks this package's own markdown headings.strip_unsupported_markupkeeps the inline subset Zotero renders (<i>,<b>,<sub>,<sup>, small caps) and drops the rest tags-only, andrepair_crossref_stringruns as a per-field pass over the finished item — before the landing-page metadata is merged in, since a publisher's own page is not CrossRef and does not carry CrossRef's deposit damage.The limits are Zotero's limits. Single-field creators (
{"name"}, nofamily) pass through verbatim, so "NASA" is not a shouted "Nasa". Titles are not case-repaired: Zotero leaves that to the user's sentence-case transform, and a title is likelier than a surname to contain acronyms worth keeping.abstractNoteis excluded from the string pass and keeps its existingclean_htmltreatment — Zotero renders an abstract's inline markup, but here it feeds the embedding model insemantic_search, which reads<i>as tokens rather than as emphasis. The subtitle merge is gated ontitle[0]being non-empty, as Zotero's is, because CrossRef does deposittitle: [""](10.1016/s1049-3867(05)80079-9): merging a subtitle onto an empty title yields": A Review", which is truthy, which would then block the landing-page fallback from filling the gap — turning a missing title into a wrong one. Still not carried over, and each worth its own change: CrossRef'schairandtranslatorcreator roles are still dropped, and several fields Zotero maps (language, ISBN, place, institution) are still unmapped. -
An add that reads a landing page now keeps the authors that page names and CrossRef does not (#229). CrossRef is the registry of record for where a paper was published; it is not a reliable record of who wrote it, since it serves what the publisher deposited and older deposits are routinely truncated.
10.1006/bulm.1999.0141(Bull. Math. Biol., 1999) deposits one author for a paper with four, and no abstract, while SpringerLink's page for the same DOI carries all four and the abstract. The url route already read that page and handed it down assupplemental, then discarded the three missing authors — page creators were used only when CrossRef supplied none at all. The rule now is that the page names people CrossRef is missing, tested on accent-folded surnames rather than on list length. Length alone is too easy to inflate: that very page emits each author twice, under bothcitation_authoranddc.creator, and a name the two families punctuate differently would surviveEmbeddedMetadata's exact-match dedup and silently double an item's author list. The folding is needed for the motivating case itself — CrossRef saysSOLEwhere the page saysSolé. Where both sources know a person, CrossRef's entry is kept: itsgiven/familysplit is authoritative where the page's is guessed from whitespace and is often initials-only, so recovering a missing fourth author must not cost the other three their forenames. The accepted cost is that a diacritic CrossRef dropped stays dropped, because splicing the page's surname onto CrossRef's forename would be inventing a person out of two records.Three shapes are declined outright. CrossRef holding a single-field creator, because large collaborations are deposited that way and a page listing 300 individuals is making a different claim about authorship, not a fuller one. CrossRef holding editors or translators, because
EmbeddedMetadata.authorsfoldscitation_editoranddc.contributorin with the authors, so on a book chapter the page's flat list cannot be reconciled with CrossRef's typed one without promoting editors to authors and dropping the editors CrossRef had right — teaching the reader to keep those apart would lift that restriction, and is a change for its own commit. And a page list that is not a strict superset, since a disagreement at equal length is a spelling dispute and CrossRef wins those. The page's abstract now folds into the existing gap-fill as well, which had every other page field but not that one; it is the field CrossRef most often lacks outright — neither Nature's nor PLOS's record for the papers checked here carried one, though both landing pages did. Onlyadd_by_urlis affected, that being the one route which currently reads a page; whether a bare DOI should also get one is a separate question and a separate commit. -
Cross-library semantic hits are hydrated through a client scoped to their own library when the local database cannot serve them (#492). The
zotero.sqlitefallback that #487 added for foreign hits does not exist outside local mode, so a web-API user with group libraries got every cross-library hit back as a bare item key with a 404 — the bound client was asked for a key its library never held, and there was no second chance. Enrichment now falls through to a per-library pyzotero client, built from the same credentials and cached pergroup_id, so ten hits from one group cost one client and an unreachable library fails once rather than per hit. Local mode keeps the batched single-query sqlite path first and only reaches the scoped client for a key the snapshot could not serve, such as an item newer than the copy on disk. When a hit genuinely cannot be served — no credentials, or a key the credentials cannot see — the error now names the library and what is missing, instead of surfacing a 404 from a library that was never going to have the item.