fh-library 0.2.16 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +9 -130
  2. package/README.md +12 -7
  3. package/dist/index.js +1821 -1650
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,137 +1,16 @@
1
1
  # FH Library — Change Log
2
2
 
3
- ### v0.2.12 — 2026-11-08
3
+ ### v0.2.19 — 2026-11-09
4
4
 
5
- * **`library_pubmed_search_articles(query, max_results?, sort?, years?)`:** a PubMed search in PubMed's own syntax — esearch for the total and the IDs, one esummary for the rows (PMID, title, first author, year, journal, publication types, PMCID, library level), cached under `search/`. With the batch fetch and `library_read` this covers what the AI4L models used of the PubMed MCP server (#27).
6
- * **`FH_LIBRARY_SEARCH_CACHE_DAYS` (7):** days a PubMed or registry search is cached; was a constant.
7
- * **An ID no service knows is remembered** for `FH_LIBRARY_RETRY_AFTER_DAYS` (1): a PMID PubMed has no record for, a DOI neither Crossref nor doi.org resolves, a set ID without a label, an NCT ID without a study. A marker (`<entry>-missing.md`, date and reason, no meta) in the entry's directory; `library_add`, `library_status`, `library_read`, `library_verify` and the batch tool answer from it ("pmid not found (checked 2026-11-08; asked again after 1 day)"), the service is asked again after the cooldown, a real entry replaces the marker. Network errors are never remembered. The same cooldown a failed page or a transient tier failure already had.
8
- * **`library_pubmed_fetch_articles(pmids)`:** title, first author, year, publication types, retracted and level for up to 200 PMIDs in one call, rows in `columns` order (~120 characters a paper against the PubMed MCP server's ~2,500). Stored PMIDs from disk; the rest in one efetch, stored at abstract level as `library_add`'s first stage, so the cascade runs later as on an untried entry. The first of the PubMed tools (#27); `library_info`'s `cache` and `.servers/` count `pubmed` hits and misses per PMID.
5
+ * **Faster full texts:** a browser rung (or Bright Data) that met the same site's challenge twice in a row is skipped for that site for `FH_LIBRARY_RETRY_AFTER_DAYS` — the other rungs still run, a success clears it; the open-access lookups (Europe PMC, Crossref, Unpaywall, OpenAlex) start together instead of one after the other, the downloads stay in tier order.
6
+ * **Smaller status:** without `title`, `first_author` and `doi`.
9
7
 
10
- ### v0.2.9 — 2026-11-08
8
+ ### v0.2.18 — 2026-11-09
11
9
 
12
- * TUI v82: the `url` row shows library_fetch hits and misses from the servers' counters; `/status` carries `fetches`. Counts right-aligned in a 5-character column.
13
- * TUI v80: a `search` row — cached registry searches, hits and misses from the servers' counters; the decision-model row is labelled `decider`; `/status` carries `searches`.
14
- * ClinicalTrials tool descriptions and server instructions one statement each.
10
+ * **Smaller answers per call:** no answer repeats what the call sent. `library_verify` returns `verdict`, `score` (two decimals) and `basis`, plus `reason`, `superseded` or `glyphs_unresolved` only when set, without the claim, the ID or the model; the status drops the ID fields, the refresh dates, `attempts`, `coi` and `funding` (`library_read` section "Declarations" returns the record's statements when the text has none); both searches and the PubMed batch return `total`, `columns` and `rows` only; publication types leave out "Journal Article" and "Research Support, …"; trial enrollment as `48` (actual) or `~100` (estimated), collaborators as the first three and `+N`.
11
+ * **Trial results outcomes first:** `library_clinicaltrials_get_study_results` puts the outcome measures first, then adverse events, and describes each group once instead of in every section — the primary outcomes now sit at the top of the answer instead of after 3,000 characters of arm descriptions.
12
+ * **`library_read` says when there is more:** a query answer carries `more` when further passages match beyond `max_hits`.
15
13
 
16
- ### v0.2.8 — 2026-11-08
14
+ ### v0.2.17 — 2026-11-08
17
15
 
18
- * **`library_clinicaltrials_get_study_record(nct)` and `library_clinicaltrials_get_study_results(nct, max_chars?)`:** thin answers over the stored `nct:` entry (added first when missing) — the review's detail of one trial, and its posted results as Markdown with the outcome titles for `library_read`. With the search, the three tools map one to one onto what the AI4L models used of the ClinicalTrials.gov MCP server (#28).
19
- * `library_verify` cache key folds the claim's typography (#33): case, whitespace and non-breaking spaces, curly quotes, en/em dashes, "+/-" for "±", a trailing period — never a word. The claim is judged and stored as written.
20
- * `library_verify`: a claim that opens with an odds ratio ("OR 2.15 (95% CI 1.06 to 4.34) for …") is a statement, not a fragment starting with the conjunction "or" (seen in an AI4L audit, pmid:25358826).
21
- * **Smaller answers (#32):** every tool answers compact JSON (no indentation); `library_add`, `library_status`, `library_read` and `library_clinicaltrials_search_studies` leave out null values, empty lists and the entry's paths and file names (`entry_dir`, `library_dir`, `files`). `library_verify` keeps its nulls (`score`, `reason` null carry meaning). A status answer is about 30% smaller, a 20-row trial search about 25%.
22
- * **`library_fetch(…, title_only)`:** the title line alone (`Contents of <url>:` and `# Title`); the page is fetched and stored in full as usual. For verifying a link at a few tokens instead of a page (#32).
23
- * **`library_clinicaltrials_search_studies(query | intervention | condition, status?, sort?, max_results?)`:** a ClinicalTrials.gov search (API v2, no key) — total count and rows as arrays in `columns` order (nct, title, status with why stopped, phase, enrollment, start, primary_completion, primary_outcome, conditions, interventions, has_results — the names once, not per row); `status` ongoing / completed / all; `sort` relevance / newest / largest / completion. Cached under `search/<2>/<4>/<key>.json` for 7 days. With `library_add("nct:…")` and `library_read` for the record and posted results, this covers what the AI4L models asked the ClinicalTrials.gov MCP server for (#28). `library_info`'s `cache` and the `.servers/` files count `search` hits and misses.
24
-
25
- ### v0.2.7 — 2026-11-08
26
-
27
- * **Verdicts are cached** (#26): `library_verify` keeps each judgement as `verify/<2>/<4>/<key>.json` — the file system as the database, laid out like `url/` — and answers the same claim on the same stored text from disk for `FH_LIBRARY_REFRESH_AFTER_DAYS`; a new full text, model or threshold is judged afresh, errors are never kept. The result is unchanged; the log line says `cached`. One atomic file per judgement: several servers on one library need no lock.
28
- * **Each server publishes its cache counters:** hits and misses of `library_verify` and `library_fetch` since it started, in memory, written to `<library>/.servers/<host>-<pid>.json` on every change and every 5 min as a heartbeat, removed on exit, swept by the next starting server when older than 20 min; `library_info` reports them as `cache`. The server also logs a `startup` line.
29
- * TUI v79: a `verify` row — stored verdicts ("queries cached"), then hits and misses summed over the servers alive on the library, read from `.servers/`, not from the log; `/status` carries `verdicts`. Resolves `.env` and `library/` from the caller's working directory, as the server does (#31) — `npm run --prefix <repo> tui` from a project root shows that project's library.
30
-
31
- ### v0.2.6 — 2026-11-08
32
-
33
- * **`library_fetch(url, max_length?, start_index?, raw?)`:** the standard MCP `fetch` tool, served from the library. Same answer as the reference server — `Contents of <url>:`, the page as Markdown with its title as the first heading, the truncation note, the raw-content prefix, the `Failed to fetch …` errors — so a model keeps its fetch habits. Underneath, the page is fetched up the download ladder (plain fetch, Playwright and Chrome rungs, Bright Data) when a plain fetch is refused, and stored under `url/`: the same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS`. A PDF comes back as Markdown. `library_info` counts web pages as type `url`; the log line says `cached` or `fetched`.
34
- * **Script shells climb the ladder:** a page answered 200 whose converted text is under 100 words with a loading/JavaScript phrase (or under 10 words at all) is treated like a wall page — the browser rungs, then Bright Data; a browser rung with so little text waits for the network to go quiet and the text to grow (at most 8 s) before reading the page. When no rung renders more, the shell is served, as fetch would (lifeextension.com's search is such a page: nothing renders it).
35
- * **Bright Data's 407 is reported:** a token's IP allowlist refusing this host used to surface as a bare "fetch failed" (Node's fetch treats a 407 as a network error); the unlocker now requests over node:https and the log alerts `!! brightdata: unlocker HTTP 407: You are not allowed to access our API via this IP (…)`.
36
- * TUI v74: a `url` row (web pages, failed fetches); fetch log lines coloured — `cached` green, rung outcomes green/red; `/status` counts `fetches` and `cached`; `(u)pdate` also rescans the library.
37
- * README: `library_version` returns the package "version", not "library_version" (one more word the prefix rename caught).
38
-
39
- ### v0.2.5 — 2026-11-08
40
-
41
- * **Tool descriptions** (issue #30): `library_info` names its key `verify`, not `library_verify`; `library_status` answers for one ID of any kind, not "one paper ID"; `library_verify`'s `claim` asks for a complete statement built from the review's words, not a verbatim fragment.
42
-
43
- ### v0.2.4 — 2026-11-08
44
-
45
- * **Fix: `library_status` on a stored entry and `library_info` failed** with "library_status is not defined" / "library_info is not defined" in 0.2.2 and 0.2.3: the rename that restored the `library_` prefix also renamed two local variables. `library_version`'s log line printed "undefined" for the same reason. The Playwright download rung lacked a `readFileSync` import. Prose in the server instructions and READMEs that had "read" or "status" turned into tool names is plain English again.
46
-
47
- ### v0.2.3 — 2026-11-08
48
-
49
- * **Every key is an `FH_LIBRARY_*` variable:** `FH_LIBRARY_NCBI_API_KEY`, `FH_LIBRARY_BRIGHTDATA_API_TOKEN`, `FH_LIBRARY_BRIGHTDATA_ZONE` replace the bare `NCBI_API_KEY`, `BRIGHTDATA_API_TOKEN`, `BRIGHTDATA_ZONE` the library used to share with other MCP servers. Hard cut: a deployment that still runs those servers keeps the bare lines for them and adds the prefixed ones for the library. The direction: fetching and PubMed move into the library, the separate servers go, and the double configuration with them.
50
-
51
- ### v0.2.2 — 2026-11-08
52
-
53
- * **Tool names carry the `library_` prefix again**, all of them: `library_add`, `library_status`, `library_read`, `library_verify`, `library_info`, `library_version` (0.2.0 and 0.2.1 had the bare names, and `version`). A model that sees many servers' tools should never have to guess what `add` or `read` belongs to. Client permissions and prompts name them with the prefix.
54
- * **Browser rungs by kind:** `FH_LIBRARY_PLAYWRIGHT_1`, `_2`, … (Playwright servers, port 8932) and `FH_LIBRARY_CHROME_1`, `_2`, … (a Chrome with `--remote-debugging-port`, port 9222) replace `FH_LIBRARY_BROWSER_1..3`. Values are hostnames (host or host:port); the kind is the variable's name, the protocol the library's. `FH_LIBRARY_PLAYWRIGHT_HEADLESS` (was `_BROWSER_HEADLESS`) belongs to the Playwright family; a Chrome rung is whatever it is. Order is fixed: Playwright servers, then Chrome rungs. The in-process "local" Chromium rung is gone — the library never launches a browser. `library_info` splits `download` into `playwright: {version, servers}` and `chrome`. Hard cut: every `.env` must move its browser lines.
55
-
56
- ### v0.2.0 — 2026-11-08
57
-
58
- * **Renamed to FH Library.** The npm packages are `fh-library` (public) and `fh-library-dev` (dev), the MCP Registry entry `io.github.forever-healthy/fh-library`, the repos `forever-healthy/fh-library` and `fh-library-dev`; the server announces itself as `fh-library`, and client configs use the keys `fh-library`, `fh-library-dev`, `fh-library-local` (the `d-` prefix is for MCP servers Forever Healthy did not write). The `ai4l-library` packages are deprecated and point here.
59
- * **Tools lose the `library_` prefix:** `add`, `status`, `read`, `verify`, `info`, `version` (was `get_version`). The server name namespaces them in every client.
60
- * **Environment variables are `FH_LIBRARY_*`** (was `AI4L_LIBRARY_*`), hard cut: every `.env` must follow. Keys other servers share (`NCBI_API_KEY`, `BRIGHTDATA_API_TOKEN`, `BRIGHTDATA_ZONE`) are unchanged. The browser helper is `fh-chrome` (service, window class, `~/.fh-chrome` profile), the browser server container `fh-browser`.
61
- * Entries in the changelog below this line use the old names; they describe the versions that carried them.
62
-
63
- ### v0.1.12 — 2026-11-07
64
-
65
- * Server instructions after the AI4L review (#21): calls may run in parallel — add, read and verify — and two calls on the same missing record share one add (said once, under Hints, instead of "reads may run in parallel"); `corrections` is named as the status's field; the stale `config.json` clause gives way to the proxy URL template from the configuration.
66
-
67
- ### v0.1.11 — 2026-11-07
68
-
69
- * `library_verify`: the verdict `na` is `unverifiable` again, now only for a paper whose abstract is stored and does not state the claim (the full text may). A title-only record — no abstract text, no full text — is a `fail`: nothing to read can back a claim; no model call, `score` null. Both carry `reason`, one fixed sentence each; `reason` is null otherwise. Audit tables map `unverifiable` where they mapped `na`. The log line pads the verdict to 12 characters.
70
- * A record with nothing to read is not citable: `citable` false with `comment` "no abstract and no full text: nothing to verify" (retraction still comes first). `library_info.not_citable` counts them. A full text dropped into the inbox makes the record citable again.
71
- * The server instructions rewritten as one short bullet document (3.9 K chars instead of 11 K): IDs, tools, rules, verify, read, status fields, sources. New guidance from the dev fleet run: no add or status before read or verify; one source, only what that source reports (a two-intervention finding cited to two papers is two claims; a statement about the literature becomes the figure this paper gives); what to do after each verdict (`fail` → read with a query, then reword or drop; `na` → "abstract only", no retry); `partial` → replace the `missing_terms`, keep the rest; a wrong ID is not retried. The `library_read` and `library_verify` descriptions shrunk to what the tool and its parameters do; the guidance lives in the instructions once.
72
-
73
- ### v0.1.10 — 2026-11-07
74
-
75
- * `library_read` and `library_verify` on a paper not in the library add it first, as `library_add` would (its lines in the log), then read or judge it; the read answer carries `added: true`, and a paper the add cannot build is the error. Parallel reads of one missing paper share a single add.
76
- * The `query` parameter says how to search: two or three distinctive terms the paper itself would use — a measure and a unit, an outcome and a group, a compound name — not the claim. The creators were sending 5–8 keyword dumps, which no paragraph holds whole.
77
-
78
- ### v0.1.9 — 2026-11-07
79
-
80
- * `library_info` shows the Playwright version the package pins as `download.playwright`; the fleet TUI's `/status` carries the same as `playwright`. A browser server of another version is logged in words as an alert — `!! browser hetzner: Playwright version mismatch — server ws://hetzner:8932 must run playwright-core 1.65.0` — instead of the raw `428 Precondition Required` (#19).
81
-
82
- ### v0.1.8 — 2026-11-07
83
-
84
- * `playwright-core` pinned to 1.65.0 (was `^1.58.1`), and `browser/Dockerfile` moved to the same version. A Playwright server refuses a client of another version with `428 Precondition Required`; a fresh install of the range had drifted to 1.65.0 while the browser servers ran 1.58.1, so the browser rungs never ran on the evipedia fleet. Package and servers must carry the same exact version.
85
-
86
- ### v0.1.7 — 2026-11-07
87
-
88
- `library_read`, from watching the creators use it (#18):
89
-
90
- * `query` answers windows, not paragraphs: each passage is at most 1000 chars around the matches (`clipped: true` when cut; a table row comes with its header row). A PDF-sourced section used to come back whole — up to 468 KB in one passage.
91
- * When no passage holds every term, the best partial matches come back with `partial: true` and the `matched` terms per passage, the rare term ranked first; `missing_terms` names the terms the text never uses in any form.
92
- * Matching forgives case, accents, hyphen/slash, Markdown markup (`t~max~` is tmax), plural and -ed/-ing/-ation forms.
93
- * `section` and `query` together search inside that section.
94
- * A parent heading answers its own text plus its `subsections` (heading, words) instead of the subtree cut at `max_chars`; a cut section reports `returned_words` against `words`.
95
- * A heading the text uses several times is listed as `Participants [3/12]` and named that way; the bare name lists the candidates.
96
- * `section "Abstract"` on a full text without one answers from the stored abstract; `section "Declarations"` on a text without one assembles the funding / conflict / acknowledgement / ethics sections.
97
- * PDF → Markdown: paragraphs from the page layout (extra leading, a first-line indent, a bullet, a change of type size, a short line ending a sentence), across columns and pages — not only from blank lines, which journal PDFs rarely have. Cochrane's section names are recognised as headings. Stored PDF-sourced texts keep their old structure until re-added.
98
- * JATS: a blank inside `<sub>`/`<sup>` moves outside the markers (`t~max~ around`, not `t~max ~around`).
99
-
100
- ### v0.1.6 — 2026-11-07
101
-
102
- * No change to the server. The fleet TUI (dev repo, not in the package) pulls its checkout with each rescan (`AI4L_LIBRARY_TUI_PULL=1`) instead of on its own timer.
103
-
104
- ### v0.1.5 — 2026-11-07
105
-
106
- * `library.log`: a message starting with `!!` is an alert — a service refused the deployment itself (Bright Data's reason from its `x-brd-err-msg` header, e.g. a token not allowed from this IP; a verify model failure; an unknown tier name at startup).
107
- * Fixed: `AI4L_LIBRARY_DIR` set in `.env` was ignored (the library directory was resolved before `.env` was loaded); only the environment worked.
108
-
109
- ### v0.1.4 — 2026-11-07
110
-
111
- * `.env` is the whole configuration: every value an environment variable, the tier set and the defaults built in. `library/config.json` and `AI4L_LIBRARY_CONFIG` are gone, and with them `${NAME}` references and the per-tier `description`.
112
- * Renamed, no aliases — `CONTACT_EMAIL` → `AI4L_LIBRARY_CONTACT_EMAIL`; `ELSEVIER_API_KEY` → `AI4L_LIBRARY_ELSEVIER_API_KEY`; `WILEY_TDM_TOKEN` → `AI4L_LIBRARY_WILEY_TDM_TOKEN`; `CORE_API_KEY` → `AI4L_LIBRARY_CORE_API_KEY`; `PERPLEXITY_URL` / `PERPLEXITY_API_KEY` / `PERPLEXITY_MODEL` → `AI4L_LIBRARY_VERIFY_URL` / `_API_KEY` / `_MODEL`; config.json's `threshold`, `timeout_seconds`, `retry_after_days`, `retry_blocked`, `refresh_after_days`, `download.headless` → `AI4L_LIBRARY_VERIFY_THRESHOLD`, `AI4L_LIBRARY_VERIFY_TIMEOUT_SECS`, `AI4L_LIBRARY_RETRY_AFTER_DAYS`, `AI4L_LIBRARY_RETRY_BLOCKED`, `AI4L_LIBRARY_REFRESH_AFTER_DAYS`, `AI4L_LIBRARY_BROWSER_HEADLESS`. `NCBI_API_KEY`, `BRIGHTDATA_API_TOKEN`, `BRIGHTDATA_ZONE` and `AI4L_LIBRARY_BROWSER_n` unchanged.
113
- * `AI4L_LIBRARY_TIERS` is the whole ladder: the listed tiers in exactly that order, a tier not listed is off (an unlisted tier no longer runs after the listed ones).
114
- * One proxy tier, `proxy`, instead of url tiers: `AI4L_LIBRARY_PROXY_URL` (template with `{doi}` `{pmid}` `{pmcid}`) and `AI4L_LIBRARY_PROXY_HEADERS`; `PROXY_TOKEN` and the `my-proxy` example are gone.
115
- * `library_info`: `config` replaces `tiers_config` — the cascade's settings, problems with `AI4L_LIBRARY_TIERS`, and per variable where its value came from (environment, `.env`, default), never the value; `tiers` in run order without descriptions (the README describes them).
116
- * The container's working directory is `/library`; the configuration arrives as its environment.
117
-
118
- ### v0.1.3 — 2026-11-07
119
-
120
- * IDs: `pmid:` (bare digits count as one), `doi:` for papers PubMed does not index (a DOI PubMed indexes is stored under its PMID and copied under the DOI, so every tool takes either), `dailymed:<set ID>` drug labels, `nct:` trial records.
121
- * `library_add(id)`: full text through a configurable cascade (`config.json`: inbox, pmc, europepmc, tdm, unpaywall, openalex, core, url tiers) and a download ladder (plain fetch, browser rungs, Bright Data); every text checked (title, methods and results, no wall page) before it counts; failed tiers recorded in `attempts`, transient ones retried after `retry_after_days`; PubMed record re-read after `refresh_after_days` for correction notices and retractions (`citable`, `corrections`).
122
- * `library_read(id, section?, query?, max_chars?, max_hits?)`: the outline, one section, or the paragraphs matching a query — never the whole paper.
123
- * `library_verify(claim, id)`: is one finding stated by one source, judged on the stored text by the Perplexity Decisions API; `superseded` when a stored correction replaced the figure; `glyphs_unresolved` for symbols a PDF font gave no character for.
124
- * `library_info(ids?)`: the library's directory, tiers, download ladder, verify model health, entries by type and level.
125
- * PDF conversion: symbols restored from the font's glyph names (minus, ±, ≤, Greek letters), words broken at line ends joined back.
126
- * `library.log`: one line per action, host-prefixed.
127
- * Environment variables renamed `LIBRARY_*` → `AI4L_LIBRARY_*` (`AI4L_LIBRARY_DIR`, `AI4L_LIBRARY_CONFIG`, `AI4L_LIBRARY_ENV`, `AI4L_LIBRARY_TIERS`, `AI4L_LIBRARY_BROWSER_n`, `AI4L_LIBRARY_TUI_STATUS_SECS`); the old names are not read.
128
- * `.env` is also looked for in the library directory when neither `AI4L_LIBRARY_ENV` nor `./.env` names one.
129
- * Entry files are written to a temporary name and renamed into place (several servers may share one library); `library.log` rotates to `library.log.1` past 50 MB.
130
-
131
- ### v0.1.1 — 2026-10-26
132
-
133
- * `library_add(id)` and `library_status(id)` for `pmid:` IDs.
134
-
135
- ### v0.0.1 — 2026-10-26
136
-
137
- * Project skeleton.
16
+ * **`library_fetch` answers record URLs from the library:** a PubMed, ClinicalTrials.gov, DailyMed or doi.org link is answered from the library entry (a missing PubMed record is stored at abstract level in one request, no full-text cascade; a DOI PubMed indexes goes through its PMID) in fetch's format — the abstract record for PubMed, the trial record, the label, the full text or abstract for a DOI; `title_only` the title line. No script shell, no browser.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Version 0.1.3](https://img.shields.io/badge/Version-0.2.16-green.svg)
1
+ ![Version 0.1.3](https://img.shields.io/badge/Version-0.2.19-green.svg)
2
2
  ![License MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
3
3
  [![Forever Healthy](https://img.shields.io/badge/(c)_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
4
4
  [![npm](https://img.shields.io/badge/npm-fh--library-cb3837.svg)](https://www.npmjs.com/package/fh-library)
@@ -7,7 +7,9 @@
7
7
 
8
8
  ![AI4L – AI for Practical Longevity](https://raw.githubusercontent.com/forever-healthy/fh-library/main/docs/AI4L-Header.png)
9
9
 
10
- **FH Library** - MCP server that builds a local library of the scientific papers an evidence review cites, part of [AI4L](https://github.com/forever-healthy/AI4L)
10
+ # FH Library MCP
11
+
12
+ MCP server that builds a local library of the scientific papers an evidence review cites, part of [AI4L](https://github.com/forever-healthy/AI4L)
11
13
 
12
14
  A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a local library of the scientific papers an evidence review cites. Given a paper ID (`type:value`, e.g. `pmid:30955454`), it fetches the paper's metadata, abstract and — where obtainable — full text through a configurable cascade of retrieval services (PubMed, PMC, Europe PMC, Unpaywall, OpenAlex, …), converts everything to Markdown, and stores it under a mounted `library/` directory. Nothing flows through the model's context: the server writes to disk itself and returns only a small status per call.
13
15
 
@@ -18,11 +20,11 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a
18
20
 
19
21
  ## Tools
20
22
 
21
- * `library_add(id)` → build or complete the library entry for one ID — a paper `pmid:<PMID>` or `doi:<DOI>` (see "Papers without a PMID"), a drug label `dailymed:<set ID>` (see "Drug labels"), or a trial record `nct:<8 digits>` (see "Clinical trial records"); returns its status (level `full_text` / `abstract` / `title` — what the entry holds, `title` when there is neither a full text nor abstract text — retrieving tier, `coi` and `funding` (PubMed's record, else the paper's own text), `citable` with `comment` — the one flag to check before citing, from `retracted` and PubMed's correction notices in `corrections` — `has_abstract` — false for title-only PubMed records such as letters and editorials — files, failed tiers in `attempts`). Idempotent: a `full_text` entry is never refetched, an `abstract` or `title` entry retries only tiers whose earlier failure was transient and past the cooldown; an entry whose PubMed record is older than `FH_LIBRARY_REFRESH_AFTER_DAYS` gets the record re-read for new notices. One ID per call.
23
+ * `library_add(id)` → build or complete the library entry for one ID — a paper `pmid:<PMID>` or `doi:<DOI>` (see "Papers without a PMID"), a drug label `dailymed:<set ID>` (see "Drug labels"), or a trial record `nct:<8 digits>` (see "Clinical trial records"); returns its status (level `full_text` / `abstract` / `title` — what the entry holds, `title` when there is neither a full text nor abstract text — retrieving tier, `citable` with `comment` — the one flag to check before citing, from `retracted` and PubMed's correction notices in `corrections` — `has_abstract` — false for title-only PubMed records such as letters and editorials — the failed tiers are in the log). Idempotent: a `full_text` entry is never refetched, an `abstract` or `title` entry retries only tiers whose earlier failure was transient and past the cooldown; an entry whose PubMed record is older than `FH_LIBRARY_REFRESH_AFTER_DAYS` gets the record re-read for new notices. One ID per call.
22
24
  * `library_status(id)` → read-only check of presence and level; never fetches
23
25
  * `library_info(ids?)` → the whole library, everything the TUI shows: the library directory and host, the decision model with its endpoint and whether it answers right now (`verify`), this server's cache hits and misses since it started (`cache`), the entries and their size on disk (`size`), per entry type (`types`: pmid, doi, dailymed, nct) the entries with their levels — `full_text`, `abstract`, `title` — and retractions; entry counts by type and level, counts of retracted and not-citable entries and of entries with errata, expressions of concern and updates, the configured tiers, library directory and host; `ids: true` also lists every entry ID
24
26
  * `library_verify(claim, id)` → is one finding of a review stated by one paper it cites? `claim` is the finding alone, verbatim, without its citations; `id` one paper (`pmid:`, or any `doi:`), drug label or trial record the review cites for it — a finding cited to three papers is three calls with the same claim; a claim that names a source or carries citations is refused. The server reads the paper (full text, else abstract) and asks a decision model; nothing of the paper enters the model's context. `verdict` **pass** (`score` the probability), **fail** (the full text does not state it; or only the title is in the library — `reason` says so), **unverifiable** (only the abstract is in the library and does not state it — `reason`) or **error**; `basis` full_text, abstract or title. See "Verifying claims" below.
25
- * `library_read(id, section?, query?, max_chars?, max_hits?)` → one small piece of a stored paper's Markdown, never the whole paper: without `section`/`query` the **outline** (headings with word counts, and whether the full text or only the abstract is stored; a heading the paper uses several times is listed as `Participants [3/12]`); `section` → that section's **own text** (heading matched as case-insensitive substring, capped at `max_chars`, default 6000, with `returned_words` against `words` when cut) and, for a parent heading, its `subsections` with word counts to pick from — a duplicated heading named bare lists the candidates instead; `query` — two or three distinctive terms the paper itself would use (a measure and a unit, an outcome and a group, a compound name), not the claim — → **passages** holding every term, each a window of at most 1000 chars around the matches (`clipped: true` when cut; a table row with its header row), with its section, best first (at most `max_hits`, default 5); matching forgives case, accents, hyphens, Markdown markup, plural and -ed/-ing forms; when no passage holds every term the best **partial** matches come back (`partial: true`, the `matched` terms per passage, the rare term first), and `missing_terms` names the terms the text never uses in any form — those are the ones to rephrase. `section` and `query` together search inside that section. `section: "Abstract"` on a full text without one answers from the stored abstract. Every full text converted from JATS XML (PMC, Europe PMC, Elsevier) has a **Declarations** section — funding, conflicts of interest, author notes, gathered from wherever the XML keeps them — and on a text without one `section: "Declarations"` assembles the sections headed funding, conflicts, acknowledgements, ethics, contributions, so it answers who paid and who declares what on every source alike; a paper with neither states nothing. Outline first, then pick. A paper not in the library is added first, as `library_add` would (its lines in the log), then library_read; the answer carries `added: true`. Reads may run in parallel.
27
+ * `library_read(id, section?, query?, max_chars?, max_hits?)` → one small piece of a stored paper's Markdown, never the whole paper: without `section`/`query` the **outline** (headings with word counts, and whether the full text or only the abstract is stored; a heading the paper uses several times is listed as `Participants [3/12]`); `section` → that section's **own text** (heading matched as case-insensitive substring, capped at `max_chars`, default 6000, with `returned_words` against `words` when cut) and, for a parent heading, its `subsections` with word counts to pick from — a duplicated heading named bare lists the candidates instead; `query` — two or three distinctive terms the paper itself would use (a measure and a unit, an outcome and a group, a compound name), not the claim — → **passages** holding every term, each a window of at most 1000 chars around the matches (`clipped: true` when cut; a table row with its header row), with its section, best first (at most `max_hits`, default 5; `more` counts the further passages that match); matching forgives case, accents, hyphens, Markdown markup, plural and -ed/-ing forms; when no passage holds every term the best **partial** matches come back (`partial: true`, the `matched` terms per passage, the rare term first), and `missing_terms` names the terms the text never uses in any form — those are the ones to rephrase. `section` and `query` together search inside that section. `section: "Abstract"` on a full text without one answers from the stored abstract. Every full text converted from JATS XML (PMC, Europe PMC, Elsevier) has a **Declarations** section — funding, conflicts of interest, author notes, gathered from wherever the XML keeps them — and on a text without one `section: "Declarations"` assembles the sections headed funding, conflicts, acknowledgements, ethics, contributions, so it answers who paid and who declares what on every source alike; a paper with neither states nothing. Outline first, then pick. A paper not in the library is added first, as `library_add` would (its lines in the log), then library_read; the answer carries `added: true`. Reads may run in parallel.
26
28
  * `library_pubmed_search_articles(query, max_results?, sort?, years?)` → a PubMed search in PubMed's own syntax (see "PubMed in batches"): the total and rows in `columns` order, with the library level of a paper already stored, cached for `FH_LIBRARY_SEARCH_CACHE_DAYS`
27
29
  * `library_pubmed_fetch_articles(pmids)` → title, first author, year, publication types, retracted and level for up to 200 PMIDs in one call (see "PubMed in batches"); PMIDs the library lacks are fetched in one PubMed request and stored at abstract level
28
30
  * `library_clinicaltrials_search_studies(query | intervention | condition, status?, sort?, max_results?)` → a ClinicalTrials.gov search (see "Trial search"): the total count and compact rows, cached for a week
@@ -60,7 +62,7 @@ Downloads are plain HTTP with a desktop Chrome User-Agent (API calls identify th
60
62
 
61
63
  * **Which tiers run, in which order:** `FH_LIBRARY_TIERS=pmc,tdm,unpaywall,openalex` — exactly these, in this order; a tier not listed is off. Unset, every tier runs in the order of the table above, the proxy last.
62
64
  * **Keys:** `FH_LIBRARY_ELSEVIER_API_KEY` and `FH_LIBRARY_WILEY_TDM_TOKEN` for `tdm`, `FH_LIBRARY_CORE_API_KEY` for `core`, `FH_LIBRARY_NCBI_API_KEY` (10 instead of 3 requests/s), `FH_LIBRARY_BRIGHTDATA_API_TOKEN` and `FH_LIBRARY_BRIGHTDATA_ZONE` for the unlocker. `FH_LIBRARY_CONTACT_EMAIL` is the one address sent to every service (NCBI `tool`/`email`, Unpaywall and OpenAlex polite pools, Crossref, the User-Agent).
63
- * **Retries:** `FH_LIBRARY_RETRY_AFTER_DAYS` (default 1) is the cooldown before a transient tier failure is retried, before a page `library_fetch` could not get is fetched again, and before an ID no service knew (a PMID, DOI, set ID or NCT ID that is not found) is asked for again — meanwhile every tool answers "not found" from a marker in the entry's directory, with the date it was checked; `FH_LIBRARY_SEARCH_CACHE_DAYS` (7) how long a PubMed or registry search is answered from disk; `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) the age of an entry's PubMed record after which `library_add` re-reads it for new correction notices and retractions. A tdm link behind a token that is not configured is recorded as `needs elsevier token` and not re-checked; after adding the token, remove those attempts from the entries to give them a go. `FH_LIBRARY_RETRY_BLOCKED` (false) decides whether a browser challenge that did not clear, or a Bright Data refusal, is final: with the same method tomorrow ends the same way, so set it true only after a change of method (a new browser rung, a new service).
65
+ * **Retries:** `FH_LIBRARY_RETRY_AFTER_DAYS` (default 1) is the cooldown before a transient tier failure is retried, before a page `library_fetch` could not get is fetched again, and before an ID no service knew (a PMID, DOI, set ID or NCT ID that is not found) is asked for again — meanwhile every tool answers "not found" from a marker in the entry's directory, with the date it was checked; `FH_LIBRARY_SEARCH_CACHE_DAYS` (7) how long a PubMed or registry search is answered from disk; `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) the age of an entry's PubMed record after which `library_add` re-reads it for new correction notices and retractions. A tdm link behind a token that is not configured is recorded as `needs elsevier token` and not re-checked; after adding the token, remove those attempts from the entries to give them a go. A browser rung or Bright Data that met the same site's challenge twice in a row is skipped for that site (and only that rung) until `FH_LIBRARY_RETRY_AFTER_DAYS` have passed since the last challenge; a success clears it (`library/walls/<host>.json`). `FH_LIBRARY_RETRY_BLOCKED` (false) decides whether a browser challenge that did not clear, or a Bright Data refusal, is final: with the same method tomorrow ends the same way, so set it true only after a change of method (a new browser rung, a new service).
64
66
  * **Browsers and verify:** `FH_LIBRARY_PLAYWRIGHT_n` and `FH_LIBRARY_CHROME_n` (hostnames; above) and `FH_LIBRARY_PLAYWRIGHT_HEADLESS`; `FH_LIBRARY_VERIFY_*` ("Verifying claims"). A Playwright server must run the **same `playwright-core` version** as the package — `library_info` shows the required one as `download.playwright.version`, `browser/Dockerfile` builds a server at it, and a server of another version is refused by Playwright itself (the log then carries the alert `!! browser <host>: Playwright version mismatch — server … must run playwright-core <version>`). Moving the version is one release: the pin in `package.json`, the tag in `browser/Dockerfile`, every server rebuilt. The library never launches a browser itself: every rung is a browser somebody runs.
65
67
 
66
68
  `library_info` reports the tiers in run order and, per variable, whether its value came from the environment, `.env` or the default — never the value.
@@ -73,8 +75,9 @@ FH_LIBRARY_PROXY_HEADERS=Authorization: Bearer <token>
73
75
  ```
74
76
 
75
77
  * The URL is a template. Placeholders: `{doi}` (`10.1056/NEJMoa042274`), `{pmid}` (`15516696`), `{pmcid}` (`PMC6728809`, with its prefix). Each is URL-encoded when filled — the slash in a DOI becomes `%2F` — so put a placeholder in a path segment or a query value. A template may use several. The tier applies only to a paper for which every placeholder it uses is known; otherwise it is skipped as `no doi` (or `no pmcid`, …), a permanent reason. A template without any placeholder never runs (`template has no placeholder`).
78
+
76
79
  * The headers are optional request headers for credentials (`Authorization`, an API-key header, a cookie), as `Name: value; Name: value`.
77
- * Unset URL: no proxy tier. `FH_LIBRARY_TIERS` places `proxy` in the order like any built-in tier.
80
+
78
81
 
79
82
  **What the proxy tier expects back.** The tier sends one GET to the filled template, with a desktop Chrome User-Agent and the headers; redirects are followed. The service answers the paper as one of two formats, read from the response bytes, not the URL or the Content-Type:
80
83
 
@@ -151,11 +154,13 @@ PubMed's book records — National Academies reports, NTP monographs, StatPearls
151
154
 
152
155
  **PubMed in batches.** `library_pubmed_fetch_articles(pmids)` answers what an auditor asks of a list of PubMed IDs — does each resolve, what is its title, year and type, is it retracted, does the library hold its full text — as rows in `columns` order, about 120 characters per paper. A PMID already in the library is read from disk. The others go to PubMed in one efetch request (up to 200), and each record is stored exactly as `library_add` stores its first stage: the XML, the abstract Markdown, the meta with its correction and retraction notices, no retrieval attempts yet — so a later `library_add` or `library_read` runs the full-text cascade as on an untried entry, and the library fills at abstract level in batches instead of one paper at a time. An entry is checked again right before writing, so a concurrent `library_add` that already stored a full text is never overwritten. Level `not found` marks a PMID PubMed does not know, `invalid` a value that is not a PMID. `library_pubmed_search_articles(query, max_results?, sort?, years?)` is the search: the query goes to PubMed as written, field tags and all; esearch gives the total and the IDs, one esummary the rows — PMID, title, first author, year, journal, publication types (Journal Article left out), PMCID when a free full text exists, and the library's level when the paper is already stored, so the model knows what `library_read` can answer at once. Cached under `search/` like the trial search.
153
156
 
157
+ **Record URLs.** `library_fetch` answers a PubMed, ClinicalTrials.gov, DailyMed or doi.org link from the library instead of the web page, in fetch's format: the abstract record for a PubMed link, the trial record, the label, the full text or abstract for a DOI; `title_only` the title line. A missing PubMed record is stored at abstract level in one request, no full-text cascade (a DOI PubMed indexes goes through its PMID); a trial or label is added as `library_add` adds it. PubMed's own page is a script shell a browser must render; the record answers in half a second, and in milliseconds once stored.
158
+
154
159
  **Trial search.** `library_clinicaltrials_search_studies(query?, intervention?, condition?, status?, sort?, max_results?)` asks the ClinicalTrials.gov API (v2, no key) what a review needs from the registry: the trials of an intervention, the ongoing ones (`status: "ongoing"` is recruiting, not yet recruiting, active not recruiting, enrolling by invitation), a free-text or condition search, sorted by relevance, newest, largest or completion date, up to 100 rows. The rows are arrays in the order of `columns` — the field names once, not per row — shaped for the review's Emerging Research item (what AI4L's prompt asks about an ongoing trial; the link is `https://clinicaltrials.gov/study/<NCT ID>`): NCT ID, title, status with why a trial stopped, phase, enrollment as "100 (estimated)", start and primary completion dates (whether "ongoing" still holds), the primary outcome with its time frame, conditions, interventions, `has_results`; the full detail of one row is `library_clinicaltrials_get_study_record`; `total` is the registry's count, for a "no trials found" statement. The same search is answered from `search/<2>/<4>/<key>.json` for `FH_LIBRARY_SEARCH_CACHE_DAYS` (7) — a registry listing a week old is current enough for a review, and an audit that runs again reads from disk. Two thin tools answer from the stored record of any row (added first when missing): `library_clinicaltrials_get_study_record(nct)` is the review's detail of one trial — official title, design, enrollment, dates, eligibility, sites, result publications as `pmid:` IDs, the brief summary — and `library_clinicaltrials_get_study_results(nct, max_chars?)` is the posted results part of the stored Markdown (participant flow, baseline, outcome measures, adverse events), with the outcome titles listed so `library_read("nct:…", section)` can return one measure whole. Together with the search they cover what the AI4L models asked the ClinicalTrials.gov MCP server for.
155
160
 
156
161
  **Web pages.** `library_fetch(url, max_length?, start_index?, raw?)` is the standard MCP [fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) tool, served from the library, for every link a review carries that is not a paper, label or trial record: a guideline, a product page, a news item. Its description tells the model to prefer it over a plain fetch or a browser tool, and says why: a page anyone fetched in the last 30 days comes from disk in milliseconds, and a page a plain fetch cannot get climbs the ladder in the same call. The answer is what `fetch` returns, so a model keeps its habits: `Contents of <url>:` and the page as Markdown with the page's title as the first heading, cut at `max_length` (default 5000) with the note `Content truncated. Call the fetch tool with a start_index of N to get more content`; `raw` gives the HTML; a `text/markdown` body comes back as is, any other non-HTML body with the `Content type … cannot be simplified to markdown` prefix; a failure is `Failed to fetch <url> - status code 404` or `Failed to fetch <url>: <reason>`. Two things happen underneath. The page is fetched up the same ladder as a paper — the plain fetch, then the browser rungs on a 403, 429 or bot wall, then Bright Data — so a page a plain fetch cannot reach is still read. And it is stored: `url/<2>/<4>/<16 hex of the URL's SHA-1>/url-<hash>-{meta.md, page.md, page.html|pdf|txt}`, the URL normalised first (host lowercased, fragment and tracking parameters dropped). The same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS` (1) — an audit that runs twice over one review reads its links once. A PDF comes back as Markdown (`fetch` would return its bytes as text). `title_only` returns `Contents of <url>:` and the title line alone — the page is still fetched and stored in full — so a link check costs a few tokens instead of a page. The log says which — `fetch example.com/… cached html … https://example.com/drugs/x` or `fetched 02.3s html retrieved_by=browser hetzner … <url>` (the ID column holds the URL without scheme, cut to a PubMed ID's width; the full URL ends the line); `library_info` counts the pages under `url`, a failed fetch as level `title`.
157
162
 
158
- **Funding and conflicts of interest.** Every status carries `coi`, the paper's conflict-of-interest statement, and `funding`, who paid. Both come from PubMed's record at ingest (`CoiStatement`, the grant list's agencies; a quarter of records have each) and are refreshed with it; where PubMed has none and a full text was retrieved, they are taken from the paper's own text — its Declarations section, or a section headed Funding, Conflicts of interest, Competing interests or Disclosure — as the statement verbatim. `coi` null and `funding` empty therefore mean: neither PubMed nor the stored text states anything. For an abstract-only entry that is as far as the library can see. The full statements of a paper with full text are one call away: `library_read(id, section: "Declarations")` — the JATS section, or on a PDF- or HTML-sourced text the funding, conflict and acknowledgement sections assembled. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
163
+ **Funding and conflicts of interest.** Every entry's meta keeps `coi`, the paper's conflict-of-interest statement, and `funding`, who paid; the status answer leaves them out (hundreds of characters on every add), `library_read(id, section: "Declarations")` returns them. Both come from PubMed's record at ingest (`CoiStatement`, the grant list's agencies; a quarter of records have each) and are refreshed with it; where PubMed has none and a full text was retrieved, they are taken from the paper's own text — its Declarations section, or a section headed Funding, Conflicts of interest, Competing interests or Disclosure — as the statement verbatim. `coi` null and `funding` empty therefore mean: neither PubMed nor the stored text states anything. For an abstract-only entry that is as far as the library can see. The full statements of a paper with full text are one call away: `library_read(id, section: "Declarations")` — the JATS section, or on a PDF- or HTML-sourced text the funding, conflict and acknowledgement sections assembled. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
159
164
 
160
165
  **Symbols a PDF font hides.** Some publishers' symbol fonts carry no Unicode for their glyphs, so text extraction gives a control character where the PDF shows a minus, ±, ≤ or β. The library restores the symbol from the glyph's name in the font — the names of the Adobe Pi fonts (`H11350` is ≥ everywhere) and of the Advent symbol font most Elsevier and Wiley PDFs use — each name checked against the glyph's outline in the library's PDFs; a name it does not know is left as it is and counted: the status's `glyphs_unresolved` is the number left in the full text (162 of 1,119 PDF-derived entries have some, down from 277), and `library_verify` returns the same count for the text it judged. A `fail` on a paper with `glyphs_unresolved` above 0 says "check the symbol", not "the paper does not state it". Words a PDF breaks at a line end ("differ- ence") are joined back when the paper uses the word elsewhere or no compound is known at that break; a hyphen the paper uses elsewhere ("time-dependent") and a suspended one ("dose- and time-dependent") are kept.
161
166