@cyanheads/pubmed-mcp-server 2.10.9 → 2.10.11

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 (83) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +13 -5
  4. package/dist/mcp-server/tools/definitions/_schemas.d.ts +16 -0
  5. package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
  6. package/dist/mcp-server/tools/definitions/_schemas.js +20 -0
  7. package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +6 -0
  9. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/convert-ids.tool.js +28 -3
  11. package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +27 -0
  13. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +166 -24
  15. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +18 -0
  17. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +307 -68
  19. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +12 -0
  21. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/find-related.tool.js +171 -27
  23. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -13
  26. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +42 -8
  29. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +6 -0
  31. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +14 -3
  33. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +10 -0
  35. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/search-articles.tool.js +52 -5
  37. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +6 -0
  39. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/spell-check.tool.js +15 -3
  41. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  42. package/dist/services/error-contracts.d.ts +39 -2
  43. package/dist/services/error-contracts.d.ts.map +1 -1
  44. package/dist/services/error-contracts.js +43 -2
  45. package/dist/services/error-contracts.js.map +1 -1
  46. package/dist/services/ncbi/formatting/citation-formatter.d.ts +0 -29
  47. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  48. package/dist/services/ncbi/formatting/citation-formatter.js +381 -30
  49. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  50. package/dist/services/ncbi/ncbi-service.d.ts +3 -2
  51. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  52. package/dist/services/ncbi/ncbi-service.js +22 -10
  53. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  54. package/dist/services/ncbi/parsing/article-parser.d.ts +45 -2
  55. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  56. package/dist/services/ncbi/parsing/article-parser.js +208 -1
  57. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  58. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  59. package/dist/services/ncbi/parsing/esummary-parser.js +32 -1
  60. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  61. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +24 -4
  62. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  63. package/dist/services/ncbi/parsing/pmc-article-parser.js +505 -57
  64. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  65. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  66. package/dist/services/ncbi/response-handler.js +42 -1
  67. package/dist/services/ncbi/response-handler.js.map +1 -1
  68. package/dist/services/ncbi/types.d.ts +215 -2
  69. package/dist/services/ncbi/types.d.ts.map +1 -1
  70. package/dist/services/openalex/api-client.d.ts +13 -4
  71. package/dist/services/openalex/api-client.d.ts.map +1 -1
  72. package/dist/services/openalex/api-client.js +19 -8
  73. package/dist/services/openalex/api-client.js.map +1 -1
  74. package/dist/services/openalex/openalex-service.d.ts +34 -17
  75. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  76. package/dist/services/openalex/openalex-service.js +119 -35
  77. package/dist/services/openalex/openalex-service.js.map +1 -1
  78. package/dist/services/openalex/types.d.ts +34 -1
  79. package/dist/services/openalex/types.d.ts.map +1 -1
  80. package/dist/services/openalex/types.js +20 -0
  81. package/dist/services/openalex/types.js.map +1 -1
  82. package/package.json +1 -1
  83. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** @cyanheads/pubmed-mcp-server
4
- **Version:** 2.10.9
4
+ **Version:** 2.10.11
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.8`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
7
 
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** @cyanheads/pubmed-mcp-server
4
- **Version:** 2.10.9
4
+ **Version:** 2.10.11
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.12.8`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
7
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
 
11
11
 
12
- [![Version](https://img.shields.io/badge/Version-2.10.9-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
12
+ [![Version](https://img.shields.io/badge/Version-2.10.11-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
13
13
 
14
14
  </div>
15
15
 
@@ -59,6 +59,7 @@ Search PubMed with full NCBI query syntax and filters.
59
59
  - Sort by relevance, publication date, author, or journal
60
60
  - Pagination via offset for paging through large result sets
61
61
  - Optional brief summaries for top N results via ESummary
62
+ - NCBI Bookshelf results carry their own venue — `bookTitle`, `publisherName`, and `docType` (`chapter`, `book`, or `citation`) — because PubMed leaves `source` empty on them; the book's editors are reported in `editors`, apart from the chapter's own authors
62
63
  - Returns the original query plus the fully applied PubMed query and normalized filter metadata
63
64
 
64
65
  ---
@@ -72,6 +73,8 @@ Fetch full article metadata by PubMed IDs.
72
73
  - Direct links to PubMed and PubMed Central (when available)
73
74
  - Optional MeSH terms, grant information, and publication types
74
75
  - Handles PubMed's inconsistent XML (structured abstracts, missing fields, varying date formats)
76
+ - NCBI Bookshelf chapters and whole books are returned as first-class records, not reported unavailable: `recordType` (`journal-article`, `book-chapter`, `book`) tells them apart, and a `book` object carries the book title, publisher, place, dates, medium, edition, series, ISBNs, book DOI, editors, and Bookshelf accession. `journalInfo` is absent on those records — a book title is never reported as a journal
77
+ - Journals that assign article numbers instead of page ranges often carry no pagination at all; the number is reported as `journalInfo.elocationId` with its `journalInfo.elocationIdType` (`pii`), never merged into `journalInfo.pages` and never confused with the DOI
75
78
  - Opt-in whole-response ceiling: `maxResponseCharacters` keeps complete article records in response order until the next one would cross it, then defers the rest whole and lists their PMIDs in `deferred.ids`. Re-call with those PMIDs to resume exactly where the response stopped — no article is split, skipped, or duplicated. Each article is measured as the JSON record it is returned as, so a ceiling under the first article returns zero articles, the full deferred list, and the size to clear
76
79
 
77
80
  ---
@@ -80,17 +83,18 @@ Fetch full article metadata by PubMed IDs.
80
83
 
81
84
  Fetch full-text articles via a three-stage chain: NCBI PMC EFetch → Europe PMC `fullTextXML` → Unpaywall.
82
85
 
83
- - Accepts exactly one of `pmcids` (direct PMC IDs), `pmids` (PubMed IDs, auto-resolved), or `dois` (auto-resolved to PMC via the ID Converter; preprints and EPMC-only OA fall through to Europe PMC / Unpaywall)
86
+ - Accepts exactly one of `pmcids` (direct PMC IDs), `pmids` (PubMed IDs, auto-resolved), or `dois` (auto-resolved to PMC via the ID Converter; preprints and EPMC-only OA fall through to Europe PMC / Unpaywall). One identifier per element in every branch — a DOI carrying a comma or whitespace is rejected at the schema
84
87
  - NCBI PMC and Europe PMC both return structured JATS; output records origin via `viaSource: "pmc" | "europepmc" | "unpaywall"`
85
88
  - Europe PMC layer (enabled by default; disable with `EUROPEPMC_ENABLED=false`) recovers PMC-counterpart records that NCBI PMC EFetch missed, and resolves DOI input to PMC counterparts when one exists. EPMC's `fullTextXML` is PMC-keyed, so preprints (PPR), patents (PAT), and Agricola (AGR) are reachable via `pubmed_europepmc_search` for metadata but have no full text via this chain.
86
89
  - Unpaywall layer (enabled by setting `UNPAYWALL_EMAIL`) resolves DOIs to legal OA copies; extracts HTML landing pages to Markdown via Defuddle or PDFs to text via unpdf
87
90
  - Discriminated output contract — `source: "pmc"` (structured sections, regardless of whether it came from PMC or EPMC) or `source: "unpaywall"` (best-effort body + `contentFormat`: `html-markdown` or `pdf-text`)
88
- - Structured unavailable reasons (`not-found`, `no-pmc-fallback-disabled`, `no-epmc-fulltext`, `no-body`, `no-doi`, `no-oa`, `fetch-failed`, `parse-failed`, `service-error`) so callers can retry or explain to users without parsing text
91
+ - Structured unavailable reasons (`not-found`, `no-pmc-fallback-disabled`, `no-epmc-fulltext`, `no-body`, `no-doi`, `doi-lookup-failed`, `no-oa`, `fetch-failed`, `parse-failed`, `service-error`) so callers can retry or explain to users without parsing text. `no-doi` and `doi-lookup-failed` are the settled and unsettled halves of the same gap: the first means the DOI lookup ran and the record has none, the second that the lookup itself errored, so a DOI may well exist and the request is worth retrying
89
92
  - An `unavailable` entry also carries `unqueriedTiers` when the chain skipped a tier this deployment has not configured and that tier could have served the id — the search was incomplete, and a deployment with those tiers configured may still resolve it
90
93
  - Each `unavailable` entry carries `idType` (`pmid` / `pmcid` / `doi`) and `triedTiers` — per-tier outcomes (`not-attempted`, `miss`, `no-fulltext`, `service-error`, …) in execution order, so callers can see which stage failed and why
91
94
  - Section filtering by title (case-insensitive substring match at any nesting depth, e.g. `["methods", "results"]`) and configurable max sections apply to PMC output. A section that matches directly is returned whole; one kept only because a nested subsection matched keeps its heading as a breadcrumb with its own text cleared
92
95
  - Tables are returned as structured cells (`tables[]` on each PMC article — rows, caption, label, footnotes, and the enclosing section, named for back-matter and appendix tables as well as body ones), covering `<floats-group>`, `<back>` and appendix deposits alongside body tables. `colspan` and `rowspan` are expanded to one entry per grid column, so a value stays under the header it belongs to on both output surfaces; a cell spanning several columns or rows repeats across the cells it covers. A deposit with no readable markup comes back labelled with an `unextractableReason` rather than silently missing. Turn them off with `includeTables: false`
93
- - Character budgets keep context size predictable: `maxCharacters` caps body text per article (PMC sections, subsections and table content — cell, caption, label and footnote text, not the Markdown grid rendered around it or the Unpaywall body), `maxCharactersPerSection` caps a single PMC section, and `overflowMode` picks between `truncate` (fill sections in document order) and `outline` (split the budget evenly so every heading survives with an excerpt). Sections are served first and tables spend what is left, in document order until one does not fit; that table and the rest are dropped whole rather than cut mid-row, and named in `truncation.articles[].omittedTableNames`. Budgets run after the semantic filters, and a `truncation` object reports per-article and per-section character counts whenever anything was shortened
96
+ - Figures and supplementary material come back as structured entries (`assets[]` on each PMC article `assetType`, label, caption, the enclosing section, and the `<graphic>`/`<media>` pointer exactly as deposited, which is a name inside the PMC deposit rather than a fetchable URL), covering `<floats-group>`, `<back>` and appendix placements alongside body ones. Each one lifted out of the body leaves a `[Figure: <label>]` / `[Supplementary: <label>]` marker at its position, so reading order survives the lift. Turn them off with `includeAssets: false`, which removes the markers with them. Prose-shaped blocks lists, definition lists, block quotes, boxed text, preformatted blocks, displayed formulae render into the section text at their document position instead, and no block is ever concatenated into a neighbouring sentence
97
+ - Character budgets keep context size predictable: `maxCharacters` caps body text per article (PMC sections and subsections, inline blocks included, plus table content — cell, caption, label and footnote text, not the Markdown grid rendered around it — and asset label, caption and pointer text; or the Unpaywall body), `maxCharactersPerSection` caps a single PMC section, and `overflowMode` picks between `truncate` (fill sections in document order) and `outline` (split the budget evenly so every heading survives with an excerpt). Sections are served first, then tables, then assets, each spending what is left in document order until one does not fit; that entry and the rest are dropped whole rather than cut mid-row or returned with a shortened caption, and named in `truncation.articles[].omittedTableNames` / `omittedAssetNames`. Budgets run after the semantic filters, and a `truncation` object reports per-article and per-section character counts whenever anything was shortened
94
98
  - `maxResponseCharacters` bounds the whole response instead of each body: every field of a returned record counts (abstract, references, metadata, body), one ledger across PMC-, Europe PMC-, and Unpaywall-served articles. Articles past the ceiling are deferred whole, with their ids — in the branch they were requested under — in `deferred.ids` for a follow-up call
95
99
  - Up to 10 articles per request
96
100
 
@@ -126,6 +130,8 @@ Fetch complete Europe PMC records by `source` + `epmcId`, the detail counterpart
126
130
  Generate formatted citations for articles.
127
131
 
128
132
  - Five citation styles: APA 7th, MLA 9th, BibTeX, RIS, Vancouver (ICMJE/NLM)
133
+ - NCBI Bookshelf chapters and whole books cite in their own form in every style — Vancouver's `In: … editors` contribution pattern, APA's chapter-in-edited-book, MLA's `edited by`, BibTeX `@incollection` / `@book`, RIS `CHAP` / `BOOK` — carrying the book title, editors, publisher, place, ISBNs and Bookshelf URL
134
+ - An article with no page range cites by its electronic article locator in each style's own convention — Vancouver's trailing `pii:` note, APA's `Article <n>`, MLA's `art. <n>`, biblatex `eid`, RIS `C7` — rather than dropping it or writing it into a page field
129
135
  - Request multiple styles per article in a single call
130
136
  - Hand-rolled formatters — zero external dependencies, fully Workers-compatible
131
137
  - Up to 50 articles per request
@@ -138,7 +144,7 @@ Generate formatted citations for articles.
138
144
  Find articles related to a source article via ELink.
139
145
 
140
146
  - Three relationship types: `similar` (content similarity), `cited_by`, `references`
141
- - Results enriched with title, authors, publication date, and source via ESummary
147
+ - Results enriched with title, authors, publication date, and source via ESummary — or, for an NCBI Bookshelf record, its book title, publisher, and doc type in place of the empty source
142
148
  - Results returned in NCBI's relevance order
143
149
  - Falls back to Europe PMC, then OpenAlex, when NCBI cannot answer; the response names which provider served it. A request no provider can answer fails with a typed `all_providers_failed` error instead of an empty result
144
150
 
@@ -169,6 +175,7 @@ Resolve partial bibliographic references to PubMed IDs via NCBI ECitMatch.
169
175
 
170
176
  - Match citations by journal, year, volume, first page, and/or author name
171
177
  - More fields = better match accuracy; at least one field required
178
+ - Bibliographic fields cannot contain a pipe (`|`) or a line break — ECitMatch's wire format is pipe-delimited, so those characters are rejected at the schema; the free-form `key` label is exempt
172
179
  - Batch up to 25 citations per request
173
180
  - Deterministic matching — more reliable than free-text search for known references
174
181
  - Returns explicit `matched`, `not_found`, and `ambiguous` statuses with recovery detail
@@ -181,6 +188,7 @@ Convert between article identifiers (DOI, PMID, PMCID) using the PMC ID Converte
181
188
 
182
189
  - Batch up to 50 IDs per request
183
190
  - Accepts DOIs, PMIDs, or PMCIDs (all IDs must be the same type)
191
+ - One identifier per array element, checked against `idType` before the request — a packed value like `"23193287,37952131"` is rejected rather than expanded into extra records, since a comma is the converter's list delimiter in any encoding
184
192
  - Only resolves articles indexed in PubMed Central
185
193
  - Per-ID success/error reporting — partial batches return resolved mappings alongside structured errors for unresolvable IDs, not a batch-level failure
186
194
 
@@ -11,4 +11,20 @@ import { z } from '@cyanheads/mcp-ts-core';
11
11
  * stray prefixes like "PMID:").
12
12
  */
13
13
  export declare const pmidStringSchema: z.ZodString;
14
+ /**
15
+ * Zod string schema for a single PMC ID. Digits, with the "PMC" prefix optional
16
+ * and case-insensitive — both forms are accepted upstream.
17
+ */
18
+ export declare const pmcidStringSchema: z.ZodString;
19
+ /**
20
+ * Zod string schema for a single DOI: the "10." directory indicator, a
21
+ * registrant prefix, "/", and a suffix.
22
+ *
23
+ * Every DOI character is allowed except whitespace and the comma, so the
24
+ * suffix punctuation a real DOI carries — parens, colons, angle brackets,
25
+ * semicolons, further slashes — passes through. The comma is excluded because
26
+ * it is the PMC ID Converter's list delimiter in any encoding, so an element
27
+ * carrying one is read upstream as two identifiers rather than one DOI.
28
+ */
29
+ export declare const doiStringSchema: z.ZodString;
14
30
  //# sourceMappingURL=_schemas.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"_schemas.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_schemas.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE3C;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,aAK1B,CAAC"}
1
+ {"version":3,"file":"_schemas.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_schemas.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE3C;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,aAK1B,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,iBAAiB,aAK3B,CAAC;AAEJ;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,aAKzB,CAAC"}
@@ -13,4 +13,24 @@ import { z } from '@cyanheads/mcp-ts-core';
13
13
  export const pmidStringSchema = z
14
14
  .string()
15
15
  .regex(/^\d+$/, 'PMID must be a numeric identifier (e.g. "13054692"). Remove any whitespace, commas, or non-digit characters — provide each PMID separately.');
16
+ /**
17
+ * Zod string schema for a single PMC ID. Digits, with the "PMC" prefix optional
18
+ * and case-insensitive — both forms are accepted upstream.
19
+ */
20
+ export const pmcidStringSchema = z
21
+ .string()
22
+ .regex(/^(?:PMC)?\d+$/i, 'PMC ID must be digits, optionally prefixed with "PMC" (e.g. "PMC9575052" or "9575052"). Remove any whitespace or commas — provide each PMC ID separately.');
23
+ /**
24
+ * Zod string schema for a single DOI: the "10." directory indicator, a
25
+ * registrant prefix, "/", and a suffix.
26
+ *
27
+ * Every DOI character is allowed except whitespace and the comma, so the
28
+ * suffix punctuation a real DOI carries — parens, colons, angle brackets,
29
+ * semicolons, further slashes — passes through. The comma is excluded because
30
+ * it is the PMC ID Converter's list delimiter in any encoding, so an element
31
+ * carrying one is read upstream as two identifiers rather than one DOI.
32
+ */
33
+ export const doiStringSchema = z
34
+ .string()
35
+ .regex(/^10\.[^\s,]+\/[^\s,]+$/, 'DOI must start with "10." and contain a "/" (e.g. "10.1093/nar/gks1195"). Remove any whitespace or commas — provide each DOI separately.');
16
36
  //# sourceMappingURL=_schemas.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"_schemas.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_schemas.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC;KAC9B,MAAM,EAAE;KACR,KAAK,CACJ,OAAO,EACP,6IAA6I,CAC9I,CAAC"}
1
+ {"version":3,"file":"_schemas.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/_schemas.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC;KAC9B,MAAM,EAAE;KACR,KAAK,CACJ,OAAO,EACP,6IAA6I,CAC9I,CAAC;AAEJ;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC;KAC/B,MAAM,EAAE;KACR,KAAK,CACJ,gBAAgB,EAChB,2JAA2J,CAC5J,CAAC;AAEJ;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC;KAC7B,MAAM,EAAE;KACR,KAAK,CACJ,wBAAwB,EACxB,0IAA0I,CAC3I,CAAC"}
@@ -51,5 +51,11 @@ export declare const convertIdsTool: import("@cyanheads/mcp-ts-core").ToolDefini
51
51
  readonly when: 'NCBI returned a structured "not found" error for the requested ID(s).';
52
52
  readonly recovery: 'Verify the ID exists in PubMed; the resource was not found in NCBI and retrying will not help.';
53
53
  readonly retryable: false;
54
+ }, {
55
+ readonly reason: 'malformed_id';
56
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.ValidationError;
57
+ readonly when: 'An `ids` element does not match the declared `idType` — most often several identifiers packed into one element, which the comma-delimited upstream batch would split into extra records.';
58
+ readonly recovery: 'Submit one identifier per `ids` element, in the declared idType format; the same packed value will be rejected again.';
59
+ readonly retryable: false;
54
60
  }], undefined>;
55
61
  //# sourceMappingURL=convert-ids.tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"convert-ids.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/convert-ids.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAcjD,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA6GzB,CAAC"}
1
+ {"version":3,"file":"convert-ids.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/convert-ids.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAiCjD,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAyHzB,CAAC"}
@@ -4,9 +4,10 @@
4
4
  * @module src/mcp-server/tools/definitions/convert-ids.tool
5
5
  */
6
6
  import { tool, z } from '@cyanheads/mcp-ts-core';
7
- import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
7
+ import { NCBI_ID_INPUT_ERRORS, NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
8
8
  import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
9
9
  import { conceptMeta, EDAM_ACCESSION, EDAM_ID_MAPPING } from './_concepts.js';
10
+ import { doiStringSchema, pmcidStringSchema, pmidStringSchema } from './_schemas.js';
10
11
  /**
11
12
  * NCBI's PMC ID Converter returns this exact wording for any non-PMC ID — even
12
13
  * articles that exist in PubMed and have a recoverable DOI. Rewrite to point
@@ -14,18 +15,34 @@ import { conceptMeta, EDAM_ACCESSION, EDAM_ID_MAPPING } from './_concepts.js';
14
15
  */
15
16
  const PMC_NOT_FOUND_RE = /^identifier not found in pmc$/i;
16
17
  const PMC_NOT_FOUND_REWRITE = 'Not in PMC ID Converter. Article may still exist in PubMed — try pubmed_fetch_articles (PMID → DOI) or pubmed_search_articles.';
18
+ /**
19
+ * Per-`idType` element format, checked in the handler rather than the schema:
20
+ * a Zod `.regex()` on `ids` cannot branch on the sibling `idType` field.
21
+ *
22
+ * Every one of these rejects an element carrying a comma, which is what closes
23
+ * #120 — the converter reads a comma as its list delimiter, so a packed element
24
+ * comes back as several records and breaks the one-record-per-submitted-ID
25
+ * contract the counts are computed from.
26
+ */
27
+ const ID_ELEMENT_SCHEMAS = {
28
+ doi: doiStringSchema,
29
+ pmcid: pmcidStringSchema,
30
+ pmid: pmidStringSchema,
31
+ };
32
+ /** Cap the offending value echoed back so an oversized element can't bloat the error. */
33
+ const MAX_ECHOED_ID_LENGTH = 120;
17
34
  export const convertIdsTool = tool('pubmed_convert_ids', {
18
35
  description: `Convert between article identifiers (DOI, PMID, PMCID). Accepts up to 50 IDs of a single type per request. Only resolves articles indexed in PubMed Central — for articles not in PMC, use pubmed_search_articles instead.`,
19
36
  annotations: { readOnlyHint: true, openWorldHint: true },
20
37
  _meta: conceptMeta([EDAM_ID_MAPPING, EDAM_ACCESSION]),
21
38
  sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/convert-ids.tool.ts',
22
- errors: [...NCBI_SERVICE_ERRORS],
39
+ errors: [...NCBI_SERVICE_ERRORS, ...NCBI_ID_INPUT_ERRORS],
23
40
  input: z.object({
24
41
  ids: z
25
42
  .array(z.string().min(1))
26
43
  .min(1)
27
44
  .max(50)
28
- .describe('Article identifiers to convert. All IDs must be the same type. DOIs: "10.1093/nar/gks1195", PMIDs: "23193287", PMCIDs: "PMC3531190" (the "PMC" prefix is optionalbare digits like "3531190" are also accepted).'),
45
+ .describe('Article identifiers to convert one identifier per element, all of the same type. Each element is checked against `idType` before the request: `doi` starts with "10." and carries a "/" ("10.1093/nar/gks1195"); `pmid` is digits ("23193287"); `pmcid` is digits with an optional "PMC" prefix ("PMC3531190" or "3531190"). No element may contain a comma or whitespace a packed value like "23193287,37952131" is rejected, so split it across elements.'),
29
46
  idType: z
30
47
  .enum(['pmcid', 'pmid', 'doi'])
31
48
  .describe('The type of IDs being submitted. Required so the API can unambiguously resolve them.'),
@@ -59,6 +76,14 @@ export const convertIdsTool = tool('pubmed_convert_ids', {
59
76
  count: input.ids.length,
60
77
  idType: input.idType,
61
78
  });
79
+ const elementSchema = ID_ELEMENT_SCHEMAS[input.idType];
80
+ for (const id of input.ids) {
81
+ const parsed = elementSchema.safeParse(id);
82
+ if (parsed.success)
83
+ continue;
84
+ const shown = id.length > MAX_ECHOED_ID_LENGTH ? `${id.slice(0, MAX_ECHOED_ID_LENGTH)}…` : id;
85
+ throw ctx.fail('malformed_id', `Invalid ${input.idType} element "${shown}". ${parsed.error.issues[0]?.message}`, { ...ctx.recoveryFor('malformed_id') });
86
+ }
62
87
  const raw = await getNcbiService().idConvert(input.ids, input.idType, { signal: ctx.signal });
63
88
  // NCBI returns pmid as a number in JSON — coerce all ID fields to strings
64
89
  const records = raw.map((r) => {
@@ -1 +1 @@
1
- {"version":3,"file":"convert-ids.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/convert-ids.tool.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"convert-ids.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/convert-ids.tool.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,11 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
12
12
  maxResponseCharacters: z.ZodOptional<z.ZodNumber>;
13
13
  }, z.core.$strip>, z.ZodObject<{
14
14
  articles: z.ZodArray<z.ZodObject<{
15
+ recordType: z.ZodEnum<{
16
+ book: "book";
17
+ "book-chapter": "book-chapter";
18
+ "journal-article": "journal-article";
19
+ }>;
15
20
  pmid: z.ZodOptional<z.ZodString>;
16
21
  title: z.ZodOptional<z.ZodString>;
17
22
  abstractText: z.ZodOptional<z.ZodString>;
@@ -32,6 +37,8 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
32
37
  volume: z.ZodOptional<z.ZodString>;
33
38
  issue: z.ZodOptional<z.ZodString>;
34
39
  pages: z.ZodOptional<z.ZodString>;
40
+ elocationId: z.ZodOptional<z.ZodString>;
41
+ elocationIdType: z.ZodOptional<z.ZodString>;
35
42
  publicationDate: z.ZodOptional<z.ZodObject<{
36
43
  year: z.ZodOptional<z.ZodString>;
37
44
  month: z.ZodOptional<z.ZodString>;
@@ -39,6 +46,26 @@ export declare const fetchArticlesTool: import("@cyanheads/mcp-ts-core").ToolDef
39
46
  medlineDate: z.ZodOptional<z.ZodString>;
40
47
  }, z.core.$strip>>;
41
48
  }, z.core.$strip>>;
49
+ book: z.ZodOptional<z.ZodObject<{
50
+ title: z.ZodOptional<z.ZodString>;
51
+ publisher: z.ZodOptional<z.ZodString>;
52
+ publisherLocation: z.ZodOptional<z.ZodString>;
53
+ pubDate: z.ZodOptional<z.ZodString>;
54
+ beginningDate: z.ZodOptional<z.ZodString>;
55
+ endingDate: z.ZodOptional<z.ZodString>;
56
+ medium: z.ZodOptional<z.ZodString>;
57
+ edition: z.ZodOptional<z.ZodString>;
58
+ collectionTitle: z.ZodOptional<z.ZodString>;
59
+ isbns: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
+ doi: z.ZodOptional<z.ZodString>;
61
+ editors: z.ZodOptional<z.ZodArray<z.ZodObject<{
62
+ lastName: z.ZodOptional<z.ZodString>;
63
+ firstName: z.ZodOptional<z.ZodString>;
64
+ initials: z.ZodOptional<z.ZodString>;
65
+ collectiveName: z.ZodOptional<z.ZodString>;
66
+ }, z.core.$strip>>>;
67
+ accession: z.ZodOptional<z.ZodString>;
68
+ }, z.core.$strip>>;
42
69
  doi: z.ZodOptional<z.ZodString>;
43
70
  pmcId: z.ZodOptional<z.ZodString>;
44
71
  pubmedUrl: z.ZodOptional<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-articles.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/fetch-articles.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA0IjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAWhB,yBAAyB;;mBAE3B,sEAAsE;uBAE1E,qFAAqF;;;;EAqO3F,CAAC"}
1
+ {"version":3,"file":"fetch-articles.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/fetch-articles.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAwRjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAWhB,yBAAyB;;mBAE3B,sEAAsE;uBAE1E,qFAAqF;;;;EAkR3F,CAAC"}
@@ -7,8 +7,7 @@ import { tool, z } from '@cyanheads/mcp-ts-core';
7
7
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
8
8
  import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
9
9
  import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
10
- import { parseFullArticle } from '../../../services/ncbi/parsing/article-parser.js';
11
- import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
10
+ import { parseArticleSet } from '../../../services/ncbi/parsing/article-parser.js';
12
11
  import { fitWholeItems } from './_budget.js';
13
12
  import { conceptMeta, EDAM_DATA_RETRIEVAL, EDAM_PUBMED_ID, SCHEMA_SCHOLARLY_ARTICLE, } from './_concepts.js';
14
13
  import { pmidStringSchema } from './_schemas.js';
@@ -43,9 +42,93 @@ const JournalInfoSchema = z
43
42
  volume: z.string().optional().describe('Volume number'),
44
43
  issue: z.string().optional().describe('Issue number'),
45
44
  pages: z.string().optional().describe('Page range (e.g. "48-55")'),
45
+ elocationId: z
46
+ .string()
47
+ .optional()
48
+ .describe('Electronic article locator from NCBI `ELocationID` — the publisher-assigned article number (e.g. "2400512"). Journals that assign article numbers instead of pages often omit pagination entirely, leaving this the only locator. Never a substitute for `pages`, and never the DOI: a DOI-typed `ELocationID` is reported in `doi` instead. Absent when the only locator NCBI supplies is marked invalid.'),
49
+ elocationIdType: z
50
+ .string()
51
+ .optional()
52
+ .describe('Type of `elocationId`, from NCBI\'s `EIdType` attribute — "pii" in practice. Free-form: NCBI does not close the set, so treat an unfamiliar value as opaque.'),
46
53
  publicationDate: JournalPublicationDateSchema.optional(),
47
54
  })
48
- .describe('Journal information');
55
+ .describe('Journal information. Present on `journal-article` records only — absent on `book-chapter` and `book` records, because a Bookshelf record has no journal and its book title is never reported as one; read `book` for those. (#114)');
56
+ const BookEditorSchema = z
57
+ .object({
58
+ lastName: z
59
+ .string()
60
+ .optional()
61
+ .describe('Editor surname, from the book\'s `Book/AuthorList Type="editors"` entry. Absent on a group editor, which carries `collectiveName` instead.'),
62
+ firstName: z
63
+ .string()
64
+ .optional()
65
+ .describe('Editor given name as NCBI supplies it (`ForeName`, often "Margaret P"). Absent when NCBI carries initials only, or on a group editor.'),
66
+ initials: z
67
+ .string()
68
+ .optional()
69
+ .describe('Editor initials with no separators (e.g. "MP"). Absent when NCBI supplies none, or on a group editor.'),
70
+ collectiveName: z
71
+ .string()
72
+ .optional()
73
+ .describe('Group or committee credited as editor, when the entry names an organization rather than a person. Mutually exclusive with the name-part fields.'),
74
+ })
75
+ .describe('One editor of the containing book. Name parts only — editors are a citation credit, not a contributor record, so no affiliations or ORCID are reported for them.');
76
+ const BookInfoSchema = z
77
+ .object({
78
+ title: z
79
+ .string()
80
+ .optional()
81
+ .describe('Title of the containing book, from `Book/BookTitle` (e.g. "GeneReviews®"). On a `book` record this is the same value as the record\'s own `title`.'),
82
+ publisher: z
83
+ .string()
84
+ .optional()
85
+ .describe('Publisher of the book, from `Book/Publisher/PublisherName`.'),
86
+ publisherLocation: z
87
+ .string()
88
+ .optional()
89
+ .describe('Place of publication, from `Book/Publisher/PublisherLocation` (e.g. "Seattle (WA)"). Absent when NCBI supplies no place.'),
90
+ pubDate: z
91
+ .string()
92
+ .optional()
93
+ .describe("Publication year from `Book/PubDate`. Year only — NCBI's month and day are not reported, since no citation style uses them for a book."),
94
+ beginningDate: z
95
+ .string()
96
+ .optional()
97
+ .describe('First year of a continuously-updated book, from `Book/BeginningDate` (GeneReviews runs from 1993). Absent on a book published once.'),
98
+ endingDate: z
99
+ .string()
100
+ .optional()
101
+ .describe('Last year of a closed date range, from `Book/EndingDate`. Absent while a book is still being updated, which leaves the range open-ended.'),
102
+ medium: z
103
+ .string()
104
+ .optional()
105
+ .describe('Medium the book is published in, from `Book/Medium` — "Internet" wherever NCBI supplies it. Absent when NCBI supplies none; it is never defaulted.'),
106
+ edition: z
107
+ .string()
108
+ .optional()
109
+ .describe('Edition statement from `Book/Edition`. Rare on Bookshelf titles — absent unless NCBI supplies one.'),
110
+ collectionTitle: z
111
+ .string()
112
+ .optional()
113
+ .describe('Series the book belongs to, from `Book/CollectionTitle` (e.g. "ADA Clinical Compendia Series"). Absent for a book outside a series.'),
114
+ isbns: z
115
+ .array(z.string().describe('One ISBN, verbatim as NCBI reports it — leading zeros intact'))
116
+ .optional()
117
+ .describe('Every `Book/Isbn` on the record. A book commonly carries a print and an electronic ISBN, so this is a list. Absent for a Bookshelf title with no ISBN, which is most of them.'),
118
+ doi: z
119
+ .string()
120
+ .optional()
121
+ .describe('The book\'s own DOI, from `Book/ELocationID` with `EIdType="doi"`. Distinct from the record-level `doi`, which is the chapter\'s: a chapter does not inherit this one.'),
122
+ editors: z
123
+ .array(BookEditorSchema)
124
+ .optional()
125
+ .describe('Editors of the containing book, from `Book/AuthorList` marked `Type="editors"`. Kept out of `authors`, which carries the chapter\'s own writers. Absent when the book credits no editors.'),
126
+ accession: z
127
+ .string()
128
+ .optional()
129
+ .describe('NCBI Bookshelf accession from `ArticleIdList` (`bookaccession`), e.g. "NBK1247". The record is readable at `https://www.ncbi.nlm.nih.gov/books/<accession>/`.'),
130
+ })
131
+ .describe('The containing book of a `book-chapter`, or the book itself on a `book` record. Present only on those two record types, and never a stand-in for `journalInfo`.');
49
132
  const MeshQualifierSchema = z
50
133
  .object({
51
134
  qualifierName: z.string().describe('Qualifier/subheading name'),
@@ -79,12 +162,22 @@ const ArticleDateSchema = z
79
162
  .describe('Dated article event');
80
163
  const FetchedArticleSchema = z
81
164
  .object({
165
+ recordType: z
166
+ .enum(['journal-article', 'book-chapter', 'book'])
167
+ .describe('Which kind of PubMed record this is, set from the XML element it arrived in: `journal-article` for an ordinary article, `book-chapter` for an NCBI Bookshelf chapter, `book` for a whole Bookshelf book. Read this to tell the three apart — `publicationTypes` cannot, because PubMed labels a Bookshelf record "Review" or "Study Guide". `journalInfo` is present only on `journal-article`; `book` only on the other two.'),
82
168
  pmid: z.string().optional().describe('PubMed ID'),
83
- title: z.string().optional().describe('Article title'),
169
+ title: z
170
+ .string()
171
+ .optional()
172
+ .describe('Article title — the chapter title on a `book-chapter`, and the book title on a `book` record, where it repeats `book.title`.'),
84
173
  abstractText: z.string().optional().describe('Abstract text'),
85
174
  affiliations: z.array(z.string()).optional().describe('Deduplicated author affiliations'),
86
- authors: z.array(AuthorSchema).optional().describe('Author list'),
175
+ authors: z
176
+ .array(AuthorSchema)
177
+ .optional()
178
+ .describe("Author list. On a `book-chapter` these are the chapter's own authors, never the book's editors, which are in `book.editors`. Empty on a Bookshelf record that credits neither."),
87
179
  journalInfo: JournalInfoSchema.optional(),
180
+ book: BookInfoSchema.optional(),
88
181
  doi: z
89
182
  .string()
90
183
  .optional()
@@ -152,7 +245,7 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
152
245
  unavailablePmids: z
153
246
  .array(z.string())
154
247
  .optional()
155
- .describe('PMIDs that returned no article data. Reported in full regardless of where a `maxResponseCharacters` cutoff lands — these are misses, not deferrals, and re-requesting them returns nothing.'),
248
+ .describe('PMIDs PubMed returned no record for. That is all this reports: PubMed omits an unknown PMID silently, with no error and no reason, so the absence says nothing about whether the PMID exists. Reported in full regardless of where a `maxResponseCharacters` cutoff lands — these are misses, not deferrals. Use `pubmed_search_articles` to find PMIDs that do resolve.'),
156
249
  deferred: DeferredSchema.optional(),
157
250
  }),
158
251
  // Recovery guidance for two cases — no articles returned at all, and articles
@@ -174,23 +267,19 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
174
267
  if (!xmlData || !('PubmedArticleSet' in xmlData)) {
175
268
  throw ctx.fail('invalid_efetch_response', 'Invalid EFetch response from NCBI: missing PubmedArticleSet', { requestedPmids: input.pmids.length, ...ctx.recoveryFor('invalid_efetch_response') });
176
269
  }
177
- const rawArticles = xmlData.PubmedArticleSet?.PubmedArticle;
178
- const xmlArticles = rawArticles ? ensureArray(rawArticles) : [];
179
- const articles = xmlArticles
180
- .filter((a) => a?.MedlineCitation)
181
- .map((a) => {
182
- const parsed = parseFullArticle(a, {
183
- includeMesh: input.includeMesh,
184
- includeGrants: input.includeGrants,
185
- });
186
- return {
187
- ...parsed,
188
- pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${parsed.pmid}/`,
189
- ...(parsed.pmcId && {
190
- pmcUrl: `https://www.ncbi.nlm.nih.gov/pmc/articles/${parsed.pmcId}/`,
191
- }),
192
- };
193
- });
270
+ // Reads both members of the set. Taking `PubmedArticleSet.PubmedArticle`
271
+ // alone discards every NCBI Bookshelf record, whose PMIDs then surface as
272
+ // unavailable even though PubMed returned them. (#114)
273
+ const articles = parseArticleSet(xmlData.PubmedArticleSet, {
274
+ includeMesh: input.includeMesh,
275
+ includeGrants: input.includeGrants,
276
+ }).map((parsed) => ({
277
+ ...parsed,
278
+ pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${parsed.pmid}/`,
279
+ ...(parsed.pmcId && {
280
+ pmcUrl: `https://www.ncbi.nlm.nih.gov/pmc/articles/${parsed.pmcId}/`,
281
+ }),
282
+ }));
194
283
  const returnedPmids = new Set(articles.map((a) => a.pmid).filter(Boolean));
195
284
  const unavailable = input.pmids.filter((id) => !returnedPmids.has(id));
196
285
  // Whole-response budget: fill with complete records in response order and
@@ -222,7 +311,7 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
222
311
  // Keyed on what resolved, not on what the budget kept: a batch emptied by a
223
312
  // small ceiling is a budget outcome, not a batch of invalid PMIDs.
224
313
  if (articles.length === 0) {
225
- ctx.enrich.notice('No articles were returned. These PMIDs may be invalid, unpublished, or withdrawn. Try pubmed_search_articles to discover valid PMIDs.');
314
+ ctx.enrich.notice('No articles were returned: PubMed matched no record to any of these PMIDs. It omits a PMID it does not recognize silently, without an error or a reason, so nothing more than that is known here. Try pubmed_search_articles to discover PMIDs that resolve.');
226
315
  }
227
316
  if (deferred) {
228
317
  ctx.enrich({ truncated: true });
@@ -275,6 +364,9 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
275
364
  parts.push(`**${ji.volume}**${ji.issue ? `(${ji.issue})` : ''}`);
276
365
  if (ji.pages)
277
366
  parts.push(ji.pages);
367
+ if (ji.elocationId) {
368
+ parts.push(ji.elocationIdType ? `${ji.elocationIdType}: ${ji.elocationId}` : ji.elocationId);
369
+ }
278
370
  if (ji.issn)
279
371
  parts.push(`ISSN ${ji.issn}`);
280
372
  if (ji.eIssn)
@@ -282,6 +374,56 @@ export const fetchArticlesTool = tool('pubmed_fetch_articles', {
282
374
  if (parts.length)
283
375
  lines.push(`\n**Journal:** ${parts.join(', ')}`);
284
376
  }
377
+ // Venue block for a Bookshelf record. `journalInfo` is never set on one,
378
+ // so a book would otherwise render as a bare title with no publisher,
379
+ // date or permalink — the fields a citation actually needs. (#114)
380
+ const bk = a.book;
381
+ if (bk) {
382
+ const bookLines = [];
383
+ // On a whole-book record the heading above already is the book title;
384
+ // the medium marker then stands alone rather than repeating it.
385
+ if (a.recordType !== 'book' && bk.title) {
386
+ bookLines.push(`**Book:** ${bk.medium ? `${bk.title} [${bk.medium}]` : bk.title}`);
387
+ }
388
+ else if (bk.medium) {
389
+ bookLines.push(`**Medium:** ${bk.medium}`);
390
+ }
391
+ if (bk.editors?.length) {
392
+ bookLines.push('', `**Editors (${bk.editors.length}):**`);
393
+ for (const ed of bk.editors) {
394
+ bookLines.push(`- ${formatAuthor(ed)}`);
395
+ }
396
+ }
397
+ if (bk.publisher)
398
+ bookLines.push(`**Publisher:** ${bk.publisher}`);
399
+ if (bk.publisherLocation)
400
+ bookLines.push(`**Publisher Location:** ${bk.publisherLocation}`);
401
+ // A book published over several years carries a closed range
402
+ // (GeneReviews 1993–2026); otherwise the single publication year. A
403
+ // publication year that differs from the range's start is kept beside it.
404
+ const range = bk.beginningDate && bk.endingDate && bk.beginningDate !== bk.endingDate
405
+ ? `${bk.beginningDate}–${bk.endingDate}`
406
+ : undefined;
407
+ const bookDate = range && bk.pubDate && bk.pubDate !== bk.beginningDate
408
+ ? `${bk.pubDate} (${range})`
409
+ : (range ?? bk.pubDate ?? bk.beginningDate ?? bk.endingDate);
410
+ if (bookDate)
411
+ bookLines.push(`**Published:** ${bookDate}`);
412
+ if (bk.edition)
413
+ bookLines.push(`**Edition:** ${bk.edition}`);
414
+ if (bk.collectionTitle)
415
+ bookLines.push(`**Collection:** ${bk.collectionTitle}`);
416
+ if (bk.isbns?.length)
417
+ bookLines.push(`**ISBN:** ${bk.isbns.join(', ')}`);
418
+ if (bk.doi)
419
+ bookLines.push(`**Book DOI:** ${bk.doi}`);
420
+ if (bk.accession) {
421
+ bookLines.push(`**Bookshelf:** https://www.ncbi.nlm.nih.gov/books/${bk.accession}/`);
422
+ }
423
+ if (bookLines.length > 0)
424
+ lines.push('', ...bookLines);
425
+ }
426
+ lines.push(`**Record Type:** ${a.recordType}`);
285
427
  if (a.publicationTypes?.length)
286
428
  lines.push(`**Type:** ${a.publicationTypes.join(', ')}`);
287
429
  if (a.pmid)