fh-library 0.2.3 → 0.2.5
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.
- package/CHANGELOG.md +8 -0
- package/README.md +3 -3
- package/dist/index.js +14 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# FH Library — Change Log
|
|
2
2
|
|
|
3
|
+
### v0.2.5 — 2026-11-08
|
|
4
|
+
|
|
5
|
+
* **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.
|
|
6
|
+
|
|
7
|
+
### v0.2.4 — 2026-11-08
|
|
8
|
+
|
|
9
|
+
* **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.
|
|
10
|
+
|
|
3
11
|
### v0.2.3 — 2026-11-08
|
|
4
12
|
|
|
5
13
|
* **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.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|

|
|
3
3
|
[_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
|
|
4
4
|
[](https://www.npmjs.com/package/fh-library)
|
|
@@ -131,13 +131,13 @@ Hosted or local is three lines in `.env`: `FH_LIBRARY_VERIFY_URL`, `FH_LIBRARY_V
|
|
|
131
131
|
curl -s http://<ip>:8080/v1/decisions -H 'Content-Type: application/json' -d '{"model":"pplx-decider-flash-v1.2","state":"I was charged twice for my order.","questions":{"team":{"type":"choice","criteria":{"billing":null,"shipping":null,"technical":null}}}}'
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
**Papers without a PMID.** A review may cite a paper PubMed does not index by its DOI (`https://doi.org/<DOI>`); `library_add("doi:10.5812/asjsm.110753")` takes it. If PubMed does index the DOI, the call builds that paper's `pmid:` entry and stores a copy of it under `doi:` — the same files, renamed, with `pmid` set in its meta — so the DOI resolves on disk: every later call answers from the `doi:` entry without asking PubMed (5 of the 101 DOIs evipedia cites). Each `library_add` of such a DOI completes the `pmid:` entry (retries, record refresh) and copies it again, so the copy is as current as the original. A mapped paper counts once under `pmid` and once under `doi` in `library_info`. Otherwise the record comes from [Crossref](https://www.crossref.org/) — or, for a DOI another registry holds (DataCite, mEDRA, …), from doi.org's content negotiation as CSL JSON, stored as `-csl.json`: title, authors, journal, year, the abstract — Crossref's when the publisher deposited one, else OpenAlex's (of the 97 DOI-only papers evipedia cites, Crossref has 55 abstracts, OpenAlex 86; 11 have none anywhere and stay title-only unless a full text is found) — the funders as `funding`, and Crossref's update notices — corrections, retractions, expressions of concern — as `corrections`, so `citable` works as for a PubMed paper. The
|
|
134
|
+
**Papers without a PMID.** A review may cite a paper PubMed does not index by its DOI (`https://doi.org/<DOI>`); `library_add("doi:10.5812/asjsm.110753")` takes it. If PubMed does index the DOI, the call builds that paper's `pmid:` entry and stores a copy of it under `doi:` — the same files, renamed, with `pmid` set in its meta — so the DOI resolves on disk: every later call answers from the `doi:` entry without asking PubMed (5 of the 101 DOIs evipedia cites). Each `library_add` of such a DOI completes the `pmid:` entry (retries, record refresh) and copies it again, so the copy is as current as the original. A mapped paper counts once under `pmid` and once under `doi` in `library_info`. Otherwise the record comes from [Crossref](https://www.crossref.org/) — or, for a DOI another registry holds (DataCite, mEDRA, …), from doi.org's content negotiation as CSL JSON, stored as `-csl.json`: title, authors, journal, year, the abstract — Crossref's when the publisher deposited one, else OpenAlex's (of the 97 DOI-only papers evipedia cites, Crossref has 55 abstracts, OpenAlex 86; 11 have none anywhere and stay title-only unless a full text is found) — the funders as `funding`, and Crossref's update notices — corrections, retractions, expressions of concern — as `corrections`, so `citable` works as for a PubMed paper. The status has `pmid` null, the abstract's head line says "not in PubMed", `retrieved_by` names the source of what the entry holds — `crossref`, `doi.org` or `openalex` for the abstract, `crossref` or `doi.org` for a title alone, the tier once a full text is found; the full-text cascade then runs on the DOI (`unpaywall`, `openalex`, `tdm`, `core`, `proxy`; `pmc`, `europepmc` and `inbox` need a PMID). An entry older than `FH_LIBRARY_REFRESH_AFTER_DAYS` gets its Crossref record re-read for new notices. Entries live under `doi/<hash 2>/<hash 4>/<slug>/`, the slug being the DOI in lowercase with every run of other characters than letters, digits, `.` and `-` as `_` (`doi-10.5812_asjsm.110753`), the Crossref record as `-crossref.json`. `library_status`, `library_read` and `library_verify` take any DOI `library_add` took: they read its `doi:` entry, the copy or the Crossref one, and never look a DOI up. Measured on evipedia's 915 live reviews (2026-11-06): 87 cite 101 papers by DOI only, against 34,378 PubMed citations.
|
|
135
135
|
|
|
136
136
|
PubMed's book records — National Academies reports, NTP monographs, StatPearls chapters in NCBI Bookshelf — are read as papers too: the chapter or book title, the authors (else the editors), the publisher in place of the journal, publication type `Book`.
|
|
137
137
|
|
|
138
138
|
**Drug labels.** `library_add("dailymed:<set ID>")` stores the current FDA prescribing information of one product from [DailyMed](https://dailymed.nlm.nih.gov/), the set ID being the fixed identifier in the label's page URL (`…/drugInfo.cfm?setid=<set ID>`) across all its revisions — the same ID a review links. The SPL XML is converted to Markdown: first a **Highlights** section with the label's header lines — the product line and "Initial U.S. Approval: 2003" (the year is also in the head line) — then every text section in order, a boxed warning headed "Boxed Warning" and the recent major changes headed as such even where the label gives them no title, (indications, dosage, contraindications, warnings, adverse reactions with their tables, drug interactions, clinical pharmacology, clinical studies); the structured product data and package images are left out; there is no abstract. The status carries `setid`, the title as `<brand> (<generic>) <form> — prescribing information`, the labeler as `first_author`, the effective year as `year`, the label version as `version` (`spl 52`), and `publication_types` `["Drug Label"]`. `library_read` and `library_verify` work on a label as on a paper, so a figure the review takes from a label is verified against the label. An entry older than `FH_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when DailyMed has a newer version. Entries live under `dailymed/<first 2>/<first 4>/<set ID>/`, with the SPL XML and the Markdown as `dailymed-<set ID>-fulltext.xml` and `-fulltext.md`, and the log line reads `library_add dailymed:<set ID> dailymed fetched level=full_text spl 52 effective=2026-06-03 Cialis (Tadalafil)`.
|
|
139
139
|
|
|
140
|
-
**Clinical trial records.** `library_add("nct:05173896")` stores the current record of one trial from [ClinicalTrials.gov](https://clinicaltrials.gov/): the ID is the eight digits of the NCT number in the review's link (`…/study/NCT05173896`); `nct:NCT05173896` and a bare `NCT05173896` are accepted and
|
|
140
|
+
**Clinical trial records.** `library_add("nct:05173896")` stores the current record of one trial from [ClinicalTrials.gov](https://clinicaltrials.gov/): the ID is the eight digits of the NCT number in the review's link (`…/study/NCT05173896`); `nct:NCT05173896` and a bare `NCT05173896` are accepted and read the same. The API's JSON is converted to Markdown, one section per module: identification with secondary IDs such as EudraCT, status with every date and its actual/estimated type, sponsor and collaborators, description, conditions, design (phase, allocation, masking, enrollment with its type), arms and interventions with doses, primary and secondary outcomes with time frames, eligibility, locations as a country list, references; and, when results are posted, the participant flow, baseline characteristics, every outcome measure with its group values and analyses, and the adverse events, all as tables. Values are written in plain words, "Phase 2", "Active, not recruiting", "100 (estimated)", so a figure the review states can be found as stated. The status carries `nctid`, `publications` (the results publications the record lists, as `pmid:…`, so whether a trial has been published is one status away), the brief title, the lead sponsor as `first_author`, the first-posted year as `year`, the last-update-posted date as `version`, and `publication_types` `["Trial Registration"]`. `library_read` and `library_verify` work on a record as on a paper. An entry older than `FH_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when the record was updated since; a status the review states that has moved on then fails, which is right. Entries live under `nct/<digits 1–2>/<digits 1–4>/<digits>/` with the JSON and the Markdown as `nct-<digits>-fulltext.json` and `-fulltext.md`; the log line reads `library_add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
|
|
141
141
|
|
|
142
142
|
**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).
|
|
143
143
|
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/env.ts
|
|
4
|
-
import { existsSync, readFileSync
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
5
|
import { join, resolve } from "path";
|
|
6
6
|
function dotenvFile(env = process.env) {
|
|
7
7
|
const named = env.FH_LIBRARY_ENV?.trim();
|
|
@@ -14,7 +14,7 @@ var loadedFromDotenv = /* @__PURE__ */ new Set();
|
|
|
14
14
|
function loadDotenv(file = dotenvFile()) {
|
|
15
15
|
if (!existsSync(file)) return [];
|
|
16
16
|
const loaded = [];
|
|
17
|
-
for (const raw of
|
|
17
|
+
for (const raw of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
18
18
|
const line = raw.trim();
|
|
19
19
|
if (!line || line.startsWith("#")) continue;
|
|
20
20
|
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
@@ -889,7 +889,7 @@ var countUnresolved = (text3) => (text3.match(UNRESOLVED_GLYPH) ?? []).length;
|
|
|
889
889
|
var looksLikePdf = (bytes) => bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
|
890
890
|
|
|
891
891
|
// src/browser.ts
|
|
892
|
-
import { existsSync as existsSync3, mkdirSync } from "fs";
|
|
892
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2 } from "fs";
|
|
893
893
|
import { createRequire } from "module";
|
|
894
894
|
import { join as join3 } from "path";
|
|
895
895
|
import { chromium } from "playwright-core";
|
|
@@ -1119,7 +1119,7 @@ async function browserFetch(url, endpoint, cfg = browserConfig()) {
|
|
|
1119
1119
|
}
|
|
1120
1120
|
if (download2) {
|
|
1121
1121
|
const d = download2;
|
|
1122
|
-
return { bytes:
|
|
1122
|
+
return { bytes: readFileSync2(await d.path()), contentType: "application/pdf", url: d.url(), via: "download" };
|
|
1123
1123
|
}
|
|
1124
1124
|
const title = await page.title().catch(() => "");
|
|
1125
1125
|
if (CHALLENGE.test(title)) throw new Error(`challenge not cleared (${title.slice(0, 40)})`);
|
|
@@ -3574,10 +3574,10 @@ IDs \u2014 always "type:value"
|
|
|
3574
3574
|
- nct:05173896 \u2014 a ClinicalTrials.gov trial record, current version. NCT05173896 is accepted too.
|
|
3575
3575
|
|
|
3576
3576
|
Tools
|
|
3577
|
-
- library_version() \u2014 package name,
|
|
3577
|
+
- library_version() \u2014 package name, version, library directory.
|
|
3578
3578
|
- library_info(ids?) \u2014 the whole library: entry counts by type and level, the retrieval sources, browsers, the decision model, the configuration. ids=true lists every entry.
|
|
3579
3579
|
- library_add(id) \u2014 download and store one record; returns its status. A stored full text is returned without any download; an abstract-level record retries only the sources that failed transiently; a record older than 30 days gets its correction notices re-read.
|
|
3580
|
-
- library_status(id) \u2014 the
|
|
3580
|
+
- library_status(id) \u2014 the status of one record without downloading; level "missing" when it is not stored.
|
|
3581
3581
|
- library_read(id, section?, query?, max_chars?, max_hits?) \u2014 one small piece of a stored document: its outline, one section, or the passages matching a query.
|
|
3582
3582
|
- library_verify(claim, id) \u2014 does this one source state this one finding? A decision model judges the stored text and returns a verdict.
|
|
3583
3583
|
|
|
@@ -3594,7 +3594,7 @@ Verifying a claim
|
|
|
3594
3594
|
- It can only confirm what this one document states. A finding about two interventions from two papers is two claims, each against its own paper. A summary of several trials ("trials used 60\u2013200 mg") is stated by none of them; the dose this one trial used is.
|
|
3595
3595
|
- It returns error for a claim under four words, a sentence fragment, or a claim still containing citations. A short finding with its setting (in vitro, cell line, dose) is accepted; filler words do not help.
|
|
3596
3596
|
- verdict "pass" \u2014 the document states the claim. score is the model's probability.
|
|
3597
|
-
- verdict "fail" \u2014 the stored full text does not state the claim as worded; library_read with a query shows what the document does say. Or, with basis "title", nothing is stored to
|
|
3597
|
+
- verdict "fail" \u2014 the stored full text does not state the claim as worded; library_read with a query shows what the document does say. Or, with basis "title", nothing is stored to read: reason says so, score is null.
|
|
3598
3598
|
- verdict "unverifiable" \u2014 only the abstract is stored and it does not state the claim; the full text may (reason says so). A retry gives the same answer until a full text is stored.
|
|
3599
3599
|
- glyphs_unresolved above 0 \u2014 the PDF font lost symbols (minus, \xB1, \u2264); a fail on a number may be the font. library_read shows the passage.
|
|
3600
3600
|
- superseded true \u2014 a published correction replaced the figure the claim uses; the status's corrections holds the notice.
|
|
@@ -3883,18 +3883,18 @@ function registerTools(server2) {
|
|
|
3883
3883
|
}
|
|
3884
3884
|
server2.tool(
|
|
3885
3885
|
"library_status",
|
|
3886
|
-
'Read-only status of the library entry for one paper
|
|
3886
|
+
'Read-only status of the library entry for one ID \u2014 a paper, a drug label or a trial record: the same status JSON as library_add, or level "missing" when there is no entry. Never fetches.',
|
|
3887
3887
|
{ id: idSchema },
|
|
3888
3888
|
async ({ id }) => {
|
|
3889
3889
|
const parsed = parseId(id);
|
|
3890
3890
|
const status = emptyStatus(parsed.requested, parsed.type, parsed.id, FH_LIBRARY_DIR);
|
|
3891
3891
|
if (parsed.error) {
|
|
3892
3892
|
log("library_status", parsed.requested, `error ${parsed.error}`);
|
|
3893
|
-
return json({ ...
|
|
3893
|
+
return json({ ...status, error: parsed.error });
|
|
3894
3894
|
}
|
|
3895
3895
|
const paths = entryPaths(FH_LIBRARY_DIR, parsed.type, parsed.id);
|
|
3896
3896
|
if (entryExists(paths)) {
|
|
3897
|
-
const s = readStatus(paths,
|
|
3897
|
+
const s = readStatus(paths, status);
|
|
3898
3898
|
log("library_status", parsed.requested, `level=${s.level}`);
|
|
3899
3899
|
return json(s);
|
|
3900
3900
|
}
|
|
@@ -3985,7 +3985,7 @@ function registerTools(server2) {
|
|
|
3985
3985
|
"library_verify",
|
|
3986
3986
|
"Does one source state one finding of a review? claim: the finding as a complete statement (subject, intervention, outcome, number), without citations or the review's framing, and only what this source reports. id: one source the review cites for it \u2014 paper, drug label (dailymed:\u2026) or trial record (nct:\u2026); added first when missing. The server reads the stored text and asks a decision model; nothing enters your context. verdict: pass (score is the probability) | fail (the full text does not state it; or only the title is stored \u2014 reason says so) | unverifiable (only the abstract is stored and does not state it \u2014 reason says so) | error (claim refused: under four words, a fragment, carrying citations; add failed; no model; model failure). basis: full_text | abstract | title. glyphs_unresolved: symbols the PDF font lost; above 0 a failed number may be the font. superseded: true when a stored correction replaced the claim's figure.",
|
|
3987
3987
|
{
|
|
3988
|
-
claim: z.string().describe("One finding,
|
|
3988
|
+
claim: z.string().describe("One finding as a complete statement (subject, intervention, outcome, number), built from the review's words, without its citations and framing."),
|
|
3989
3989
|
id: idSchema
|
|
3990
3990
|
},
|
|
3991
3991
|
async ({ claim, id }) => {
|
|
@@ -4004,7 +4004,7 @@ function registerTools(server2) {
|
|
|
4004
4004
|
);
|
|
4005
4005
|
server2.tool(
|
|
4006
4006
|
"library_info",
|
|
4007
|
-
"The whole library as JSON: library directory and host; entry count and size on disk; per type (pmid, doi, dailymed, nct) the entries by level (full_text, abstract, title) and retractions; counts of retracted and not-citable entries and of errata, expressions of concern and updates; the retrieval sources in run order and which are off (tiers); the download ladder (download): the Playwright servers with the version they must run, the Chrome rungs, the Bright Data unlocker; the configuration and where each value came from \u2014 environment, .env or default, never the value (config); the decision model behind library_verify and whether it answers right now (
|
|
4007
|
+
"The whole library as JSON: library directory and host; entry count and size on disk; per type (pmid, doi, dailymed, nct) the entries by level (full_text, abstract, title) and retractions; counts of retracted and not-citable entries and of errata, expressions of concern and updates; the retrieval sources in run order and which are off (tiers); the download ladder (download): the Playwright servers with the version they must run, the Chrome rungs, the Bright Data unlocker; the configuration and where each value came from \u2014 environment, .env or default, never the value (config); the decision model behind library_verify and whether it answers right now (verify). ids=true also lists every entry ID.",
|
|
4008
4008
|
{ ids: z.boolean().optional().describe("Also list every entry ID (type:value).") },
|
|
4009
4009
|
async ({ ids }) => {
|
|
4010
4010
|
const info = libraryInfo(FH_LIBRARY_DIR, HOST, ids === true);
|
|
@@ -4018,7 +4018,7 @@ function registerTools(server2) {
|
|
|
4018
4018
|
info.verify = { ...info.verify, ok: false, ms: Date.now() - t0, error: e instanceof Error ? e.message : String(e) };
|
|
4019
4019
|
}
|
|
4020
4020
|
}
|
|
4021
|
-
log("library_info", null, `entries=${
|
|
4021
|
+
log("library_info", null, `entries=${info.entries}${info.verify ? ` verify ${info.verify.model} ${info.verify.ok ? `ok ${info.verify.ms} ms` : `down: ${info.verify.error}`}` : " verify not configured"}`);
|
|
4022
4022
|
return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
|
|
4023
4023
|
}
|
|
4024
4024
|
);
|
|
@@ -4027,7 +4027,7 @@ function registerTools(server2) {
|
|
|
4027
4027
|
"Get the running fh-library MCP server's package name and version, and the resolved library directory. Useful to confirm which build is loaded.",
|
|
4028
4028
|
{},
|
|
4029
4029
|
async () => {
|
|
4030
|
-
log("library_version", null, `${pkg.name} ${pkg.
|
|
4030
|
+
log("library_version", null, `${pkg.name} ${pkg.version}`);
|
|
4031
4031
|
return { content: [{ type: "text", text: `${pkg.name} v${pkg.version} \xB7 host: ${HOST} \xB7 library_dir: ${FH_LIBRARY_DIR} \xB7 log: ${LOG_FILE}` }] };
|
|
4032
4032
|
}
|
|
4033
4033
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fh-library",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"mcpName": "io.github.forever-healthy/fh-library",
|
|
5
5
|
"description": "MCP Server that builds a local Markdown library of the scientific papers cited by evidence reviews — fetches metadata, abstract and full text per PubMed ID through a configurable cascade of retrieval services",
|
|
6
6
|
"type": "module",
|