ai4l-library 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,13 @@
1
+ # AI4L Library - Change Log
2
+
3
+
4
+ ### v0.1.1 — 2026-10-26
5
+
6
+ * `library_add(id)` and `library_status(id)` for `pmid:` IDs: the `pubmed` tier fetches the efetch XML and writes `-abstract.xml`, `-abstract.md` and `-meta.md` under `<type>/<L1>/<L2>/<number>/`; entries are level `abstract` and idempotent
7
+ * Status JSON per call; invalid IDs, unsupported types, unknown PMIDs and fetch failures are returned as `error`, never thrown
8
+ * Library directory: `LIBRARY_DIR`, else `/library` when mounted, else `./library`
9
+ * Unit tests (`npm test`) and an opt-in live PubMed test (`LIVE=1`)
10
+
11
+ ### v0.0.1 — 2026-10-26
12
+
13
+ * Project skeleton: stdio MCP server with `get_version`; publishing scaffold (dev/public npm, MCP registry, GitHub release)
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forever Healthy Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including, without limitation, the rights to use, copy, modify, merge, publish, distribute, sub-license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ ![Version](https://img.shields.io/badge/Version-0.1.1-green.svg)
2
+ [![Forever Healthy](https://img.shields.io/badge/(c)_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
3
+ [![npm](https://img.shields.io/badge/npm-ai4l--library-cb3837.svg)](https://www.npmjs.com/package/ai4l-library)
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # AI4L Library MCP Server
6
+
7
+ 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.
8
+
9
+ **Status: v0.1, early development.** `library_add` and `library_status` work for `pmid:` IDs through the PubMed tier only, so every entry is level `abstract`. The full-text cascade and `library_read` are next.
10
+
11
+
12
+ ## Tools
13
+
14
+ * `library_add(id, url?)` → build or complete the library entry for one ID; returns its status (level `full_text` / `abstract`, retrieving tier, retraction flag, files). Idempotent. One ID per call.
15
+ * `library_status(id)` → read-only check of presence and level
16
+ * `library_read(id, section | query)` → one passage of a stored paper's Markdown — the deliberate, small flow through context for claim verification (planned)
17
+
18
+ IDs follow the `type:value` form (`pmid:`, `pmc:`, `doi:`, `nct:`); bare digits count as `pmid:`. v0.1 supports `pmid:` only.
19
+ * `get_version()` → the running server's package name, version and library directory
20
+
21
+
22
+ ## Install
23
+
24
+ The server is published to npm as **[`ai4l-library`](https://www.npmjs.com/package/ai4l-library)** and runs over stdio via `npx` — no global install needed (requires Node.js ≥ 18):
25
+
26
+ ```
27
+ npx -y ai4l-library
28
+ ```
29
+
30
+ Client config (`.mcp.json` for Claude Code, `claude_desktop_config.json` for Claude Desktop, or your client's MCP settings):
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "d-library": {
36
+ "command": "npx",
37
+ "args": ["-y", "ai4l-library"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Planned: a Docker image with the converters and a headless browser built in, the library mounted as a volume —
44
+ `docker run --rm -i --env-file .env -v "$PWD/library:/library" ghcr.io/forever-healthy/ai4l-library`
45
+
46
+
47
+ ## License
48
+
49
+ MIT — © 2026 Forever Healthy Foundation
package/dist/index.js ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/tools.ts
8
+ import { readFileSync as readFileSync2 } from "fs";
9
+ import { z } from "zod";
10
+
11
+ // src/entry.ts
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
+ import { basename } from "path";
14
+ function emptyStatus(requested, type, id, libraryDir) {
15
+ return {
16
+ requested,
17
+ type,
18
+ id,
19
+ entry: null,
20
+ entry_dir: null,
21
+ library_dir: libraryDir,
22
+ pmid: null,
23
+ pmcid: null,
24
+ doi: null,
25
+ title: null,
26
+ first_author: null,
27
+ year: null,
28
+ publication_types: [],
29
+ retracted: false,
30
+ level: null,
31
+ version: null,
32
+ retrieved_by: null,
33
+ retrieved_date: null,
34
+ attempts: [],
35
+ files: []
36
+ };
37
+ }
38
+ function renderAbstractMd(r) {
39
+ const line = [
40
+ r.authors.join(", ") || null,
41
+ r.journal,
42
+ r.year,
43
+ `PMID ${r.pmid}`,
44
+ r.doi ? `DOI ${r.doi}` : null,
45
+ r.pmcid ? `PMCID ${r.pmcid}` : null
46
+ ].filter(Boolean).join(" \xB7 ");
47
+ const body = r.abstract.length ? r.abstract.map((a) => a.label ? `**${a.label}.** ${a.text}` : a.text).join("\n\n") : "_No abstract available._";
48
+ return `# ${r.title}
49
+
50
+ ${line}
51
+
52
+ ## Abstract
53
+
54
+ ${body}
55
+ `;
56
+ }
57
+ function renderMeta(r, files, retrievedDate, converter) {
58
+ const lines = [
59
+ ["pmid", r.pmid],
60
+ ["pmcid", r.pmcid ?? ""],
61
+ ["doi", r.doi ?? ""],
62
+ ["title", JSON.stringify(r.title)],
63
+ ["first_author", r.firstAuthor ?? ""],
64
+ ["year", r.year ?? ""],
65
+ ["publication_types", r.publicationTypes.join(", ")],
66
+ ["retracted", r.publicationTypes.includes("Retracted Publication") ? "yes" : "no"],
67
+ ["level", "abstract"],
68
+ ["version", ""],
69
+ ["files", files.join(", ")],
70
+ ["retrieved_by", "pubmed"],
71
+ ["retrieved_date", retrievedDate],
72
+ ["converted_by", converter],
73
+ ["attempts", ""]
74
+ ];
75
+ return "---\n" + lines.map(([k, v]) => v === "" ? `${k}:` : `${k}: ${v}`).join("\n") + "\n---\n";
76
+ }
77
+ function entryExists(p) {
78
+ return existsSync(p.meta);
79
+ }
80
+ function writeEntry(p, r, xml, converter) {
81
+ mkdirSync(p.entryDir, { recursive: true });
82
+ const files = [basename(p.xml), basename(p.md)];
83
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
84
+ writeFileSync(p.xml, xml);
85
+ writeFileSync(p.md, renderAbstractMd(r));
86
+ writeFileSync(p.meta, renderMeta(r, files, today, converter));
87
+ }
88
+ function parseFrontmatter(md) {
89
+ const m = /^---\n([\s\S]*?)\n---/.exec(md);
90
+ const out = {};
91
+ for (const line of (m?.[1] ?? "").split("\n")) {
92
+ const i = line.indexOf(":");
93
+ if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
94
+ }
95
+ return out;
96
+ }
97
+ var list = (v) => v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
98
+ var opt = (v) => v ? v : null;
99
+ function readStatus(p, base) {
100
+ const fm = parseFrontmatter(readFileSync(p.meta, "utf8"));
101
+ let title = opt(fm.title);
102
+ if (title?.startsWith('"')) {
103
+ try {
104
+ title = JSON.parse(title);
105
+ } catch {
106
+ }
107
+ }
108
+ return {
109
+ ...base,
110
+ entry: p.entry,
111
+ entry_dir: p.entryDir,
112
+ pmid: opt(fm.pmid),
113
+ pmcid: opt(fm.pmcid),
114
+ doi: opt(fm.doi),
115
+ title,
116
+ first_author: opt(fm.first_author),
117
+ year: opt(fm.year),
118
+ publication_types: list(fm.publication_types),
119
+ retracted: fm.retracted === "yes",
120
+ level: opt(fm.level) ?? null,
121
+ version: opt(fm.version),
122
+ retrieved_by: opt(fm.retrieved_by),
123
+ retrieved_date: opt(fm.retrieved_date),
124
+ attempts: list(fm.attempts),
125
+ files: list(fm.files)
126
+ };
127
+ }
128
+
129
+ // src/ids.ts
130
+ var SUPPORTED = /* @__PURE__ */ new Set(["pmid"]);
131
+ var KNOWN = /* @__PURE__ */ new Set(["pmid", "pmc", "doi", "nct"]);
132
+ function parseId(raw) {
133
+ const requested = String(raw ?? "").trim();
134
+ const bare = /^\d+$/.test(requested);
135
+ const m = bare ? null : /^([A-Za-z][A-Za-z0-9_-]*):(.+)$/.exec(requested);
136
+ if (!bare && !m) return { requested, type: null, id: null, error: "invalid id" };
137
+ const type = bare ? "pmid" : m[1].toLowerCase();
138
+ const value = bare ? requested : m[2].trim();
139
+ if (!KNOWN.has(type)) return { requested, type, id: value, error: "invalid id" };
140
+ if (!SUPPORTED.has(type)) return { requested, type, id: value, error: "type not supported yet" };
141
+ if (!/^[1-9]\d*$/.test(value)) return { requested, type, id: value, error: "invalid id" };
142
+ return { requested, type, id: value };
143
+ }
144
+
145
+ // src/layout.ts
146
+ import { existsSync as existsSync2 } from "fs";
147
+ import { join, resolve } from "path";
148
+ var LIBRARY_DIR = (() => {
149
+ const env = process.env.LIBRARY_DIR;
150
+ if (env && env.trim()) return resolve(env.trim());
151
+ if (existsSync2("/library")) return "/library";
152
+ return resolve("library");
153
+ })();
154
+ function entryDirRel(type, num) {
155
+ const l1 = num.length > 6 ? num.slice(0, -6) : "0";
156
+ const l2 = num.length > 4 ? num.slice(0, -4) : "0";
157
+ return `${type}/${l1}/${l2}/${num}`;
158
+ }
159
+ function entryPaths(libraryDir, type, num) {
160
+ const entry = `${type}-${num}`;
161
+ const entryDir = join(libraryDir, entryDirRel(type, num));
162
+ return {
163
+ entry,
164
+ entryDir,
165
+ xml: join(entryDir, `${entry}-abstract.xml`),
166
+ md: join(entryDir, `${entry}-abstract.md`),
167
+ meta: join(entryDir, `${entry}-meta.md`)
168
+ };
169
+ }
170
+
171
+ // src/pubmed.ts
172
+ import { XMLParser } from "fast-xml-parser";
173
+ var EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi";
174
+ var TIMEOUT_MS = 3e4;
175
+ function efetchUrl(pmid) {
176
+ const u = new URL(EFETCH);
177
+ u.searchParams.set("db", "pubmed");
178
+ u.searchParams.set("id", pmid);
179
+ u.searchParams.set("retmode", "xml");
180
+ if (process.env.NCBI_API_KEY) u.searchParams.set("api_key", process.env.NCBI_API_KEY);
181
+ if (process.env.CONTACT_EMAIL) {
182
+ u.searchParams.set("tool", "ai4l-library");
183
+ u.searchParams.set("email", process.env.CONTACT_EMAIL);
184
+ }
185
+ return u.toString();
186
+ }
187
+ async function fetchPubmedXml(pmid) {
188
+ const url = efetchUrl(pmid);
189
+ let last;
190
+ for (let attempt = 0; attempt < 2; attempt++) {
191
+ try {
192
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
193
+ if (res.ok) return await res.text();
194
+ last = new Error(`HTTP ${res.status}`);
195
+ if (res.status !== 429 && res.status < 500) break;
196
+ } catch (e) {
197
+ last = e;
198
+ }
199
+ }
200
+ const msg = last instanceof Error ? last.message : String(last);
201
+ throw new Error(`pubmed fetch failed: ${msg}`);
202
+ }
203
+ var parser = new XMLParser({
204
+ ignoreAttributes: false,
205
+ attributeNamePrefix: "@_",
206
+ parseTagValue: false,
207
+ processEntities: true,
208
+ stopNodes: ["*.ArticleTitle", "*.AbstractText"],
209
+ isArray: (name) => ["PubmedArticle", "AbstractText", "Author", "ArticleId", "PublicationType"].includes(name)
210
+ });
211
+ function text(n) {
212
+ if (n == null) return "";
213
+ if (typeof n === "object") return text(n["#text"]);
214
+ return String(n);
215
+ }
216
+ function inlineText(raw) {
217
+ return decodeEntities(raw.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
218
+ }
219
+ function decodeEntities(s) {
220
+ return s.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
221
+ }
222
+ function parsePubmedXml(xml) {
223
+ const doc = parser.parse(xml);
224
+ const article = doc?.PubmedArticleSet?.PubmedArticle?.[0];
225
+ if (!article) return null;
226
+ const cit = article.MedlineCitation ?? {};
227
+ const art = cit.Article ?? {};
228
+ const ids = article.PubmedData?.ArticleIdList?.ArticleId ?? [];
229
+ const idOf = (t) => {
230
+ const hit = ids.find((i) => i["@_IdType"] === t);
231
+ return hit ? text(hit).trim() || null : null;
232
+ };
233
+ const authorList = art.AuthorList?.Author ?? [];
234
+ const authors = authorList.map((a) => {
235
+ if (a.CollectiveName) return text(a.CollectiveName).trim();
236
+ const last = text(a.LastName).trim();
237
+ const ini = text(a.Initials).trim();
238
+ return [last, ini].filter(Boolean).join(" ");
239
+ }).filter(Boolean);
240
+ const first = authorList[0];
241
+ const firstAuthor = first ? text(first.CollectiveName ?? first.LastName).trim() || null : null;
242
+ const pub = art.Journal?.JournalIssue?.PubDate ?? {};
243
+ let year = text(pub.Year).trim() || null;
244
+ if (!year) year = /\d{4}/.exec(text(pub.MedlineDate))?.[0] ?? null;
245
+ const abstract = (art.Abstract?.AbstractText ?? []).map((a) => ({
246
+ label: typeof a === "object" ? a["@_Label"] ?? null : null,
247
+ text: inlineText(text(a))
248
+ })).filter((a) => a.text);
249
+ return {
250
+ pmid: text(cit.PMID).trim() || idOf("pubmed") || "",
251
+ pmcid: idOf("pmc"),
252
+ doi: idOf("doi"),
253
+ title: inlineText(text(art.ArticleTitle)),
254
+ authors,
255
+ firstAuthor,
256
+ journal: text(art.Journal?.Title).trim() || null,
257
+ year,
258
+ publicationTypes: (art.PublicationTypeList?.PublicationType ?? []).map((p) => text(p).trim()).filter(Boolean),
259
+ abstract
260
+ };
261
+ }
262
+
263
+ // src/tools.ts
264
+ var pkg = (() => {
265
+ try {
266
+ return JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
267
+ } catch {
268
+ return { name: "ai4l-library", version: "0.0.0" };
269
+ }
270
+ })();
271
+ var INSTRUCTIONS = `ai4l-library builds a local library of the scientific papers cited by evidence reviews. Given a paper ID ("type:value", e.g. pmid:30955454; bare digits count as pmid) it fetches the paper's record and stores it as files under the library directory. Nothing flows through your context: the server writes to disk itself and returns only a small JSON status per call. One ID per call \u2014 no batches.
272
+
273
+ Tools:
274
+ - library_add(id, url?) \u2014 build the entry for one ID and return its status. Idempotent: an existing entry is returned without any network call.
275
+ - library_status(id) \u2014 read-only: the entry's status, or level "missing".
276
+ - get_version() \u2014 package name, version and the resolved library directory.
277
+
278
+ Status JSON fields: requested, type, id, entry, entry_dir, library_dir, pmid, pmcid, doi, title, first_author, year, publication_types, retracted, level, version, retrieved_by, retrieved_date, attempts, files, and error when the call could not produce an entry ("invalid id", "type not supported yet", "pmid not found", or a fetch failure).
279
+
280
+ v0.1: pmid: IDs only (other types return error "type not supported yet"); the pubmed tier only, so every entry is level "abstract" (efetch XML, abstract Markdown, meta); url is accepted but ignored.`;
281
+ var idSchema = z.string().describe('Paper ID as "type:value", e.g. "pmid:30955454"; bare digits count as pmid.');
282
+ var json = (s) => ({ content: [{ type: "text", text: JSON.stringify(s, null, 2) }] });
283
+ function registerTools(server2) {
284
+ server2.tool(
285
+ "library_add",
286
+ "Build the library entry for one paper ID and return its status JSON. v0.1: pmid: only, pubmed tier only (level abstract). Idempotent \u2014 an existing entry is returned without refetching.",
287
+ { id: idSchema, url: z.string().optional().describe("A download location obtained elsewhere (ignored in v0.1).") },
288
+ async ({ id }) => {
289
+ const parsed = parseId(id);
290
+ const status = emptyStatus(parsed.requested, parsed.type, parsed.id, LIBRARY_DIR);
291
+ if (parsed.error) return json({ ...status, error: parsed.error });
292
+ const paths = entryPaths(LIBRARY_DIR, parsed.type, parsed.id);
293
+ if (entryExists(paths)) return json(readStatus(paths, status));
294
+ let xml;
295
+ try {
296
+ xml = await fetchPubmedXml(parsed.id);
297
+ } catch (e) {
298
+ return json({ ...status, error: e instanceof Error ? e.message : String(e) });
299
+ }
300
+ const record = parsePubmedXml(xml);
301
+ if (!record) return json({ ...status, error: "pmid not found" });
302
+ writeEntry(paths, record, xml, `${pkg.name} ${pkg.version}`);
303
+ return json(readStatus(paths, status));
304
+ }
305
+ );
306
+ server2.tool(
307
+ "library_status",
308
+ 'Read-only status of the library entry for one paper ID: the same status JSON as library_add, or level "missing" when there is no entry. Never fetches.',
309
+ { id: idSchema },
310
+ async ({ id }) => {
311
+ const parsed = parseId(id);
312
+ const status = emptyStatus(parsed.requested, parsed.type, parsed.id, LIBRARY_DIR);
313
+ if (parsed.error) return json({ ...status, error: parsed.error });
314
+ const paths = entryPaths(LIBRARY_DIR, parsed.type, parsed.id);
315
+ if (entryExists(paths)) return json(readStatus(paths, status));
316
+ return json({ ...status, entry: paths.entry, entry_dir: paths.entryDir, level: "missing" });
317
+ }
318
+ );
319
+ server2.tool(
320
+ "get_version",
321
+ "Get the running ai4l-library MCP server's package name and version, and the resolved library directory. Useful to confirm which build is loaded.",
322
+ {},
323
+ async () => ({ content: [{ type: "text", text: `${pkg.name} v${pkg.version} \xB7 library_dir: ${LIBRARY_DIR}` }] })
324
+ );
325
+ }
326
+
327
+ // src/index.ts
328
+ var server = new McpServer(
329
+ { name: pkg.name, version: pkg.version },
330
+ { instructions: INSTRUCTIONS }
331
+ );
332
+ registerTools(server);
333
+ var transport = new StdioServerTransport();
334
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "ai4l-library",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "mcpName": "io.github.forever-healthy/ai4l-library",
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
+ "type": "module",
7
+ "bin": {
8
+ "ai4l-library": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "CHANGELOG.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.0.0",
19
+ "fast-xml-parser": "^5.11.2",
20
+ "zod": "^3.0.0"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "ai4l",
25
+ "pubmed",
26
+ "papers",
27
+ "library",
28
+ "evidence-review"
29
+ ],
30
+ "author": "Forever Healthy Foundation",
31
+ "license": "MIT",
32
+ "homepage": "https://github.com/forever-healthy/AI4L-library",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/forever-healthy/AI4L-library.git"
36
+ },
37
+ "private": false
38
+ }