github 54yyyu/zotero-mcp v0.12.0
0.12.0

4 hours ago

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_collection renames 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=True moves 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 as zotero-cli collections update KEY --name ... --parent ... --top-level.

  • The open-access PDF cascade now tries the citation_pdf_url the article's own landing page advertised (#229). html_metadata.EmbeddedMetadata has parsed that tag since the embedded-metadata reader landed and nothing had ever read the field — grep for pdf_url outside html_metadata.py and 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_url is 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_url never fetches, so attach_mode="linked_url" never reached the SSRF guard at all — a file:///etc/passwd in 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_pdf trusted Content-Type, and Cloudflare interstitials and "PDF viewer" endpoints — exactly what a publisher's citation_pdf_url points at — serve HTML under application/pdf, which was attached as if it were the paper; it now checks the bytes for a %PDF header. The download path's SSRF protection is otherwise inherited correctly: _guarded_pdf_get rejects 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_metadata runs under @with_zotero_api_lock on 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_url entry 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_url take page_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 — and add_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-db found the library unchanged and never reconciled either. The deletion pass now runs on both paths (not on a forced rebuild, which starts empty, or a limit test 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/storage only 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_url could create a duplicate from a page's embedded metadata even with if_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-mcp was created with the process umask, usually 0755, so other accounts on the machine could read the semantic index (#401). That directory holds chroma_db, which stores the indexed metadata and full text of the library, next to config.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.sqlite in WAL mode under an exclusive lock. The reader opened it with immutable=1, which gets past the lock by ignoring the -wal file, 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, and ZOTERO_MCP_DB_SNAPSHOT=0 turns the copy off for very large databases.

  • A uv tool or pipx install outside the default directories was told to pip install a missing extra (#534). The installer was detected from the package's path alone (.../uv/tools/..., .../pipx/venvs/...), so an environment under a custom UV_TOOL_DIR, PIPX_HOME or relocated data directory fell through to the generic hint, which leads with pip. Detection now also checks for the marker each installer writes at the environment root, uv-receipt.toml or pipx_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 list and could corrupt the MCP stdio stream (#529). When Better BibTeX rejected a cite-key search, the client printed Error searching for cite keys: ... before falling back to the Zotero API, so the command's stdout was that line followed by a valid envelope, and json.loads/jq failed on a lookup that had succeeded. The pdfannots helpers on the zotero_get_annotations path printed install and extraction messages the same way, and on the stdio transport stdout is the JSON-RPC channel. All of these now go through logging, which writes to stderr.

  • zotero-cli --json get children with more than one parent key reported count: 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-rebuild re-marked all of them, so the number went up (928 to 2273 in the report) while the rebuild itself said 0 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-batch read 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 made tests/test_generic_batch_flags.py depend 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.py did import httpx, which resolves to the httpx 0.28 that mcp/httpx-sse pull in regardless of pyzotero, and built its 429 fixtures as httpx.Response. pyzotero's _post_check catches only httpx2.HTTPError, so those responses escaped it uncaught: the retry loop never saw the 429, error_handler never recorded the backoff, and TooManyRetriesError was never raised. Five tests failed — three leaking HTTPStatusError where they expect a retry or the typed error, two degrading to [] because find_existing_items' generic except Exception caught the leaked error instead of the typed clause above it. Since CI installs bare pyzotero against 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 declared pyzotero>=1.13.5 range 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 to httpx2 (httpx 2.x, published under its own package name), and the two are disjoint class hierarchies, so pyzotero's except httpx2.HTTPError never fired for a response our client produced. error_handler never ran, and every error status reached our call sites as a raw httpx.HTTPStatusError instead 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 as HTTPStatusError. The costly one is 429. find_existing_items special-cases a rate-limited search precisely so it does not degrade to "not present": except (TooManyRetriesError, TooManyRequestsError): raise, sitting above the generic except Exception: return []. A leaked HTTPStatusError misses the first clause and is caught by the second, so the dedup search reported "nothing found" and if_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, since error_handler is 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 the PreConditionFailedError retry 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 the pyzotero>=1.13.5 floor 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 --json no 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 for itemKey=<chunk> with limit=len(chunk), which is exactly right against the web API and wrong against the local one: the local API answers an itemKey filter 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 at limit=1 returned that item's attachment and nothing else, leaving found holding 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 as count: 6, and single-key selections came back as count: 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 _paginate and filtered to the requested keys, which is what zotero_export_bibliography already does for the same quirk (#371). Web API users are unaffected and pay no extra request: that API filters itemKey correctly, 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_pages returned 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 --json envelope is built from that return value, so zotero-cli --json read TESTKEY1 --start-page 2 --end-page 1 answered {"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 answered isError: false with the same string under structuredContent. 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 as PdfReadError, a ToolError subclass carrying a stable code, so FastMCP marks the tool result as an error and cli_standalone.main's existing handler turns it into an ok: false envelope 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.code is one of empty_item_key, invalid_page_range, no_pdf_attachment, pdf_unreadable, page_out_of_range or page_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_path accepts an attachment's own key. Handed the key of a PDF attachment — which is exactly what zotero_get_item_children lists — it returned "No attachments found for item", because it only walked the parent's children via get_item_by_key, whose query excludes the 'attachment' item type (the #372 root cause again). It now falls back to get_attachment_by_key and 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_item sent callers to zotero_delete_note, zotero_attach_file to zotero_add_from_file, and BibTeX/CSL JSON results were headed # zotero_add_by_bibtex / # zotero_add_by_csl_json; all now name zotero_manage_note / zotero_add_item. The zotero_semantic_search filters example used a year key 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 config printed OPENAI_API_KEY and GOOGLE_API_KEY in full. The mask list only named Zotero and WebDAV secrets. Any key ending in _API_KEY, _PASSWORD, _TOKEN or _SECRET is now masked by default (--show-secrets still reveals them), and GEMINI_API_KEY is included in the listing. The packaged agent skill tells agents to run zotero-cli config first, so the full keys were landing in agent transcripts.

  • zotero-mcp update could report a successful update while the old version stayed installed. The uv path ran uv tool upgrade zotero-mcp-server and 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 its uv-receipt.toml, and uv tool upgrade resolves 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 nor PYTHONPATH can answer for it, while a pip install --user install 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 --force asked for it; landing on a newer release that is not the latest says so. For uv tools, an exact == pin is reinstalled with uv 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 later uv tool upgrade runs work normally; the same reinstall runs when an unconstrained uv tool upgrade exits 0 without moving the version. The extras and Python come from the receipt because a bare uv tool install --force zotero-mcp-server would 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 to uv tool upgrade and never overridden. On Windows the reinstall is left to the user, since uv tool install --force recreates the environment holding the running python.exe. tomli is a dependency on Python 3.10, where tomllib is not in the standard library.

  • batch-import now submits the chunks the enqueued-token throttle parked as pending. Throttling is always on (the provider Tier-1 caps are the defaults), so any update-db --batch run larger than one budget's worth left chunks with no batch id, and only --auto-loop ever submitted them: a plain batch-import skipped them every time, the help text and the run summary said otherwise, and a throttled --force-rebuild run could never import (it demands every chunk complete) while the only way out, a new update-db --batch, re-billed the whole library. batch-import now 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 — run batch-import without 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 with batch-status no longer makes it the run a later batch-import resumes.

  • Adding a SpringerLink article by URL scraped nothing, because the publisher-page fetch was posing as a browser (#229). _fetch_embedded_metadata identified itself as Mozilla/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 against link.springer.com/article/10.1006/bulm.1999.0141, counting occurrences of citation_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 both zotero-mcp/1.0 (+https://…) and python-requests/2.32.0 got 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 in utils.py rather than the tools layer because tools/_helpers.py cannot import from tools/write.py without 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 sent zotero-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 a mailto: 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_doi mapped the raw CrossRef /works message 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.0141 deposits {given: "R", family: "SOLE"} where Zotero renders {firstName: "R", lastName: "Sole"} — so capitalize_name ports Zotero.Utilities.capitalizeName, which repairs a wholly-upper or wholly-lower word and leaves a mixed-case one alone, meaning MacGregor survives and O'neal is not second-guessed into O'Neal. Titles were silently truncated at the colon, because CrossRef registers the half after it separately as subtitle: 10.1108/01409171011011571 splits "Procedural justice, participation and power distance" from "Information sharing in Chinese firms", and title[0] alone dropped the second half. And deposited strings carried the registry's own damage — CrossRef serves JATS and MathML inline, escapes markup as &lt;i&gt;, 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_markup keeps the inline subset Zotero renders (<i>, <b>, <sub>, <sup>, small caps) and drops the rest tags-only, and repair_crossref_string runs 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"}, no family) 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. abstractNote is excluded from the string pass and keeps its existing clean_html treatment — Zotero renders an abstract's inline markup, but here it feeds the embedding model in semantic_search, which reads <i> as tokens rather than as emphasis. The subtitle merge is gated on title[0] being non-empty, as Zotero's is, because CrossRef does deposit title: [""] (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's chair and translator creator 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 as supplemental, 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 both citation_author and dc.creator, and a name the two families punctuate differently would survive EmbeddedMetadata's exact-match dedup and silently double an item's author list. The folding is needed for the motivating case itself — CrossRef says SOLE where the page says Solé. Where both sources know a person, CrossRef's entry is kept: its given/family split 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.authors folds citation_editor and dc.contributor in 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. Only add_by_url is 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.sqlite fallback 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 per group_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.

Don't miss a new zotero-mcp release

NewReleases is sending notifications on new releases.