Polarize
GitHub Get in touch

Work · Repository

paper-fetch

Open-access paper library: search, legal full-text fetch, provenance, and an MCP server

  • Python
  • MIT licence
  • 12 merged PRs

View on GitHub → Changelog ↓

README

A deduplicated, private library of legal open-access scientific papers. Give it a DOI, OpenAlex ID, PMID, PMCID or arXiv ID; it looks in what it already holds first, and only then asks a chain of open-access providers for a copy. Every copy it keeps is validated (checksum, format, readable text) and stored with a provenance record: where it came from, under what licence, and every route that was tried.

It ships as a Python library, a command line (paper-fetch), and an MCP server (paper-fetch-mcp) so that an LLM agent can search, fetch and read papers through a small, stable tool contract.

$ paper-fetch fetch 10.1371/journal.pcbi.1003285
✓ W2036318837  [retrieved]  2013  Ten Simple Rules for Reproducible Computational Research
    oa=gold  route=pmc-s3:jats-xml  format=jats-xml  license=CC BY
$ paper-fetch fetch 10.1371/journal.pcbi.1003285       # second time: no network at all
✓ W2036318837  [library]  2013  Ten Simple Rules for Reproducible Computational Research

Searching by discipline, for a project, with memory

Three things make repeated literature work cheaper. All of them are deterministic: no language model runs inside paper-fetch.

  • Discipline profiles hold the search half of a field: the controlled terms it is indexed under (MeSH descriptors or declared free text, each with the synonyms people write instead), the quantities it reports, its authoritative sources, and advice for whoever writes the queries. Searching with --profile cardiovascular also runs the query with each synonym swapped for its indexed term (“HRV” → “heart rate variability”), and hits whose titles name the field’s terms rank first. Four profiles ship built in (cardiovascular, neuroscience, respiratory, vestibular); add or replace one with a TOML file in ~/.config/paper-fetch/profiles/ (or $PAPER_FETCH_PROFILE_DIR), shaped like those in src/paper_fetch/profile_data/.
  • Named collections list a project’s papers inside the one shared library. A paper is stored once, whichever collections list it. A collection can carry a profile, which then applies to every search made for it.
  • Search memory remembers every search: its query, profile, collection and what it found. A new search reports earlier searches on the same concept (synonyms count as the same concept) and marks each hit an earlier search already found. paper-fetch recall asks the memory directly, by concept, discipline, collection or paper, without touching the network.

  • Passage retrieval answers “which passage of which held paper answers this?”, which is what quoting needs. Held full texts are split into passages (the reference list is left out) in a local SQLite index, ranked by BM25 fused with embedding similarity when an embedding model is configured, and optionally re-scored by a cross-encoder. Each passage has a stable id (<work>#p<ord>) and exact character offsets. A retrieval scoped to a collection or a list of papers indexes them first if needed.
$ paper-fetch collection sleep-review --create --profile neuroscience
$ paper-fetch search "slow oscillations and memory consolidation" --collection sleep-review
$ paper-fetch fetch 10.1371/journal.pcbi.1003285 --collection sleep-review
$ paper-fetch recall "memory consolidation"
$ paper-fetch retrieve "does sleep improve recall?" --collection sleep-review

What it will and will not do

  • Open-access copies only. A provider returns a location only for a copy it reports as open access (Europe PMC’s isOpenAccess, PMC’s open-access subset, OpenAlex’s per-location is_oa, Unpaywall’s best_oa_location, a preprint server’s own copy, a Creative Commons licence on the record). There is no code path that follows a paywall, scrapes a publisher page or uses a shadow library. If no provider reports an open copy, nothing is downloaded and the answer says so.
  • Open access is a right to read, not to republish. Many OA copies carry no reuse licence at all, and NIH author manuscripts in PMC are under a text-and-data-mining licence. So the store is private (files 0600, directories 0700, checked after every write; S3 objects never get an ACL and are checked with an anonymous request), and each copy records the licence exactly as its provider reported it. Quote briefly and cite; do not redistribute stored files.
  • A copy you legitimately hold (an author copy, a purchased PDF) can be added with paper-fetch add file.pdf <id> --rights "...". The rights statement is required and recorded. This is deliberately not an MCP tool: that statement is the operator’s to make, not a model’s.
  • Validated, not assumed. A candidate is refused if its md5 does not match the provider’s published checksum, if it is an HTML page (a login screen served as “PDF”), if it is not the format claimed, or if the extracted text does not look like prose.
  • Loud, per-provider failure, of two kinds. A provider that is down, rate-limited or out of allowance is reported by name as unavailable, the other providers answer, and it is left alone for a while (a minute after an outage, five after a 429, the rest of the process once an allowance is spent) instead of being asked again by every search. A provider that fails because of a defect (it rejected our request, or its answer changed shape) is reported as error, named in the search’s broken list and logged with its traceback (paper-fetch problems). Neither is ever folded into “no results”.
  • Not Google Scholar. It has no API and its terms forbid automated querying.

Install

Python 3.11+.

paper-fetch is not on PyPI; install it from this repository, with the extras you need:

pip install "paper-fetch[mcp] @ git+https://github.com/polarizetech/paper-fetch"     # CLI + MCP server
pip install "paper-fetch[mcp,s3] @ git+https://github.com/polarizetech/paper-fetch"  # ... plus the S3-compatible store (boto3)
# other extras: `retrieval` (numpy, for embedding-based passage retrieval),
#               `rerank` (onnxruntime + tokenizers, for the cross-encoder)
# or, from a checkout:
uv sync && uv run paper-fetch --help

The core depends only on the standard library and pypdf (pure Python, used for PDF text extraction; no external binaries such as pdftotext). mcp is needed only for the server, boto3 only for the S3 store, numpy only for embedding retrieval, and onnxruntime only for reranking.

Configure

Everything is configured with environment variables; none is required. Both paper-fetch and paper-fetch-mcp also read KEY=value lines from *.env files in ~/.config/paper-fetch/ (or $PAPER_FETCH_ENV_DIR), taking only the variables below and never overriding the environment. Keep those files out of repositories (chmod 600).

Variable Purpose
PAPER_FETCH_EMAIL Contact email. Sent to OpenAlex (polite pool), NCBI and PubMed, and in the User-Agent. Required by Unpaywall; without it the unpaywall route is skipped. Never sent unless set.
OPENALEX_API_KEY Optional (free). Raises limits and enables OpenAlex’s own cached full texts. A wrong key fails loudly rather than silently degrading.
OPENCITATIONS_ACCESS_TOKEN Optional; OpenCitations asks applications to send one.
NCBI_API_KEY Optional; raises PubMed from 3 to 10 requests/s.
CORE_API_KEY Optional; raises CORE’s limits.
SEARXNG_URL Optional. A SearXNG instance with JSON output enabled, used as a last-resort search fallback (never a download source). Without it, a search that finds no open copy asks its caller to search the web instead (ask_the_web, then leads).
PAPER_FETCH_PROVIDERS Comma-separated locate order (default below).
PAPER_FETCH_SEARCH_PROVIDERS Comma-separated federated-search set (default below).
PAPER_FETCH_WEB_FALLBACK 0 disables the web fallback.
PAPER_FETCH_STORE local (default), s3 or memory.
PAPER_FETCH_DATA_DIR Local store directory. Default ~/.local/share/paper-fetch ($XDG_DATA_HOME/paper-fetch).
PAPER_FETCH_CACHE Read-through cache for the S3 store. Default ~/.cache/paper-fetch.
PAPER_FETCH_S3_BUCKET S3 store: bucket (required when PAPER_FETCH_STORE=s3).
PAPER_FETCH_S3_ENDPOINT_URL, PAPER_FETCH_S3_REGION S3 store: endpoint for non-AWS services (DigitalOcean Spaces, MinIO, R2, …) and region.
PAPER_FETCH_S3_ACCESS_KEY_ID, PAPER_FETCH_S3_SECRET_ACCESS_KEY S3 store credentials; if unset, boto3’s usual credential chain applies.
PAPER_FETCH_PROFILE_DIR Your discipline profiles (*.toml). Default ~/.config/paper-fetch/profiles.
PAPER_FETCH_PROBLEMS Where provider defects are logged, one JSON object per line. Default provider-problems.jsonl in the local store’s directory. Never in the store.
PAPER_FETCH_INDEX The passage index file. Default passages.sqlite in the local store’s directory. Derived: deleting it loses nothing.
PAPER_FETCH_EMBED_MODEL, PAPER_FETCH_EMBED_URL An embedding model (e.g. bge-m3) on an Ollama-compatible endpoint (default http://127.0.0.1:11434). Unset, retrieval is lexical (BM25).
PAPER_FETCH_RERANK_MODEL A cross-encoder on the Hugging Face hub in ONNX form, repo::file, e.g. onnx-community/bge-reranker-v2-m3-ONNX::onnx/model_int8.onnx. Unset, no reranking.
PAPER_FETCH_S3_PUBLIC_URL S3 store: the anonymous base URL used to prove objects are not public. Default https://<bucket>.<endpoint host>.

Providers

name search locate notes
pmc-s3   yes PMC open-access subset on AWS Open Data; md5 per file.
europepmc yes yes Includes bioRxiv/medRxiv preprints; OA JATS XML.
plos yes yes PLOS DOIs only; 10 requests/minute.
openalex yes yes Metered in USD (search ~$0.001, lookups free); searches are cached.
biorxiv   yes JATS XML and licence by DOI.
openaire yes yes Only copies with an explicit open licence on an OPEN record.
hal yes yes Licence recorded verbatim (HAL’s deposit authorisation is not a reuse licence).
osf yes yes OSF preprints via SHARE; only Creative Commons copies.
core yes yes Repository copies; CORE reports no licence, and none is claimed.
unpaywall   yes Needs PAPER_FETCH_EMAIL.
pubmed yes   Finds papers and PMCIDs; PMC routes hold the copies.
doaj yes   Search only (its full-text links are landing pages).
scite opt-in   Needs a scite subscription and one sign-in: paper-fetch scite-login. Rationed (250 calls a month), so asked only when a search says so. Search only; hits carry citation tallies. Not cached.
web fallback   SearXNG; identifiers are parsed from URLs and never trusted as open.

Default locate order: pmc-s3, europepmc, plos, openalex, biorxiv, openaire, hal, osf, core, unpaywall. Default search set: openalex, europepmc, pubmed, openaire, plos, osf, hal, doaj, core, with web asked only when none of those returns an open-access hit and scite only when a search asks for it. NCBI’s ID converter fills in missing PMCIDs/PMIDs before providers are asked, and OpenCitations answers the citation graph. paper-fetch providers lists what is enabled and what each needs, without touching the network.

scite

scite is searched through its MCP server, with your own subscription: there is no API key. Sign in once per machine:

paper-fetch scite-login     # opens the browser; stores the sign-in in ~/.config/paper-fetch/scite-oauth.json (0600)
paper-fetch scite-logout    # forget it

The subscription allows 250 MCP calls a month, so scite is not in the default search set. Ask for it per search, on top of the usual providers:

paper-fetch search "tinnitus retraining therapy" --scite
lib.search("tinnitus retraining therapy", also=["scite"])

Over MCP, search takes scite: true. scite is asked once per search, with the query as written (never a profile’s variants), and the sign-in renews itself. To have every search ask it anyway, name it in PAPER_FETCH_SEARCH_PROVIDERS.

A merged hit that scite found carries scite: its tally (supporting, contrasting, mentioning, citingPublications), scite’s open-access status and any editorial notice (retraction, correction, concern). scite is search-only: copies still come through the open-access routes above, and its access links, abstracts and citation statements are not kept.

Command line

paper-fetch fetch <doi|W123|pmid:N|PMCN|arxiv:ID> [...] [--force] [--collection C]
paper-fetch search "<query>" [--providers a,b] [--include-closed] [-n N] [--refresh]
                   [--profile P] [--collection C] [--no-expand]
paper-fetch providers
paper-fetch profiles [slug]
paper-fetch collection [name] [--create] [--profile P] [--description D]
paper-fetch collect <name> <id> [...] [--remove]
paper-fetch recall ["<query>"] [--profile P] [--collection C] [--work ID] [-n N]
paper-fetch index [id ...] [--collection C] [--refresh | --rebuild]
paper-fetch retrieve "<query>" [--collection C] [--work ID ...] [-n N] [--per-paper N]
paper-fetch library ["<query>"] [--full-text]
paper-fetch text <id>
paper-fetch provenance <id>
paper-fetch verify <id>                # re-hash stored files against their recorded sha256
paper-fetch add <file.pdf> <id> --rights "..."
paper-fetch citations <id> [--references] [-n N] [--refresh]
paper-fetch scite-login | scite-logout  # sign this machine in to scite, in the browser
paper-fetch leads <url> [...]          # identifiers in URLs you found on the web (no network)
paper-fetch problems [-n N] [--trace]  # provider defects logged on this machine
paper-fetch status | rebuild-index | adopt-orphans

python -m paper_fetch is the same program. Exit codes: 1 not found, 2 unavailable or bad input, 3 checksum verification failed.

Python

from paper_fetch import Library

lib = Library.default()
rec = lib.fetch("10.1371/journal.pcbi.1003285")  # rec["from"]: library | retrieved | not-obtainable
text = lib.text(rec["work"])  # raises NotFound if there is no readable text
prov = lib.provenance(rec["work"])
res = lib.search("reproducible computational research")

lib.create_collection("review", profile="neuroscience")
lib.search("slow oscillations memory", collection="review")  # the collection's profile applies
lib.fetch("10.1371/journal.pcbi.1003285", collection="review")
lib.recall("memory consolidation")  # earlier searches on the concept, and what they found
lib.profile("neuroscience").guidance()  # scope, terms, measures and advice for a query writer
lib.retrieve(["does sleep improve recall?"], collection="review", per_paper=2)

Library(store, openalex, providers=[...], search_providers=[...]) accepts any store with the Store protocol and any Provider subclasses, which is how the test suite runs offline.

MCP server

paper-fetch-mcp is a stdio MCP server (works with mcp 1.x and 2.x). Any MCP client can run it:

{
  "mcpServers": {
    "papers": {
      "command": "paper-fetch-mcp",
      "args": [],
      "env": {
        "PAPER_FETCH_EMAIL": "[email protected]",
        "PAPER_FETCH_DATA_DIR": "/path/to/paper-fetch"
      }
    }
  }
}

Without a global install, use uvx: "command": "uvx", "args": ["--from", "paper-fetch[mcp] @ git+https://github.com/polarizetech/paper-fetch", "paper-fetch-mcp"]. Secrets can stay out of client configuration by putting them in ~/.config/paper-fetch/*.env.

Tool contract

Every tool returns one JSON object:

{"ok": true, "data": "..."}
{"ok": false, "code": "not_found | unavailable | tool_error", "error": "human-readable message"}

not_found: not held, no readable text, or no DOI/PMID to ask OpenCitations with. unavailable: a remote service refused or did not answer; this never means “zero results”. tool_error: bad arguments or configuration (unparseable identifier, unknown provider, bad key).

tool arguments data
search query: str, include_closed: bool = false, limit: int = 10, profile: str = "", collection: str = "", expand: bool = true, scite: bool = false {"query", "variants", "profile", "collection", "providers": {name: {"status", ...}}, "hits": [...], "memory": [...], "search_id"}, plus "broken": [{"provider", "error", "log"}] when a provider failed because of a defect, and "ask_the_web": {"why", "query", "web_search_here", "how"} when no open copy was found and the library could not search the web itself
leads found: list[str] (URLs or text from your own web search) {"leads": [{"from", "ids", "key", "fetch", "verified": false, "in_library", "full_text_in_library", "work"}], "no_identifier": [...]}. No network.
fetch identifier: str, collection: str = "" a catalogue row (below) plus "from"
profiles slug: str = "" {"profiles": [{"slug", "label", "scope", "anchors", "providers", "origin"}]}, or one profile’s {"scope", "search_guidance", "terms", "anchors", "measures", "sources", ...}
recall query: str = "", profile: str = "", collection: str = "", identifier: str = "", limit: int = 10 {"searches": [{"id", "at", "query", "variants", "profile", "collection", "n_hits", "score", "hits": [...]}]}
collections   {"collections": [{"name", "profile", "description", "members", "updated"}]}
collection name: str {"name", "profile", "description", "n", "with_full_text", "members": [...], "searches": [...]}
create_collection name: str, profile: str = "", description: str = "" the collection
collect, uncollect name: str, identifiers: [str] the collection
retrieve query: str = "", queries: [str], identifiers: [str], collection: str = "", limit: int = 20, per_paper: int = 0 {"results": [{"query", "passages": [...]}], "scope", "indexed", "reranker", "index"}
passages identifier: str, start: int = 0, limit: int = 4 {"work", "passages": [...]}: a paper’s passages in order
relevance targets: [str], texts: [str] {"scores": [0..1], "method"}: each text’s closeness to the nearest target, for deciding what to fetch first
index identifiers: [str], collection: str = "" {"indexed", "unchanged", "no_full_text", "not_held", "passages_added", "index"}
library query: str = "", full_text: bool = false, limit: int = 50 {"works": [row, ...], "n": int}
text identifier: str, offset: int = 0, max_chars: int = 100000 {"text", "offset", "end", "total_chars"}
provenance identifier: str the stored provenance record (below)
citations identifier: str, direction: "citations" \| "references" = "citations", limit: int = 25 {"of", "direction", "status", "n", "held", "source", "items": [...]}
providers   {"providers": [{"name", "label", "search", "locate", "needs", "recommends", "available", "why", "terms"}]}
status   {"works", "with_full_text", "not_obtainable", "store", "locate_providers", "openalex_key", "network_calls", "openalex_spent_usd", "openalex_remaining_usd", "collections", "searches_remembered", "profiles", "passages": {"papers", "passages", "with_vectors", "stale", "embedding_model", "path"}}

search: providers[name].status is ok or cache (with n, cost_usd), skipped (missing configuration; why), unavailable or error (why), or for web also not-needed (with why_ran when it did run). Each hit is {"title", "year", "ids": {"doi", "pmid", "pmcid", "openalex", "arxiv", ...}, "authors", "providers": [...], "is_oa", "locations": ["provider:format", ...], "urls": [...], "in_library", "full_text_in_library", "work", "key", "found_by": [queries], "profile_match": [anchor labels], "seen_before": {"search", "query", "at"} | null}, plus "in_collection" when a collection is given. Hits are merged across providers and query variants by identifier, and sorted: those naming the profile’s terms first, then by how many providers returned them, then by year. memory lists up to three earlier searches on the same concept.

Passage (retrieve, passages): {"id": "<work>#p<ord>", "sha", "work", "ord", "start", "end", "text", "bm25_rank", "dense_rank", "score", "rerank_score"?, "paper": {"title", "year", "doi", "pmid", "authors", "is_retracted", "license", "text_sha256", "hidden_chars", "route", "format"}}. Offsets are into the paper’s indexed text: the stored full text with hidden formatting characters removed; hidden_chars says how many were, and text_sha256 identifies that text exactly. id survives rebuilding the index; sha changes if the passage’s text does.

recall: with a query, past searches are scored by the overlap of their concepts (content words, synonyms mapped to indexed terms); with none, the most recent come first. Each hit is marked with what the library holds now.

Catalogue row (fetch, library): work (the OpenAlex ID, or doi-.../pmcid-... when OpenAlex has no record), doi, pmid, pmcid, title, year, authors (first three), full_text (true only if readable text passed the gate), oa_status, license, route (provider:format), format, retrieved, retry_after (when a paper with no open copy will be asked about again), is_retracted; fetch adds from (library | retrieved | not-obtainable) and library adds matched_in when a query was given.

text: max_chars is clamped to 1,000..100,000. Page through with offset = end until end >= total_chars.

Provenance record: work, retrieved, full_text, file_stored, route, source_url, format, license, version, md5_verified, text_method, text_gate, oa_status, rights, refused, providers (tried, in order), attempts ([[route, outcome], ...]), sha256 ({file: hex}), ids, ids_note, retry_after.

citations: n is the total; items holds at most limit, newest citing work first for direction="citations". Each item is {"ids": {"doi", "pmid", "openalex", "omid"}, "oci", "citing_date", "timespan", "journal_self_citation", "author_self_citation", "in_library", "full_text_in_library", "work"}. There is no citation intent: a citation is not agreement.

Storage layout

papers/index.jsonl                     catalogue (derived; `paper-fetch rebuild-index` recreates it)
papers/doi/<quoted-doi>.json           DOI -> work pointer
papers/works/<work>/work.json          the OpenAlex record
papers/works/<work>/provenance.json    authoritative: routes tried, source, licence, sha256s
papers/works/<work>/fulltext.{pdf,jats.xml,tei.xml,provider.txt}
papers/works/<work>/fulltext.txt       only when the text passed the readability gate
papers/searches/<provider>/<sha256>.json
papers/citations/<direction>/<id>.json
papers/collections/<name>.json         a collection: profile, description, members
papers/memory/searches.jsonl           search memory: one row per search

The passage index is not in the store: it is a local SQLite file derived from the stored full texts (PAPER_FETCH_INDEX), one per machine, rebuilt by paper-fetch index --rebuild.

The same layout is used on disk and in a bucket. A lookup reads the catalogue first and falls back to the per-work provenance, so a stale or deleted catalogue can slow a lookup down but can never cause a second download.

Limits

  • Coverage is whatever the providers report as open; a paper with no open copy stays not-obtainable (asked about again after 30 days, since embargoes lift).
  • Text is machine-extracted: figures, tables and equations may be missing or garbled. Scanned PDFs without a text layer fail the readability gate (there is no OCR).
  • Licences are recorded as reported, not adjudicated. CORE and some repositories report none.
  • Searches are cached for 30 days, so a repeated query can miss papers indexed since (--refresh bypasses the cache).
  • Rate limits are the providers’: PLOS allows 10 requests/minute, unkeyed OpenAlex has a small daily budget, and so on. Requests are spaced politely per provider.
  • library search is term matching over titles and authors (optionally full text), not ranking; ranked retrieval is retrieve, over passages.
  • Indexing with embeddings is the slow step: about 15 passages a second with bge-m3 on a laptop, so a paper takes a few seconds the first time and nothing after.

Development

uv sync                                   # Python 3.11+; installs the dev group
uv run ruff check . && uv run ruff format --check .
uv run pyright
uv run pytest --cov

The suite is offline: HTTP is replaced by a fake that serves small synthetic fixtures (tests/fixtures/), and one test drives the real paper-fetch-mcp entry point over stdio. CI runs all of the above on Python 3.11, 3.12 and 3.13.

License

MIT. See LICENSE. The papers you retrieve are under their own licences.

Changelog

2026-10-07 #12

Ask the caller to search the web when the library cannot

Summary

The last resort after the scholarly providers is a web search. Until now that needed a hosted SearXNG instance; without one, the fallback was skipped.

paper-fetch runs no language model, but its caller is usually a model with its own web search. This PR hands the last step to the caller, so nothing needs hosting.

  • ask_the_web in search results. When the fallback is wanted (no open-access hit) and the library’s own web search did not answer, the result carries ask_the_web with why, the query, what happened to the library’s own web search, and how to proceed. “Did not answer” means SEARXNG_URL is unset or SearXNG is down.
  • leads, available as Library.leads, the MCP leads tool and paper-fetch leads <url> .... It takes the URLs the caller found. It reads a DOI, PMID, PMCID or arXiv id out of each, using the web provider’s own parser, marks what is already held, and returns what to fetch. It makes no network calls. A URL with no identifier is returned under no_identifier rather than dropped.
  • MCP instructions tell the model what to do when it sees ask_the_web.

The rule is unchanged: nothing is downloaded from a web page. An identifier goes through the open-access providers, which decide whether a copy may be kept.

SearXNG still works when configured, and then nothing is asked of the caller. web_fallback=False and PAPER_FETCH_WEB_FALLBACK=0 turn off the request along with the fallback.

Test plan

  • uv run pytest: 375 passed, 96% coverage. New tests in tests/test_fallbacks.py cover:
    • ask_the_web when no web search is configured or SearXNG is down;
    • no ask_the_web when the fallback is off, not needed, or SearXNG answered;
    • leads parsing, de-duplication, held marking, and making no network call;
    • the CLI output and the MCP tool.
  • ruff check, ruff format --check and pyright are clean.
  • Live on this machine (no SearXNG): a no-hit search printed the ask-the-web prompt. leads on a DOI URL and a PubMed URL returned both identifiers marked as held, and returned a URL with no identifier under no_identifier.

🤖 Generated with Claude Code

2026-10-07 #11

Wait out a provider's outside problems; flag its defects

Summary

A search already asks every provider and merges whatever answers, with web search last. This PR fixes how a failing provider is treated, by telling two kinds of failure apart.

An outside problem means the service is down, rate-limiting us or out of allowance.

  • The provider is reported as unavailable and the other providers answer.
  • The provider is then left alone for a while instead of being asked again by every search:
    • 60 s after an outage
    • 300 s after a 429
    • an hour after OpenAlex’s daily allowance
    • the rest of the process after scite’s monthly one
  • While paused it isn’t called. Its report says unavailable with paused: true and the original reason.
  • A cached answer is still served, and refresh=True asks anyway.

A defect means the service rejected our request or its answer changed shape. Waiting won’t fix it, so:

  • It is never paused.
  • Its report says error with broken: true.
  • The search result carries a top-level broken list.
  • The CLI prints ! BROKEN: … on stderr.
  • The MCP server’s instructions tell the model to tell the user.
  • The traceback is logged to a local file, shown by paper-fetch problems --trace.

The bug this fixes

Nine query helpers did if r.status != 200: return []. A provider answering 500, or rejecting a malformed query with 400, was reported as ok, 0 hits, so an outage or a bug read as “nothing in the literature”.

They now go through Provider._answered, which raises one kind of failure or the other. It applies only to query endpoints, which answer 200 with an empty list when nothing matches. A lookup by identifier can still treat a 404 as “not known”.

Also in this PR

  • scite: its failures are sorted the same way. A spent allowance or a busy service is unavailable. A rejected request, a JSON-RPC error, an unrecognised tool error or an answer without hits is a defect.
  • OpenAlex: a 429 or 5xx is unavailable rather than a generic error.
  • Locating copies: a defect met while locating a copy is logged too.
  • README install lines: they used a PyPI name that doesn’t exist (404). They now install from this repository.

Test plan

  • uv run pytest: 367 passed, 96% coverage. Five existing tests relied on a 404 meaning “empty” and now use real empty answers.
    • tests/test_fallbacks.py covers pausing, the pause lengths, cache served while paused, defects named and logged, and web search asked when every provider is down.
    • tests/test_providers.py checks that all eight query providers treat a 503 or 403 as unavailable and a 400 as broken.
    • tests/test_scite.py covers the scite classification.
  • ruff check, ruff format --check and pyright are clean.
  • Live: five searches in one process. All nine default providers answered ok, including for a nonsense query, so none had been failing silently. scite reported its spent allowance once and was reported as paused on the next search. Zero defects were logged.

🤖 Generated with Claude Code

2026-10-07 #10

Make scite opt-in: its subscription allows 250 calls a month

Summary

Makes scite opt-in. Its subscription allows 250 MCP calls a month, and one day in the default search set spent all of it.

After #7, every paper-fetch search asked scite, and a profile search asked it up to three times (once per query variant). With the research-rag gateway and about a dozen sessions searching, the month’s allowance was gone within a day. scite now answers “You have reached your monthly MCP usage limit (250 calls)”.

What changes

  • Default set: scite is no longer in DEFAULT_SEARCH. An ordinary search doesn’t touch it.
  • Opting in: a search asks scite on top of the usual providers with also=["scite"] in Python, --scite on the CLI, or scite: true on the MCP search tool. Naming it in PAPER_FETCH_SEARCH_PROVIDERS or providers= still works.
  • One call per search: Provider.metered is new and is True for scite. A metered provider is asked the query as written, once, and never a profile’s variants.
  • Session reuse: the opted-in provider is built once per Library, so scite’s MCP session is reused instead of opened per search. One thread uses that session at a time.
  • Docs: README and CLAUDE.md record the limit and how to opt in.

Test plan

  • uv run pytest: 329 passed, 95% coverage. New tests cover:
    • scite is asked only when told to;
    • also doesn’t ask a provider twice and reuses the session;
    • a metered provider is skipped for profile variants;
    • the CLI flag and the MCP parameter pass the opt-in through.
  • ruff check, ruff format --check and pyright are clean.
  • Live: a default paper-fetch search reports the nine usual providers and no scite.
  • Live: --scite asks scite by name. It currently returns scite’s monthly-limit message, reported as unavailable.
  • A successful live scite search through the opt-in path. This can’t run until the allowance resets.

🤖 Generated with Claude Code

2026-10-06 #9

One Library, many threads: lock the catalogue, memory, collections and pacing

Follow-up to #8. An MCP server runs every tool call on a worker thread, so one Library is used from several threads at once. #8 locked the passage index’s connection. This PR covers the rest of the state those threads share.

  • Catalogue:
    • One lock guards the in-memory dictionary and its save.
    • Readers that walk the catalogue take a snapshot (_rows()). Writers go through _record / _forget.
    • A save now updates the dictionary in place without emptying it. Before, a lookup from another thread mid-save could find a held paper missing and fetch it again.
    • rebuild_index builds the new catalogue aside and swaps it in whole.
  • Search memory and collections: each read-modify-write holds a lock, so no thread’s row or member is lost.
  • Lazily built parts (profiles, memory, passage index, reranker): built once.
  • Provider request spacing: two threads could both pass the wait at once and break a rate limit such as PLOS’s 10 per minute. The wait is now taken under a per-provider lock.

No lock is held across a fetch or a search, so slow network work does not block other calls. The locks are per process; the cross-process behaviour is unchanged.

Tested:

  • tests/test_threads.py runs each part from 16 threads. All five tests fail on main and pass here.
  • The thread tests passed 25 runs in a row.
  • All 324 tests pass, and ruff and pyright are clean.

🤖 Generated with Claude Code

2026-10-06 #8

Lock the passage index's connection: tool calls run on worker threads

What failed: retrieve failed intermittently in service. The gateway log shows 13 failed calls. Eleven are sqlite3.InterfaceError: bad parameter or other API misuse, and one is passage text read back as undecodable bytes.

Cause: the MCP library runs every sync tool on a worker thread (anyio.to_thread.run_sync), so overlapping retrieve calls used the passage index’s single SQLite connection at the same time. The index was written as if calls were serial.

Fix: PassageIndex holds an RLock around every use of its connection, including reads, writes and the cached vector matrix. Embedding, the slow part of indexing, stays outside the lock, so indexing a paper does not block retrieval for seconds.

Tested:

  • A new test runs 200 mixed search / passages / paper / stats / add calls across 16 threads on one index. It fails on main and passes here.
  • All 319 tests pass, and ruff and pyright are clean.

Not covered: Library’s other shared state (the in-memory catalogue) is also reached from several threads. No failure from it appears in the logs, so this PR leaves it alone.

🤖 Generated with Claude Code

2026-10-05 #7

Add scite as a search provider, through its MCP server

Summary

Adds scite as a search provider alongside OpenAlex and the others. It uses your scite subscription and needs no API key.

scite’s REST search (/api_partner/search) returns 401 without a partner token, which comes from their sales team. Its MCP server (https://api.scite.ai/mcp) accepts the subscriber’s own sign-in instead: OAuth 2.1 with dynamic client registration and PKCE. So the provider is a small MCP client written against Http. It sends initialize, then tools/call search_literature.

What changes

  • Sign-in: paper-fetch scite-login opens the browser once and stores the sign-in in ~/.config/paper-fetch/scite-oauth.json (mode 0600). It is never written to the shared store. scite-logout removes it.
  • Token renewal: the access token is renewed automatically. scite rotates the refresh token on every use, so the file is re-read before a refresh and once more after a refused one.
  • Provider: providers/scite.py is search-only, like doaj. It is in the default search set and reported skipped until someone signs in. Its results are not cached.
  • What a hit keeps: DOI, title, year, three authors, scite’s open-access flag and status, the citation tally and editorial notices.
  • What a hit drops: scite’s access links (they carry the account’s email address), abstracts and citation statements.
  • Merged search results: a hit that scite found carries a scite key with the tally, oa_status and editorial_notices.
  • HTTP layer: Http.post is new. It returns a 4xx response as an answer, the way get does. http.py stays the only module that calls urlopen.
  • Docs: README, CLAUDE.md and INTEGRATION.md are updated.

Design notes

  • No mcp SDK in the core. The core stays standard-library plus pypdf, and all outbound requests still go through Http. The one new socket is the loopback listener that receives the browser redirect during login.
  • oa_only: the tool has no open-access filter. The provider asks for three times limit (capped at 50, because every hit carries a citation list) and drops what scite marks closed.
  • Failures: an expired sign-in, a 429, a tool error or an unreachable server is reported as unavailable with what to do, never as zero results.

Test plan

  • uv run pytest: 318 passed, 95% coverage (29 new tests in tests/test_scite.py, against a fake scite server).
  • ruff check, ruff format --check and pyright are clean.
  • Live sign-in through the browser on 2026-10-05; the stored file is mode 0600.
  • Live search: 5 hits in 3.7 s (3 POSTs on a new session), with nothing from the access links kept.
  • Live oa_only search: every hit returned is open.
  • Live token refresh: forced by expiring the token locally. The access token changed and the refresh token rotated.
  • Live federated search: paper-fetch search … --providers scite,openalex,europepmc merged hits across the three providers.

🤖 Generated with Claude Code

2026-10-02 #6

Do not index reference lists that have no heading paragraph

Found in a live run of retrieval-augmented-generation: a bibliography entry from a PDF was retrieved as a passage. The chunker only cut at a paragraph that was exactly “References”. PDF text often has the heading on a line inside a paragraph, or loses it altogether.

Detection (references_start)

  • A heading on a line of its own (“References”, “6. References”, “Literature Cited”), past the first third of the text.
  • Or, with no heading: a run of at least 8 consecutively numbered entries starting at 1 that begins in the second half of the text, reaches its last tenth, and mostly carries publication years.
  • Numbered lists in the body are kept. That covers lists that are short, early, have no years, or do not start at 1.

Measured on 80 held papers: 11 of 12 PDFs are now cut (none were before), every cut I read lands at the start of the list, and the 68 JATS and TEI texts are unaffected. An unnumbered author-year list with no heading is still not recognised.

Existing indexes

  • The chunker has a version (CHUNKER = 2), kept in a new chunked table next to the indexed_at of the row it describes.
  • The papers table is unchanged, so servers still running older code and sharing the file keep working. A paper one of them re-indexes counts as stale again.
  • has() is false for a stale paper, so it is re-chunked the next time it is indexed. Passages whose text did not change keep their vectors, so only new text is embedded.
  • status reports stale.

Tested: 287 tests pass, including the detector’s positive and negative cases, vector reuse, an index from before versions existed, and a row rewritten by older code. ruff and pyright are clean.

🤖 Generated with Claude Code

2026-10-02 #5

Accept the library's own work ids; skip bad records when indexing and collecting

Problems

  1. Works the library creates itself (`pmcid-PMC5519140`, `doi-10.1214%2F12-aoas565supp`) were rejected by `index`, `collect` and the MCP `index`/`collect` tools with “unrecognised identifier”. One bad id also failed a whole batch.
  2. `paper-fetch index` with no arguments aborted on the first unparseable record, leaving later works unindexed.

Changes

  • `ids.normalize` parses `doi-`/`pmcid-`/`pmid-`/`arxiv-` work ids, including percent-encoded DOIs. Behaviour for ordinary DOI / OpenAlex / PMID / PMCID inputs is unchanged and junk is still refused.
  • `index_works` reads text from the catalogue row (no re-parse) and skips and reports unreadable records under `skipped`; the CLI prints each with its reason and a count.
  • `collect` returns a `rejected` list instead of failing the batch.
  • When OpenAlex has no record, `fetch` fills title, year, authors and DOI from Europe PMC (never fatal).
  • Existing library records are not rewritten; already-stored metadata-less works are only enriched on a new `fetch`.

Tests

`tests/test_own_work_ids.py`: 12 of its 19 cases fail on main and all pass here; full suite 278 passed, ruff and pyright clean.

🤖 Generated with Claude Code

2026-09-30 #4

Passages carry their route; relevance() for fetch ordering

Two small additions that retrieval-augmented-generation needs before it can drop its own index and embedding calls:

  • Each passage’s paper now includes route and format, so an application’s run log can say where the quoted copy came from.
  • New Library.relevance(targets, texts) and MCP tool relevance: scores each text (for example a hit’s title) against the nearest target (for example a sub-question), from 0 to 1. It uses the configured embedder, or concept overlap without one.

Tests: 259 pass offline, and ruff and pyright are clean.

🤖 Generated with Claude Code

2026-09-30 #3

Passage index and retrieval

Phase 2 of moving paper searching and indexing into paper-fetch. This PR is stacked on #2 (the base is search-profiles-collections), so merge #2 first.

What moves here from retrieval-augmented-generation

  • The passage index (passages.py). It is a local SQLite file built from held full texts, with FTS5 (BM25) always and dense vectors when PAPER_FETCH_EMBED_MODEL is set, fused by reciprocal rank.
  • Chunking and hidden-character cleaning are unchanged, so passage offsets and text_sha256 mean what they did in the RAG index. The paper row now also records how many hidden characters were removed (hidden_chars).
  • Passage ids have the form <work>#p<ord> and survive rebuilds. sha identifies the passage text.
  • The ONNX cross-encoder reranker (rerank.py), set with PAPER_FETCH_RERANK_MODEL.
  • The embedder and the reranker are both deterministic: they return vectors and scores, never text. The model-based reranker and the prompt-injection scan stay in the application, because both concern a model reading the text.

API

  • Library.retrieve(queries, identifiers=, collection=, limit, per_paper, rerank) indexes its scope on demand before retrieving.
  • Also index_works, paper_passages, and the passages / reranker properties.
  • New MCP tools: retrieve, passages, index. New CLI commands: paper-fetch index [--rebuild] and paper-fetch retrieve.
  • New optional extras: retrieval (numpy) and rerank (onnxruntime, tokenizers, huggingface_hub). The core still depends only on pypdf and fonttools.
  • Embedding requests go through the new Http.post_json, so the “only http.py and the store open URLs” guarantee still holds.

Tested:

  • 257 tests pass offline: exact offsets, stable ids across rebuilds, hybrid and lexical ranking, scope, the per-paper cap, reranking, refusing a different embedding model, and the MCP and CLI commands. ruff and pyright are clean.
  • A live run with bge-m3 against the real library, writing the index to a scratch file, turned 15 held papers into 1,405 passages in 94 s. A repeat query took about 10 ms.

Next (phase 3): retrieval-augmented-generation drops its own index and reranker and calls these tools, and its domains keep only their evidence rules.

🤖 Generated with Claude Code

2026-09-30 #2

Discipline profiles, named collections and search memory

Phase 1 of moving paper searching and indexing out of retrieval-augmented-generation and into paper-fetch. Nothing here uses a language model: everything is string matching and bookkeeping, so it is repeatable and tested offline.

Discipline profiles (profiles.py, data in profile_data/*.toml)

  • A profile holds the search half of a field: controlled terms (MeSH ids are validated), synonyms, measures, sources, and search guidance for whoever writes the queries.
  • search(profile=) also runs up to two variants that swap a synonym for its indexed term (“HRV” → “heart rate variability”). It never swaps the other way. Hits whose titles name the field’s terms rank first (profile_match), and results are merged across variants (found_by).
  • Four profiles ship built in. They were converted from the RAG repo’s domains. They don’t narrow the provider set, because that would drop the repository providers that find open copies.
  • A TOML file in ~/.config/paper-fetch/profiles/ adds a profile or replaces a built-in one by slug.

Named collections (collection.py)

  • papers/collections/<name>.json holds a member list and a default profile. A paper is stored once, whichever collections list it.
  • fetch(collection=) lists the paper in the collection. collect and uncollect add or remove members by any identifier.

Search memory (memory.py)

  • papers/memory/searches.jsonl gets one row per search, written with the same re-read-and-append approach as the catalogue.
  • Every search reports up to three earlier searches on the same concept, and marks each hit that an earlier search already found (seen_before).
  • recall looks up past searches by concept, profile, collection or paper. A concept is the set of content words with synonyms mapped to indexed terms.

Surfaces

  • New MCP tools: profiles, recall, collections, collection, create_collection, collect, uncollect. search and fetch take profile and collection.
  • New CLI commands: profiles, collection, collect, recall, plus --profile and --collection on search and fetch.

Tested:

  • 239 tests pass offline, including concurrent writers to the memory and to a collection. ruff and pyright are clean.
  • A live search with the cardiovascular profile, run on an in-memory store, found the “heart rate variability” variant hits, ranked them first, and recalled the earlier “HRV” search at a score of 0.8.

Next:

  • Phase 2: the passage index and retrieval move here.
  • Phase 3: retrieval-augmented-generation calls paper-fetch for search, memory and retrieval, and its domains keep only the evidence half.

🤖 Generated with Claude Code

2026-09-29 #1

Bring over CLAUDE.md, HARVEST.md and INTEGRATION.md from the monorepo

Summary

Brings over the three docs from the audio-projects monorepo’s tools/paper-library (package paperlib), which is being replaced by this repo (polarizetech/audio-projects#26). dataset-fetch keeps the same three files at its root:

  • CLAUDE.md: measured facts, gotchas and design decisions, with their dates.
  • HARVEST.md: the procedure for bulk-collecting a topic into the library.
  • INTEGRATION.md: how a consumer depends on paper-fetch. It covers the install from git with a pinned commit, setting up the S3/Spaces store through PAPER_FETCH_* in ~/.config/paper-fetch/*.env, the Python API and the MCP server.

Names, commands, env vars and APIs are rewritten to paper-fetch’s own and checked against src/paper_fetch/. Behaviour that has changed since the monorepo copy is described as it is now:

  • Catalogue writes: since bee1bfe a save merges this process’s changes instead of the last writer winning.
  • Cache: it lives under PAPER_FETCH_CACHE and applies only to the S3 store.
  • load_env(): Library.default() does not call it, so Python consumers must.

Monorepo-only material is dropped: the tools/.venv-sci commands, sync.py and the generated copy, mcpkit envelopes, and REGISTRY/ORG-PROTOCOL references. Live measurements from the monorepo copy are kept, dated and labelled as such.

Docs only; no code changes.

Test plan

  • Every command, flag and env var named in the docs exists in src/paper_fetch/.
  • uv run pytest passes (214 tests).

🤖 Generated with Claude Code