@cyanheads/pubmed-mcp-server 2.10.19 → 2.10.20

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 (84) hide show
  1. package/AGENTS.md +7 -4
  2. package/CLAUDE.md +7 -4
  3. package/Dockerfile +12 -3
  4. package/README.md +7 -7
  5. package/changelog/2.10.x/2.10.20.md +63 -0
  6. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/definitions/_text.js +10 -5
  8. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +28 -10
  11. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +27 -9
  13. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +577 -485
  15. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
  17. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/find-related.tool.js +12 -8
  19. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +11 -7
  21. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +70 -34
  23. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +2 -1
  25. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  26. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +11 -3
  27. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +18 -0
  29. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +97 -13
  31. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -3
  33. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +7 -5
  35. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -4
  37. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/search-articles.tool.js +33 -11
  39. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +2 -1
  41. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/spell-check.tool.js +5 -1
  43. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  44. package/dist/services/error-contracts.d.ts +1 -1
  45. package/dist/services/error-contracts.d.ts.map +1 -1
  46. package/dist/services/error-contracts.js +1 -1
  47. package/dist/services/error-contracts.js.map +1 -1
  48. package/dist/services/ncbi/formatting/citation-formatter.d.ts +24 -0
  49. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  50. package/dist/services/ncbi/formatting/citation-formatter.js +147 -46
  51. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  52. package/dist/services/ncbi/ncbi-service.d.ts +16 -1
  53. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  54. package/dist/services/ncbi/ncbi-service.js +115 -70
  55. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  56. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  57. package/dist/services/ncbi/parsing/article-parser.js +38 -29
  58. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  59. package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
  60. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  61. package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
  62. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  63. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  64. package/dist/services/ncbi/parsing/pmc-article-parser.js +395 -147
  65. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  66. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +73 -5
  67. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  68. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +223 -27
  69. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  70. package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
  71. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  72. package/dist/services/ncbi/parsing/text-helpers.js +95 -19
  73. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  74. package/dist/services/ncbi/response-handler.d.ts +20 -12
  75. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  76. package/dist/services/ncbi/response-handler.js +112 -19
  77. package/dist/services/ncbi/response-handler.js.map +1 -1
  78. package/dist/services/ncbi/types.d.ts +2 -2
  79. package/dist/services/openalex/openalex-service.d.ts +10 -6
  80. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  81. package/dist/services/openalex/openalex-service.js +13 -9
  82. package/dist/services/openalex/openalex-service.js.map +1 -1
  83. package/package.json +13 -11
  84. package/server.json +10 -24
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Full-text fetch tool. Resolves full-text articles through a
3
3
  * three-stage chain: NCBI PMC EFetch → Europe PMC `fullTextXML` → Unpaywall.
4
- * Accepts three mutually-exclusive input shapes:
4
+ * Accepts three input fields, alone or together, naming at most 10 distinct
5
+ * identifiers between them:
5
6
  *
6
7
  * - `pmcids` — fetch directly by PMC ID, once per record however it is
7
8
  * spelled (`PMC123`, `pmc123`, `123`, zero-padded `PMC0123`), and reported
@@ -16,6 +17,13 @@
16
17
  * DOIs are case-insensitive: every casing of one DOI runs the chain once,
17
18
  * and `unavailable[]` reports each casing as the caller wrote it.
18
19
  *
20
+ * Routing runs `pmcids`, then `pmids`, then `dois`. Every tier keys its work on
21
+ * the record — PMC on the PMCID, Europe PMC on the search hit's `source` + `id`,
22
+ * Unpaywall on the lowercased DOI — and the first id to reach a record owns it:
23
+ * a later id naming the same article joins the owner's chain as another caller
24
+ * id, so the article is fetched and returned once. Each `unavailable[]` entry
25
+ * and deferred id carries the field its id was sent in.
26
+ *
19
27
  * Output uses a discriminated union on `source` (`pmc` | `unpaywall`) with an
20
28
  * extra `viaSource` discriminator that records which layer produced the
21
29
  * content. EPMC's JATS reuses the `pmc` schema shape because it's the same
@@ -30,7 +38,7 @@
30
38
  * @module src/mcp-server/tools/definitions/fetch-fulltext.tool
31
39
  */
32
40
  import { tool, z } from '@cyanheads/mcp-ts-core';
33
- import { serializationError } from '@cyanheads/mcp-ts-core/errors';
41
+ import { JsonRpcErrorCode, serializationError } from '@cyanheads/mcp-ts-core/errors';
34
42
  import { htmlExtractor, pdfParser } from '@cyanheads/mcp-ts-core/utils';
35
43
  import { getServerConfig } from '../../../config/server-config.js';
36
44
  import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
@@ -46,6 +54,7 @@ import { fitWholeItems } from './_budget.js';
46
54
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
47
55
  import { doiStringSchema, normalizePmid, pmcidStringSchema, pmidStringSchema } from './_schemas.js';
48
56
  import { escapeMarkdownInline, escapeMarkdownTableCell, sliceAtWordBoundary } from './_text.js';
57
+ import { hasVisibleText } from './_visible-text.js';
49
58
  /**
50
59
  * Canonical digits of a PMC ID {@link pmcidStringSchema} accepted: the `PMC`
51
60
  * prefix dropped in any case, and leading zeros stripped the way
@@ -68,6 +77,31 @@ function matchesSectionFilter(title, lowerFilter) {
68
77
  function lowerCase(s) {
69
78
  return s.toLowerCase();
70
79
  }
80
+ /**
81
+ * Split a `sections` filter into the terms it filters on and the paths of its
82
+ * blank elements. An exactly-empty element is skipped — form clients send one
83
+ * for a field left untouched — so `[""]` filters on nothing and `["Methods", ""]`
84
+ * on Methods alone. An element with no visible character (`hasVisibleText`)
85
+ * names no heading — as a substring, a single space matches every multi-word
86
+ * title and a zero-width space usually none — so it is reported by its index
87
+ * (`sections.1`) instead. A kept term is trimmed of surrounding whitespace, so
88
+ * `[" Methods"]` matches what `["Methods"]` does — as a substring, a padded
89
+ * term misses every heading the padding does not happen to fit. Whitespace
90
+ * inside a term and every other character are matched literally. (#186)
91
+ */
92
+ function splitSectionFilter(sections = []) {
93
+ const blank = [];
94
+ const terms = [];
95
+ for (const [index, term] of sections.entries()) {
96
+ if (term === '')
97
+ continue;
98
+ if (hasVisibleText(term))
99
+ terms.push(term.trim());
100
+ else
101
+ blank.push(`sections.${index}`);
102
+ }
103
+ return { blank, terms };
104
+ }
71
105
  /**
72
106
  * Prune a section tree to the branches a `sections` filter selects, matching
73
107
  * titles at every nesting depth rather than the top level alone. (#126)
@@ -438,8 +472,11 @@ const TableSchema = z
438
472
  .describe('How many leading `rows` entries are header rows — a `<thead>` block, or leading rows made entirely of `<th>`. 0 when the table declares none. Several header rows stack: read one column top to bottom for its full header path.'),
439
473
  rows: z
440
474
  .array(z.array(z.string()).describe('One row, as cell text by grid column'))
441
- .describe('Cell text by row, in document order, one entry per grid column. `colspan` and `rowspan` are expanded, so a cell covering several columns or rows repeats its text across each cell it covers and a well-formed table is rectangular — align on position from the left, and read a repeated value as one spanning cell rather than several measurements. Empty when `unextractableReason` is set.'),
442
- footnotes: z.string().optional().describe('`<table-wrap-foot>` text, flattened to one string'),
475
+ .describe('Cell text by row, in document order, one entry per grid column. `colspan` and `rowspan` are expanded, so a cell covering several columns or rows repeats its text across each cell it covers and a well-formed table is rectangular — align on position from the left, and read a repeated value as one spanning cell rather than several measurements. A line break in a cell — a `<break/>`, a paragraph, or a list item in the source — is kept as `\\n`, so `1.08\\n2.44` is two values; superscripts and other inline markup are never spaced (`0.108a`). Empty when `unextractableReason` is set.'),
476
+ footnotes: z
477
+ .string()
478
+ .optional()
479
+ .describe('`<table-wrap-foot>` text, one line per footnote or paragraph, joined by `\\n`. A footnote marker deposited as a `<label>` leads its line, followed by a space (`* No patient …`).'),
443
480
  unextractableReason: z
444
481
  .enum(['cals-tgroup', 'graphic-only', 'no-rows'])
445
482
  .optional()
@@ -552,11 +589,11 @@ const UnpaywallArticleSchema = z
552
589
  pmcId: z
553
590
  .string()
554
591
  .optional()
555
- .describe('PMC ID this article was requested under, in `PMC<digits>` form — present for `pmcids` input, absent for `pmids` and `dois` input. Ties the article back to the requested identifier, which `unavailable[]` keys on for the ids that found nothing.'),
592
+ .describe('PMC ID this article was requested under, in `PMC<digits>` form — present when a `pmcids` entry named it, absent when it was requested under a PMID or DOI. When several identifiers name one article, it is requested under the first to reach it: `pmcids`, then `pmids`, then `dois`. Ties the article back to the requested identifier, which `unavailable[]` keys on for the ids that found nothing.'),
556
593
  pmid: z
557
594
  .string()
558
595
  .optional()
559
- .describe('PubMed ID when input was `pmids`; absent for `pmcids` and `dois` input'),
596
+ .describe('PubMed ID this article was requested under — present when it was requested under a `pmids` entry, absent when under a PMC ID or DOI (see `pmcId` for which identifier an article several of them name is requested under)'),
560
597
  pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
561
598
  doi: z.string().describe('DOI used to locate the open-access copy'),
562
599
  sourceUrl: z.string().describe('URL the content was fetched from'),
@@ -638,7 +675,9 @@ const UnavailableSchema = z
638
675
  id: z
639
676
  .string()
640
677
  .describe('Identifier the chain could not resolve — PMID, PMCID, or DOI per `idType`'),
641
- idType: z.enum(['pmid', 'pmcid', 'doi']).describe('Which input branch the id came from'),
678
+ idType: z
679
+ .enum(['pmid', 'pmcid', 'doi'])
680
+ .describe('Which input field the id was sent in — `pmids`, `pmcids`, or `dois` respectively'),
642
681
  reason: UnavailableReasonSchema,
643
682
  triedTiers: z
644
683
  .array(TriedTierSchema)
@@ -779,10 +818,23 @@ const DeferredSchema = z
779
818
  .describe('Articles the chain resolved but withheld to stay under the ceiling'),
780
819
  idType: z
781
820
  .enum(['pmid', 'pmcid', 'doi'])
782
- .describe('Which input branch the deferred ids belong to — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries.'),
821
+ .optional()
822
+ .describe('The input field every deferred id was sent in — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries. Absent when the deferred ids came from more than one field; `pmcids` / `pmids` / `dois` then group them.'),
783
823
  ids: z
784
824
  .array(z.string())
785
- .describe('Identifiers of the deferred articles, in response order, keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` branch and the same other inputs. Never contains an id from `unavailable`.'),
825
+ .describe('Identifiers of the deferred articles, in response order, keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` field — or, when `idType` is absent, with the `pmcids` / `pmids` / `dois` lists below — and the same other inputs. Never contains an id from `unavailable`.'),
826
+ pmcids: z
827
+ .array(z.string())
828
+ .optional()
829
+ .describe('The deferred ids sent as PMC IDs, in response order — re-submit them as `pmcids`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a PMC ID.'),
830
+ pmids: z
831
+ .array(z.string())
832
+ .optional()
833
+ .describe('The deferred ids sent as PMIDs, in response order — re-submit them as `pmids`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a PMID.'),
834
+ dois: z
835
+ .array(z.string())
836
+ .optional()
837
+ .describe('The deferred ids sent as DOIs, in response order — re-submit them as `dois`. Present only when the deferred ids came from more than one field (so `idType` is absent) and one of them was a DOI.'),
786
838
  nextDeferredCharacters: z
787
839
  .number()
788
840
  .describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped. Raise `maxResponseCharacters` to at least this to make progress; a smaller article further down `ids` cannot be reached until this one fits.'),
@@ -1180,9 +1232,24 @@ function buildTruncationNotice(truncation) {
1180
1232
  */
1181
1233
  function buildDeferralNotice(deferred) {
1182
1234
  const spent = deferred.returnedCharacters === 0
1183
- ? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
1235
+ ? `The first article alone exceeds the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
1184
1236
  : `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
1185
- return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\` to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
1237
+ const resend = deferred.idType
1238
+ ? `Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\``
1239
+ : `Re-call pubmed_fetch_fulltext with each id under its own field (${deferredFieldLists(deferred)})`;
1240
+ return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. ${resend} to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
1241
+ }
1242
+ /**
1243
+ * The per-field lists of a deferral that spans fields, in field order —
1244
+ * `` `pmcids`: PMC1; `dois`: 10.1/x `` — or `''` when `idType` covers them all.
1245
+ */
1246
+ function deferredFieldLists(deferred) {
1247
+ return ['pmcids', 'pmids', 'dois']
1248
+ .flatMap((field) => {
1249
+ const ids = deferred[field];
1250
+ return ids?.length ? [`\`${field}\`: ${ids.join(', ')}`] : [];
1251
+ })
1252
+ .join('; ');
1186
1253
  }
1187
1254
  // ─── Tool Definition ─────────────────────────────────────────────────────────
1188
1255
  /**
@@ -1217,11 +1284,13 @@ export function buildFulltextDescription(tiers) {
1217
1284
  : tiers.unpaywall
1218
1285
  ? '; DOIs with no PMC copy recover via Unpaywall open access'
1219
1286
  : '';
1220
- const input = `Provide exactly one of \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), or \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}).`;
1287
+ const input = `Name articles by \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}), or any mix of the three — up to 10 distinct identifiers per call; an article several of them name is fetched and returned once.`;
1221
1288
  const budget = 'Two independent character controls: `maxCharacters` caps body text per article, `maxResponseCharacters` caps the whole response and defers articles past the ceiling whole, listing them in `deferred.ids` for a follow-up call.';
1222
1289
  return `${base} ${fallback} ${input} ${budget}`;
1223
1290
  }
1224
1291
  const serverConfig = getServerConfig();
1292
+ /** Distinct identifiers one call may name across `pmcids`, `pmids`, and `dois`. (#192) */
1293
+ const MAX_IDENTIFIERS = 10;
1225
1294
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1226
1295
  description: buildFulltextDescription({
1227
1296
  europePmc: serverConfig.europepmcEnabled,
@@ -1233,8 +1302,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1233
1302
  // Only the ID routing's `idConvert` calls run unwrapped. Every Europe PMC and
1234
1303
  // Unpaywall call sits behind a catch that folds the failure into
1235
1304
  // `unavailable[].triedTiers`, so their service reasons never reach a caller
1236
- // and are not declared here. (#168)
1237
- errors: [...NCBI_SERVICE_ERRORS],
1305
+ // and are not declared here. (#168) `blank_filter` is the handler's own input
1306
+ // check, raised before any upstream request. (#186)
1307
+ errors: [
1308
+ ...NCBI_SERVICE_ERRORS,
1309
+ {
1310
+ reason: 'blank_filter',
1311
+ code: JsonRpcErrorCode.ValidationError,
1312
+ when: 'A `sections` element holds no visible character — only whitespace, or invisible characters such as a zero-width space — so it names no heading. An exactly-empty element is not blank here; it is skipped.',
1313
+ recovery: 'Give each named `sections` element a heading term such as Methods, or make it an empty string, which is skipped; omit `sections` for no filter. The same blank element will be rejected again.',
1314
+ retryable: false,
1315
+ },
1316
+ ],
1238
1317
  input: z
1239
1318
  .object({
1240
1319
  pmcids: z
@@ -1242,19 +1321,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1242
1321
  .min(1)
1243
1322
  .max(10)
1244
1323
  .optional()
1245
- .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.'),
1324
+ .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Combinable with `pmids` and `dois` — at most 10 distinct identifiers across the three. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.'),
1246
1325
  pmids: z
1247
1326
  .array(pmidStringSchema)
1248
1327
  .min(1)
1249
1328
  .max(10)
1250
1329
  .optional()
1251
- .describe('PubMed IDs. Provide exactly one of `pmcids`, `pmids`, or `dois`. Articles in PMC are returned as structured JATS; articles not in PMC fall through to Europe PMC (when EPMC has a `fullTextXML`), then to Unpaywall when `UNPAYWALL_EMAIL` is set and a DOI is available.'),
1330
+ .describe('PubMed IDs. Combinable with `pmcids` and `dois` — at most 10 distinct identifiers across the three. Articles in PMC are returned as structured JATS; articles not in PMC fall through to Europe PMC (when EPMC has a `fullTextXML`), then to Unpaywall when `UNPAYWALL_EMAIL` is set and a DOI is available.'),
1252
1331
  dois: z
1253
1332
  .array(doiStringSchema)
1254
1333
  .min(1)
1255
1334
  .max(10)
1256
1335
  .optional()
1257
- .describe('DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]), one per element. Provide exactly one of `pmcids`, `pmids`, or `dois`. Resolved to a PMCID via the PMC ID Converter and returned as structured JATS when the article is in PMC; DOIs with no PMC counterpart (preprints, EPMC-only OA) fall through to Europe PMC, then Unpaywall, when those layers are enabled.'),
1336
+ .describe('DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]), one per element. Combinable with `pmcids` and `pmids` — at most 10 distinct identifiers across the three. Resolved to a PMCID via the PMC ID Converter and returned as structured JATS when the article is in PMC; DOIs with no PMC counterpart (preprints, EPMC-only OA) fall through to Europe PMC, then Unpaywall, when those layers are enabled.'),
1258
1337
  includeReferences: z
1259
1338
  .boolean()
1260
1339
  .default(false)
@@ -1277,7 +1356,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1277
1356
  sections: z
1278
1357
  .array(z.string())
1279
1358
  .optional()
1280
- .describe('Filter to specific sections by title (e.g. ["Introduction", "Methods", "Results", "Discussion"]). A term matches a section or subsection title at any nesting depth, case-insensitively, as a substring — "resul" matches "Results". A section whose own title matches is returned whole; one kept only because a nested subsection matched keeps its heading as a breadcrumb, with its own text cleared and only the matching branch beneath it. Tables and assets narrow with the filter: one whose section did not survive, or that names no section, is dropped. Applies to `source=pmc` results only.'),
1359
+ .describe('Filter to specific sections by title (e.g. ["Introduction", "Methods", "Results", "Discussion"]). A term matches a section or subsection title at any nesting depth, case-insensitively, as a substring — "resul" matches "Results". A section whose own title matches is returned whole; one kept only because a nested subsection matched keeps its heading as a breadcrumb, with its own text cleared and only the matching branch beneath it. Tables and assets narrow with the filter: one whose section did not survive, or that names no section, is dropped. Empty strings are skipped, so `[""]` applies no filter; an element of only whitespace or invisible characters, such as a zero-width space, is rejected. Applies to `source=pmc` results only.'),
1281
1360
  maxCharacters: z
1282
1361
  .number()
1283
1362
  .int()
@@ -1304,8 +1383,27 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1304
1383
  .default('truncate')
1305
1384
  .describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole, the section the budget runs out in is cut, and every section or subsection past that point is dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section and subsection keeps its heading, and an excerpt as far as the budget reaches — a heading the budget left empty is marked as such in the rendered text. Use it to survey what an article contains before requesting specific `sections`. Ignored when no budget is set, and identical for `source=unpaywall` bodies, which have no headings to preserve.'),
1306
1385
  })
1307
- .refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
1308
- message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
1386
+ // Counted in each field's canonical form, the one its chain keys on, so the
1387
+ // cap is settled before any upstream request and holds every fallback tier
1388
+ // to at most 10 candidates. (#192)
1389
+ .superRefine((v, refinement) => {
1390
+ const distinct = new Set([
1391
+ ...(v.pmcids ?? []).map((id) => `pmcid:${normalizePmcId(id)}`),
1392
+ ...(v.pmids ?? []).map((id) => `pmid:${normalizePmid(id)}`),
1393
+ ...(v.dois ?? []).map((id) => `doi:${id.toLowerCase()}`),
1394
+ ]);
1395
+ if (distinct.size === 0) {
1396
+ refinement.addIssue({
1397
+ code: 'custom',
1398
+ message: 'Provide at least one of `pmcids`, `pmids`, or `dois`.',
1399
+ });
1400
+ }
1401
+ else if (distinct.size > MAX_IDENTIFIERS) {
1402
+ refinement.addIssue({
1403
+ code: 'custom',
1404
+ message: `\`pmcids\`, \`pmids\`, and \`dois\` together name ${distinct.size} distinct identifiers; a call accepts at most ${MAX_IDENTIFIERS} — split them across calls.`,
1405
+ });
1406
+ }
1309
1407
  }),
1310
1408
  output: z.object({
1311
1409
  articles: z.array(ArticleSchema).describe('Full-text articles'),
@@ -1315,7 +1413,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1315
1413
  unavailable: z
1316
1414
  .array(UnavailableSchema)
1317
1415
  .optional()
1318
- .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
1416
+ .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` names the field each id was sent in. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
1319
1417
  truncation: TruncationSchema.optional(),
1320
1418
  deferred: DeferredSchema.optional(),
1321
1419
  }),
@@ -1337,17 +1435,27 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1337
1435
  .describe('True when a character budget shortened at least one returned body, or withheld a whole article. Absent when every resolved article is present with its full post-filter body. The per-article body accounting is in `truncation`; the withheld ids are in `deferred`.'),
1338
1436
  },
1339
1437
  async handler(input, ctx) {
1438
+ const idCount = (input.pmcids?.length ?? 0) + (input.pmids?.length ?? 0) + (input.dois?.length ?? 0);
1340
1439
  ctx.log.info('Executing pubmed_fetch_fulltext', {
1341
1440
  hasPmcids: !!input.pmcids,
1342
1441
  hasPmids: !!input.pmids,
1343
1442
  hasDois: !!input.dois,
1344
- idCount: (input.pmcids ?? input.pmids ?? input.dois)?.length,
1443
+ idCount,
1345
1444
  });
1445
+ // A blank `sections` element fails the call before any upstream request;
1446
+ // every stage filters on the nonblank terms alone. (#186)
1447
+ const sectionFilter = splitSectionFilter(input.sections);
1448
+ if (sectionFilter.blank.length > 0) {
1449
+ const one = sectionFilter.blank.length === 1;
1450
+ throw ctx.fail('blank_filter', `${sectionFilter.blank.map((path) => `\`${path}\``).join(', ')} ${one ? 'holds' : 'hold'} only whitespace or invisible characters, so ${one ? 'it names' : 'they name'} no section heading.`, { fields: sectionFilter.blank });
1451
+ }
1452
+ const filters = { ...input, sections: sectionFilter.terms };
1346
1453
  // ── Chain tracking ──────────────────────────────────────────────────────
1347
1454
  // Per-input-id tier history (the `triedTiers` array on unavailable entries).
1348
- // Keys: pmid for `pmids` input, prefixed PMCID for `pmcids` input, doi for
1349
- // `dois` input. `recoveredIds` collects ids the chain produced an article
1350
- // for, so we can skip them when building `unavailable[]`.
1455
+ // Keys: canonical PMID for a `pmids` entry, prefixed PMCID for a `pmcids`
1456
+ // entry, first-submitted casing for a `dois` entry — disjoint shapes, so
1457
+ // one map holds all three. `recoveredIds` collects ids the chain produced an
1458
+ // article for, so we can skip them when building `unavailable[]`.
1351
1459
  const chainByInput = new Map();
1352
1460
  const recoveredIds = new Set();
1353
1461
  // Per-input-id set of tiers this deployment has not configured AND that
@@ -1361,13 +1469,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1361
1469
  tiers.add(tier);
1362
1470
  unqueriedByInput.set(inputId, tiers);
1363
1471
  };
1364
- // Back-map from a converter-resolved prefixed PMCID to the input id that
1365
- // seeded it — a PMID for `pmids` input, a DOI for `dois` input — so the PMC
1366
- // and EPMC stages attribute recoveries and misses to the original input id.
1472
+ // Back-map from a prefixed PMCID to the input id that owns its record — the
1473
+ // PMC ID itself, or the PMID or DOI the converter placed on it — so the PMC
1474
+ // stage attributes recoveries and misses to the original input id.
1367
1475
  const pmcidToInputId = new Map();
1368
- // DOI hints captured during pmids→pmcid routing so PMC-misses on the pmids
1369
- // branch can still reach Unpaywall without re-fetching from PubMed metadata.
1370
- const pmidContext = new Map();
1476
+ // A DOI the routing learned for a PMC record — from the converter, or from a
1477
+ // `dois` entry that named it — so a PMC miss can still reach Unpaywall
1478
+ // without another lookup, whichever field owns the record.
1479
+ const doiByPmcRecord = new Map();
1371
1480
  // Ids of returned articles whose `sections` filter removed every body
1372
1481
  // section — collected across the PMC and EPMC stages to drive one recovery
1373
1482
  // notice via ctx.enrich.notice (#80).
@@ -1386,19 +1495,46 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1386
1495
  // caller can re-submit rather than whatever id the article happens to
1387
1496
  // carry — a `pmids` request recovers articles keyed by PMCID. (#100)
1388
1497
  const inputIdByArticle = new Map();
1389
- // The caller's own spellings of each chain key, so `unavailable[]` and
1390
- // `deferred.ids` report what was submitted. The `pmids` branch fills it — a
1391
- // PMID's chain runs on its canonical form — and so does the `dois` branch,
1392
- // whose chain runs once per DOI however many casings name it. Both also
1393
- // fold in an input id that resolves to a PMC record another one already
1394
- // claimed (see `routeToPmc`). Any other key is its own spelling. (#161, #166)
1498
+ // The caller's own spellings of each chain key, each with the field it was
1499
+ // sent in, so `unavailable[]` and `deferred` report what was submitted and
1500
+ // where. A PMID's chain runs on its canonical form and a DOI's once however
1501
+ // many casings name it, so those keys gather several spellings; a PMC ID is
1502
+ // reported in its `PMC<digits>` key form. The first entry is the key's own,
1503
+ // the id that opened the chain; an id that reaches a record another one
1504
+ // already owns is folded in after it (see `joinChain`). (#161, #166, #192)
1395
1505
  const callerIds = new Map();
1396
- const addCallerId = (key, spelling) => {
1506
+ const addCallerId = (key, callerId) => {
1397
1507
  const spellings = callerIds.get(key) ?? [];
1398
- if (!spellings.includes(spelling))
1399
- spellings.push(spelling);
1508
+ if (!spellings.some((s) => s.id === callerId.id && s.idType === callerId.idType)) {
1509
+ spellings.push(callerId);
1510
+ }
1400
1511
  callerIds.set(key, spellings);
1401
1512
  };
1513
+ // A joined spelling's own tiers from before its join, and the length of the
1514
+ // owner's chain at that point: it reports those tiers, then only what the
1515
+ // owner's chain gained afterwards, never a step another id took. (#192)
1516
+ const ownChainOf = new Map();
1517
+ /**
1518
+ * Fold `joiner` into `owner` once both are known to name one record: the
1519
+ * joiner's spellings become caller ids of the owner's chain, so the record is
1520
+ * worked once and every spelling is recovered with it, or reported
1521
+ * unavailable with its own tiers up to the join and the owner's after. (#192)
1522
+ */
1523
+ const joinChain = (joiner, owner) => {
1524
+ const joinerChain = chainByInput.get(joiner) ?? [];
1525
+ const from = chainByInput.get(owner)?.length ?? 0;
1526
+ for (const callerId of callerIds.get(joiner) ?? []) {
1527
+ const own = ownChainOf.get(callerId);
1528
+ const tried = [...(own?.tried ?? []), ...joinerChain.slice(own?.from ?? 0)];
1529
+ ownChainOf.set(callerId, { tried, from });
1530
+ addCallerId(owner, callerId);
1531
+ }
1532
+ callerIds.delete(joiner);
1533
+ chainByInput.delete(joiner);
1534
+ unqueriedByInput.delete(joiner);
1535
+ if (bodylessInputIds.delete(joiner))
1536
+ bodylessInputIds.add(owner);
1537
+ };
1402
1538
  const budget = {
1403
1539
  overflowMode: input.overflowMode,
1404
1540
  ...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
@@ -1406,18 +1542,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1406
1542
  maxCharactersPerSection: input.maxCharactersPerSection,
1407
1543
  }),
1408
1544
  };
1409
- const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
1410
- // ── Branch routing → produce buckets the staged chain consumes ──────────
1411
- let pmcIds = [];
1412
- let pmidFallbackCandidates = [];
1413
- let pmcidFallbackCandidates = [];
1414
- let doiCandidates = [];
1415
- // Send a converter-resolved PMCID to PMC EFetch under the input id that
1416
- // named it. A second input id the converter places on the same PMC record
1417
- // — two DOIs for one article — joins the first one's chain as another
1418
- // spelling of it: the record is fetched once, and both ids are recovered,
1419
- // or reported unavailable with that chain, rather than the later id taking
1420
- // the PMCID over and leaving the earlier one with an empty chain.
1545
+ // ── Routing → produce the buckets the staged chain consumes ─────────────
1546
+ // `pmcids`, then `pmids`, then `dois`: the first id to reach a record owns
1547
+ // it, so the PMC IDs claim their records before any converter answer is
1548
+ // placed. Fallback candidates are bucketed by the field that owns them and
1549
+ // run in that order, which is how a later tier picks a record's owner too.
1550
+ const pmcIds = [];
1551
+ const pending = { pmcid: [], pmid: [], doi: [] };
1552
+ // Send a PMCID to PMC EFetch under the input id that named it. A second
1553
+ // input id that reaches the same PMC record — two DOIs for one article, or
1554
+ // a PMID for a PMC ID already given — joins the first one's chain as
1555
+ // another spelling of it: the record is fetched once, and both ids are
1556
+ // recovered, or reported unavailable with that chain, rather than the later
1557
+ // id taking the PMCID over and leaving the earlier one with an empty chain.
1421
1558
  const routeToPmc = (inputId, pmcid) => {
1422
1559
  const normalized = normalizePmcId(pmcid);
1423
1560
  const prefixed = withPmcPrefix(normalized);
@@ -1427,135 +1564,126 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1427
1564
  pmcidToInputId.set(prefixed, inputId);
1428
1565
  return;
1429
1566
  }
1430
- if (owner === inputId)
1431
- return;
1432
- for (const spelling of callerIds.get(inputId) ?? [inputId])
1433
- addCallerId(owner, spelling);
1434
- callerIds.delete(inputId);
1435
- chainByInput.delete(inputId);
1567
+ if (owner !== inputId)
1568
+ joinChain(inputId, owner);
1436
1569
  };
1437
- if (input.pmids) {
1438
- // The ID Converter parses `00000001` as PMID 1 yet reports it "not found
1439
- // in PMC", and every later stage answers with NCBI's own PMID, so the chain
1440
- // runs once per distinct PMID in its canonical form. (#161)
1441
- for (const id of input.pmids)
1442
- addCallerId(normalizePmid(id), id);
1443
- const pmids = [...callerIds.keys()];
1444
- for (const id of pmids)
1445
- chainByInput.set(id, []);
1446
- const records = await getNcbiService().idConvert(pmids, 'pmid', ctx.signal ? { signal: ctx.signal } : undefined);
1447
- const seen = new Set();
1448
- for (const r of records) {
1449
- if (r.pmid === undefined)
1450
- continue;
1451
- const pmid = String(r.pmid);
1452
- seen.add(pmid);
1453
- if (r.pmcid) {
1454
- routeToPmc(pmid, String(r.pmcid));
1455
- pmidContext.set(pmid, { pmid, ...(r.doi && { doi: r.doi }) });
1456
- }
1457
- else {
1458
- chainByInput.get(pmid)?.push({
1459
- tier: 'pmc',
1460
- outcome: 'not-attempted',
1461
- detail: 'PMID has no PMC counterpart',
1462
- });
1463
- pmidFallbackCandidates.push({ pmid, ...(r.doi && { doi: r.doi }) });
1464
- }
1570
+ const noteRecordDoi = (pmcid, doi) => {
1571
+ const prefixed = withPmcPrefix(normalizePmcId(pmcid));
1572
+ if (doi && !doiByPmcRecord.has(prefixed))
1573
+ doiByPmcRecord.set(prefixed, doi);
1574
+ };
1575
+ // `PMC123`, `pmc123`, `123` and `PMC0123` name one record; it runs the
1576
+ // chain once, keyed and reported in `PMC<digits>` form. (#170)
1577
+ for (const id of input.pmcids ?? []) {
1578
+ const normalized = normalizePmcId(id);
1579
+ const key = withPmcPrefix(normalized);
1580
+ if (!chainByInput.has(key))
1581
+ chainByInput.set(key, []);
1582
+ addCallerId(key, { id: key, idType: 'pmcid' });
1583
+ routeToPmc(key, normalized);
1584
+ }
1585
+ // The ID Converter parses `00000001` as PMID 1 yet reports it "not found
1586
+ // in PMC", and every later stage answers with NCBI's own PMID, so the chain
1587
+ // runs once per distinct PMID in its canonical form. (#161)
1588
+ for (const id of input.pmids ?? [])
1589
+ addCallerId(normalizePmid(id), { id, idType: 'pmid' });
1590
+ const pmids = [...new Set((input.pmids ?? []).map(normalizePmid))];
1591
+ // DOIs resolve through the converter too, so PMC-indexed DOIs reach PMC
1592
+ // EFetch instead of going straight to the EPMC/Unpaywall fallback (which
1593
+ // misses articles whose only OA copy is the PMC JATS).
1594
+ //
1595
+ // DOIs are case-insensitive, and the converter echoes one casing in
1596
+ // `requested-id` for DOIs that differ only in case, so every casing of a
1597
+ // DOI shares one chain, keyed by the first spelling submitted, and
1598
+ // converter records are matched to it case-insensitively. (#166)
1599
+ const chainKeyByDoi = new Map();
1600
+ for (const doi of input.dois ?? []) {
1601
+ const key = chainKeyByDoi.get(doi.toLowerCase()) ?? doi;
1602
+ chainKeyByDoi.set(doi.toLowerCase(), key);
1603
+ addCallerId(key, { id: doi, idType: 'doi' });
1604
+ }
1605
+ const dois = [...new Set(chainKeyByDoi.values())];
1606
+ for (const key of [...pmids, ...dois])
1607
+ chainByInput.set(key, []);
1608
+ // The two lookups are independent; their answers are placed in field order.
1609
+ const convertOptions = ctx.signal ? { signal: ctx.signal } : undefined;
1610
+ const [pmidRecords, doiRecords] = await Promise.all([
1611
+ pmids.length > 0 ? getNcbiService().idConvert(pmids, 'pmid', convertOptions) : [],
1612
+ dois.length > 0 ? getNcbiService().idConvert(dois, 'doi', convertOptions) : [],
1613
+ ]);
1614
+ const seenPmids = new Set();
1615
+ for (const r of pmidRecords) {
1616
+ if (r.pmid === undefined)
1617
+ continue;
1618
+ const pmid = String(r.pmid);
1619
+ seenPmids.add(pmid);
1620
+ if (r.pmcid) {
1621
+ noteRecordDoi(String(r.pmcid), r.doi);
1622
+ routeToPmc(pmid, String(r.pmcid));
1465
1623
  }
1466
- for (const requested of pmids) {
1467
- if (!seen.has(requested)) {
1468
- chainByInput.get(requested)?.push({
1469
- tier: 'pmc',
1470
- outcome: 'not-attempted',
1471
- detail: 'ID Converter returned no record for this PMID',
1472
- });
1473
- pmidFallbackCandidates.push({ pmid: requested });
1474
- }
1624
+ else {
1625
+ chainByInput.get(pmid)?.push({
1626
+ tier: 'pmc',
1627
+ outcome: 'not-attempted',
1628
+ detail: 'PMID has no PMC counterpart',
1629
+ });
1630
+ pending.pmid.push({ idType: 'pmid', id: pmid, ...(r.doi && { doi: r.doi }) });
1475
1631
  }
1476
1632
  }
1477
- else if (input.pmcids) {
1478
- // `PMC123`, `pmc123`, `123` and `PMC0123` name one record; it runs the
1479
- // chain once. (#170)
1480
- pmcIds = [...new Set(input.pmcids.map(normalizePmcId))];
1481
- for (const id of pmcIds)
1482
- chainByInput.set(withPmcPrefix(id), []);
1633
+ for (const requested of pmids) {
1634
+ if (!seenPmids.has(requested)) {
1635
+ chainByInput.get(requested)?.push({
1636
+ tier: 'pmc',
1637
+ outcome: 'not-attempted',
1638
+ detail: 'ID Converter returned no record for this PMID',
1639
+ });
1640
+ pending.pmid.push({ idType: 'pmid', id: requested });
1641
+ }
1483
1642
  }
1484
- else if (input.dois) {
1485
- // Mirror the `pmids` branch: resolve DOI → PMCID via the PMC ID Converter
1486
- // so PMC-indexed DOIs reach PMC EFetch instead of going straight to the
1487
- // EPMC/Unpaywall fallback (which misses articles whose only OA copy is the
1488
- // PMC JATS). DOIs the converter can't place in PMC seed `doiCandidates`.
1489
- //
1490
- // DOIs are case-insensitive, and the converter echoes one casing in
1491
- // `requested-id` for DOIs that differ only in case, so every casing of a
1492
- // DOI shares one chain, keyed by the first spelling submitted, and
1493
- // converter records are matched to it case-insensitively. (#166)
1494
- const chainKeyByDoi = new Map();
1495
- for (const doi of input.dois) {
1496
- const key = chainKeyByDoi.get(doi.toLowerCase()) ?? doi;
1497
- chainKeyByDoi.set(doi.toLowerCase(), key);
1498
- addCallerId(key, doi);
1643
+ const seenDois = new Set();
1644
+ for (const r of doiRecords) {
1645
+ // Match on the echoed `requested-id`, not `r.doi`: the record's own DOI
1646
+ // can be cased differently from anything the caller sent.
1647
+ const doi = chainKeyByDoi.get(String(r['requested-id']).toLowerCase());
1648
+ if (doi === undefined)
1649
+ continue;
1650
+ seenDois.add(doi);
1651
+ if (r.pmcid) {
1652
+ noteRecordDoi(String(r.pmcid), doi);
1653
+ routeToPmc(doi, String(r.pmcid));
1499
1654
  }
1500
- const dois = [...callerIds.keys()];
1501
- for (const doi of dois)
1502
- chainByInput.set(doi, []);
1503
- const records = await getNcbiService().idConvert(dois, 'doi', ctx.signal ? { signal: ctx.signal } : undefined);
1504
- const seen = new Set();
1505
- for (const r of records) {
1506
- // Match on the echoed `requested-id`, not `r.doi`: the record's own DOI
1507
- // can be cased differently from anything the caller sent.
1508
- const doi = chainKeyByDoi.get(String(r['requested-id']).toLowerCase());
1509
- if (doi === undefined)
1510
- continue;
1511
- seen.add(doi);
1512
- if (r.pmcid) {
1513
- routeToPmc(doi, String(r.pmcid));
1514
- }
1515
- else {
1516
- chainByInput.get(doi)?.push({
1517
- tier: 'pmc',
1518
- outcome: 'not-attempted',
1519
- detail: 'DOI has no PMC counterpart',
1520
- });
1521
- doiCandidates.push({ doi });
1522
- }
1655
+ else {
1656
+ chainByInput.get(doi)?.push({
1657
+ tier: 'pmc',
1658
+ outcome: 'not-attempted',
1659
+ detail: 'DOI has no PMC counterpart',
1660
+ });
1661
+ pending.doi.push({ idType: 'doi', id: doi, doi });
1523
1662
  }
1524
- for (const requested of dois) {
1525
- if (!seen.has(requested)) {
1526
- chainByInput.get(requested)?.push({
1527
- tier: 'pmc',
1528
- outcome: 'not-attempted',
1529
- detail: 'ID Converter returned no record for this DOI',
1530
- });
1531
- doiCandidates.push({ doi: requested });
1532
- }
1663
+ }
1664
+ for (const requested of dois) {
1665
+ if (!seenDois.has(requested)) {
1666
+ chainByInput.get(requested)?.push({
1667
+ tier: 'pmc',
1668
+ outcome: 'not-attempted',
1669
+ detail: 'ID Converter returned no record for this DOI',
1670
+ });
1671
+ pending.doi.push({ idType: 'doi', id: requested, doi: requested });
1533
1672
  }
1534
1673
  }
1535
- // Route PMC-missed prefixed PMCIDs into the fallback buckets so EPMC and
1536
- // (for pmids/dois) Unpaywall still get a chance. For pmids input we look up
1537
- // the captured DOI hint via `pmidContext` to avoid an extra PubMed eFetch
1538
- // when available; the converter often returns the DOI alongside a PMCID
1539
- // match. For dois input the original DOI is the fallback key directly.
1674
+ // Route PMC-missed prefixed PMCIDs on to the remaining tiers, each as a
1675
+ // candidate of the field that owns its record — which decides how Europe
1676
+ // PMC searches for it and which id an Unpaywall article is stamped with. A
1677
+ // DOI the routing learned for the record rides along, sparing Unpaywall a
1678
+ // lookup; a DOI owner carries its own.
1540
1679
  const routePmcMissesToFallback = (missingPrefixed) => {
1541
- if (missingPrefixed.length === 0)
1542
- return;
1543
- if (input.pmcids) {
1544
- pmcidFallbackCandidates = missingPrefixed.map((pmcid) => ({ pmcid }));
1545
- }
1546
- else if (input.pmids) {
1547
- for (const prefixed of missingPrefixed) {
1548
- const pmid = pmcidToInputId.get(prefixed);
1549
- if (pmid)
1550
- pmidFallbackCandidates.push(pmidContext.get(pmid) ?? { pmid });
1551
- }
1552
- }
1553
- else if (input.dois) {
1554
- for (const prefixed of missingPrefixed) {
1555
- const doi = pmcidToInputId.get(prefixed);
1556
- if (doi)
1557
- doiCandidates.push({ doi });
1558
- }
1680
+ for (const prefixed of missingPrefixed) {
1681
+ const owner = pmcidToInputId.get(prefixed) ?? prefixed;
1682
+ const idType = callerIds.get(owner)?.[0]?.idType;
1683
+ if (idType === undefined)
1684
+ continue;
1685
+ const doi = idType === 'doi' ? owner : doiByPmcRecord.get(prefixed);
1686
+ pending[idType].push({ idType, id: owner, ...(doi && { doi }) });
1559
1687
  }
1560
1688
  };
1561
1689
  // ── Stage 1: PMC EFetch ─────────────────────────────────────────────────
@@ -1587,8 +1715,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1587
1715
  bodylessPmcIds.add(before.pmcId);
1588
1716
  continue;
1589
1717
  }
1590
- const after = applyPmcFilters(before, input);
1591
- if (isSectionFilterMiss(before, after, input.sections)) {
1718
+ const after = applyPmcFilters(before, filters);
1719
+ if (isSectionFilterMiss(before, after, filters.sections)) {
1592
1720
  sectionFilterMisses.push(articleDisplayId(after));
1593
1721
  }
1594
1722
  const budgeted = applyPmcBudget(after, budget);
@@ -1650,85 +1778,72 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1650
1778
  routePmcMissesToFallback(allPrefixed);
1651
1779
  }
1652
1780
  }
1781
+ // The records PMC EFetch served, by PMCID, under the id that owns each: the
1782
+ // ID Converter can miss a PMC record a Europe PMC hit links, and that hit
1783
+ // joins the owner rather than fetching the article again. (#192)
1784
+ const servedByPmc = new Map();
1785
+ for (const a of pmcArticles) {
1786
+ if (a.pmcId)
1787
+ servedByPmc.set(a.pmcId, inputIdByArticle.get(a) ?? a.pmcId);
1788
+ }
1653
1789
  // ── Stage 2: Europe PMC fullTextXML ─────────────────────────────────────
1790
+ // Each candidate's `id` is its own chain key from here on: a PMC miss is
1791
+ // routed under the id that owns the record.
1792
+ let candidates = [...pending.pmcid, ...pending.pmid, ...pending.doi];
1654
1793
  const epmc = getEuropePmcService();
1655
1794
  const epmcOutcomes = epmc
1656
- ? await runEpmcStage(epmc, {
1657
- pmidFallbackCandidates,
1658
- pmcidFallbackCandidates,
1659
- doiCandidates,
1660
- input,
1661
- budget,
1662
- ctx,
1663
- })
1795
+ ? await runEpmcStage(epmc, { candidates, servedByPmc, input: filters, budget, ctx })
1664
1796
  : {
1665
1797
  articles: [],
1666
- remainingPmid: pmidFallbackCandidates,
1667
- remainingPmcid: pmcidFallbackCandidates,
1668
- remainingDoi: doiCandidates,
1669
- pmidOutcomes: new Map(),
1670
- pmcidOutcomes: new Map(),
1671
- doiOutcomes: new Map(),
1798
+ articleInputIds: new Map(),
1799
+ joins: [],
1800
+ outcomes: new Map(),
1801
+ remaining: candidates,
1672
1802
  sectionFilterMisses: [],
1673
1803
  truncatedArticles: [],
1674
1804
  omittedSections: 0,
1675
- articleInputIds: new Map(),
1676
1805
  };
1677
1806
  pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
1678
1807
  truncatedArticles.push(...epmcOutcomes.truncatedArticles);
1679
1808
  omittedSections += epmcOutcomes.omittedSections;
1680
1809
  for (const [article, candidateId] of epmcOutcomes.articleInputIds) {
1681
- inputIdByArticle.set(article, pmcidToInputId.get(candidateId) ?? candidateId);
1810
+ inputIdByArticle.set(article, candidateId);
1682
1811
  }
1683
1812
  // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
1684
1813
  // recovered, so their ids are added to `recoveredIds` here.
1685
1814
  if (!epmc) {
1686
- const epmcDisabledEntry = {
1687
- tier: 'europepmc',
1688
- outcome: 'not-attempted',
1689
- detail: 'EUROPEPMC_ENABLED=false',
1690
- };
1691
1815
  // EPMC searches by PMID, PMCID, and DOI alike, so it could have served
1692
1816
  // every candidate that reached this stage — no applicability test.
1693
- const skipEpmc = (inputId) => {
1694
- chainByInput.get(inputId)?.push(epmcDisabledEntry);
1695
- markUnqueried(inputId, 'europepmc');
1696
- };
1697
- for (const c of pmidFallbackCandidates)
1698
- skipEpmc(c.pmid);
1699
- for (const c of pmcidFallbackCandidates) {
1700
- const prefixed = withPmcPrefix(c.pmcid);
1701
- skipEpmc(pmcidToInputId.get(prefixed) ?? prefixed);
1817
+ for (const c of candidates) {
1818
+ chainByInput.get(c.id)?.push({
1819
+ tier: 'europepmc',
1820
+ outcome: 'not-attempted',
1821
+ detail: 'EUROPEPMC_ENABLED=false',
1822
+ });
1823
+ markUnqueried(c.id, 'europepmc');
1702
1824
  }
1703
- for (const c of doiCandidates)
1704
- skipEpmc(c.doi);
1705
1825
  }
1706
1826
  else {
1707
- const foldEpmcOutcome = (inputId, outcome) => {
1827
+ // A candidate whose search reached a record an earlier one already owns
1828
+ // joins that chain before the owner's outcome lands on it. (#192)
1829
+ for (const { id, owner } of epmcOutcomes.joins)
1830
+ joinChain(id, owner);
1831
+ for (const [inputId, outcome] of epmcOutcomes.outcomes) {
1708
1832
  if (outcome.kind === 'hit') {
1709
1833
  recoveredIds.add(inputId);
1710
- return;
1834
+ continue;
1711
1835
  }
1712
1836
  if (outcome.kind === 'no-body')
1713
1837
  bodylessInputIds.add(inputId);
1714
1838
  chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
1715
- };
1716
- for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes)
1717
- foldEpmcOutcome(pmid, outcome);
1718
- for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
1719
- foldEpmcOutcome(pmcidToInputId.get(prefixed) ?? prefixed, outcome);
1720
1839
  }
1721
- for (const [doi, outcome] of epmcOutcomes.doiOutcomes)
1722
- foldEpmcOutcome(doi, outcome);
1723
1840
  }
1724
- pmidFallbackCandidates = epmcOutcomes.remainingPmid;
1725
- pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
1726
- doiCandidates = epmcOutcomes.remainingDoi;
1841
+ candidates = epmcOutcomes.remaining;
1727
1842
  sectionFilterMisses.push(...epmcOutcomes.sectionFilterMisses);
1728
1843
  // ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
1729
1844
  const unpaywall = getUnpaywallService();
1730
1845
  const fallbackArticles = [];
1731
- // Detail of a DOI-backfill lookup that threw, per branch. A candidate still
1846
+ // Detail of a DOI-backfill lookup that threw, per field. A candidate still
1732
1847
  // DOI-less after one of these is an unknown, not a settled absence: it
1733
1848
  // reports `doi-lookup-failed` rather than `no-doi`. (#119)
1734
1849
  let pmcidDoiLookupFailure;
@@ -1739,200 +1854,129 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1739
1854
  outcome: failure ? 'doi-lookup-failed' : 'no-doi',
1740
1855
  ...(failure && { detail: failure }),
1741
1856
  });
1742
- // `pmcids` input reaches Unpaywall on the DOI the chain already holds: the
1743
- // EPMC stage searches by PMCID and its hit carries one, captured on non-hit
1744
- // outcomes too. PMCIDs EPMC never resolved fall back to the PMC ID
1745
- // Converter, which returns DOIs for PMC-indexed records. (#88)
1746
- if (pmcidFallbackCandidates.length > 0) {
1747
- if (!unpaywall) {
1748
- // The PMCID → DOI lookup below only runs when Unpaywall is configured,
1749
- // so a candidate arrives here with a DOI only if the EPMC stage handed
1750
- // one over. An absent DOI therefore means "never looked up", not "this
1751
- // record has none" — the tier stays a genuine unknown and is marked.
1752
- for (const c of pmcidFallbackCandidates) {
1753
- const prefixed = withPmcPrefix(c.pmcid);
1754
- const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
1755
- chainByInput.get(inputId)?.push({
1756
- tier: 'unpaywall',
1757
- outcome: 'not-attempted',
1758
- detail: 'UNPAYWALL_EMAIL is not set',
1759
- });
1760
- markUnqueried(inputId, 'unpaywall');
1761
- }
1762
- }
1763
- else {
1764
- const needDoi = pmcidFallbackCandidates
1765
- .filter((c) => !c.doi)
1766
- .map((c) => withPmcPrefix(c.pmcid));
1767
- if (needDoi.length > 0) {
1768
- try {
1769
- const records = await getNcbiService().idConvert(needDoi, 'pmcid', ctx.signal ? { signal: ctx.signal } : undefined);
1770
- const doiByPmcid = new Map();
1771
- for (const r of records) {
1772
- if (r.pmcid && r.doi) {
1773
- doiByPmcid.set(withPmcPrefix(normalizePmcId(String(r.pmcid))), String(r.doi));
1774
- }
1775
- }
1776
- pmcidFallbackCandidates = pmcidFallbackCandidates.map((c) => {
1777
- if (c.doi)
1778
- return c;
1779
- const doi = doiByPmcid.get(withPmcPrefix(c.pmcid));
1780
- return doi ? { ...c, doi } : c;
1781
- });
1782
- }
1783
- catch (error) {
1784
- if (ctx.signal.aborted)
1785
- throw error;
1786
- pmcidDoiLookupFailure = error instanceof Error ? error.message : String(error);
1787
- ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
1788
- error: pmcidDoiLookupFailure,
1789
- pmcidCount: needDoi.length,
1790
- });
1791
- }
1792
- }
1793
- const outcomes = await Promise.all(pmcidFallbackCandidates.map(async (candidate) => {
1794
- // The prefixed PMCID is the id `unavailable[]` keys on, so stamping
1795
- // it on the article makes a partially-recovered batch report its
1796
- // successes and its failures under the same identifier. (#92)
1797
- const pmcId = withPmcPrefix(candidate.pmcid);
1798
- return {
1799
- pmcId,
1800
- result: candidate.doi
1801
- ? await resolveUnpaywall({ pmcId, doi: candidate.doi, epmcTitle: candidate.title, budget }, unpaywall, ctx)
1802
- : undefined,
1803
- };
1804
- }));
1805
- for (const { pmcId, result } of outcomes) {
1806
- const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
1807
- if (result === undefined) {
1808
- chainByInput.get(inputId)?.push(doilessTierEntry(pmcidDoiLookupFailure));
1809
- }
1810
- else if ('article' in result) {
1811
- fallbackArticles.push(result.article);
1812
- inputIdByArticle.set(result.article, inputId);
1813
- if (result.truncation)
1814
- truncatedArticles.push(result.truncation);
1815
- recoveredIds.add(inputId);
1816
- }
1817
- else {
1818
- const u = result.unavailable;
1819
- chainByInput.get(inputId)?.push({
1820
- tier: 'unpaywall',
1821
- outcome: unpaywallReasonToTierOutcome(u.reason),
1822
- ...(u.detail && { detail: u.detail }),
1823
- });
1857
+ // Candidates reach Unpaywall on the DOI the chain already holds — a `dois`
1858
+ // entry's own, one the routing learned for a PMC record, or one a Europe
1859
+ // PMC hit carried, captured on non-hit outcomes too (#88). The rest are
1860
+ // backfilled: a PMC ID through the PMC ID Converter, which returns DOIs for
1861
+ // PMC-indexed records, and only when Unpaywall is configured; a PMID from
1862
+ // PubMed metadata (db=pubmed), since the converter returns DOIs only for
1863
+ // articles it has in PMC. The two lookups are independent.
1864
+ const backfillPmcidDois = async (pmcids) => {
1865
+ const found = new Map();
1866
+ if (pmcids.length === 0)
1867
+ return found;
1868
+ try {
1869
+ const records = await getNcbiService().idConvert(pmcids, 'pmcid', ctx.signal ? { signal: ctx.signal } : undefined);
1870
+ for (const r of records) {
1871
+ if (r.pmcid && r.doi) {
1872
+ found.set(withPmcPrefix(normalizePmcId(String(r.pmcid))), String(r.doi));
1824
1873
  }
1825
1874
  }
1826
1875
  }
1827
- }
1828
- if (pmidFallbackCandidates.length > 0) {
1829
- // The PMC ID Converter only returns DOIs for articles it has in PMC, so
1830
- // candidates here are missing DOIs by default. Pull them from PubMed
1831
- // metadata (db=pubmed) before dispatching to Unpaywall.
1832
- const needDoi = pmidFallbackCandidates.filter((c) => !c.doi).map((c) => c.pmid);
1833
- if (needDoi.length > 0) {
1834
- try {
1835
- const doiMap = await fetchPubmedDois(needDoi, ctx.signal);
1836
- pmidFallbackCandidates = pmidFallbackCandidates.map((c) => {
1837
- if (c.doi)
1838
- return c;
1839
- const doi = doiMap.get(c.pmid);
1840
- return doi ? { ...c, doi } : c;
1841
- });
1842
- }
1843
- catch (error) {
1844
- if (ctx.signal.aborted)
1845
- throw error;
1846
- pmidDoiLookupFailure = error instanceof Error ? error.message : String(error);
1847
- ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
1848
- error: pmidDoiLookupFailure,
1849
- pmidCount: needDoi.length,
1850
- });
1851
- }
1876
+ catch (error) {
1877
+ if (ctx.signal.aborted)
1878
+ throw error;
1879
+ pmcidDoiLookupFailure = error instanceof Error ? error.message : String(error);
1880
+ ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
1881
+ error: pmcidDoiLookupFailure,
1882
+ pmcidCount: pmcids.length,
1883
+ });
1852
1884
  }
1853
- if (!unpaywall) {
1854
- // `fetchPubmedDois` has already run, so a candidate with no DOI could
1855
- // not have reached Unpaywall configured or not — that is `no-doi`, a
1856
- // real answer, not an incomplete search. Unless the lookup itself
1857
- // threw, in which case the DOI state is unknown rather than absent.
1858
- for (const c of pmidFallbackCandidates) {
1859
- if (!c.doi) {
1860
- chainByInput.get(c.pmid)?.push(doilessTierEntry(pmidDoiLookupFailure));
1861
- continue;
1862
- }
1863
- chainByInput.get(c.pmid)?.push({
1864
- tier: 'unpaywall',
1865
- outcome: 'not-attempted',
1866
- detail: 'UNPAYWALL_EMAIL is not set',
1867
- });
1868
- markUnqueried(c.pmid, 'unpaywall');
1869
- }
1885
+ return found;
1886
+ };
1887
+ const backfillPmidDois = async (pmidsNeedingDoi) => {
1888
+ if (pmidsNeedingDoi.length === 0)
1889
+ return new Map();
1890
+ try {
1891
+ return await fetchPubmedDois(pmidsNeedingDoi, ctx.signal);
1870
1892
  }
1871
- else {
1872
- const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
1873
- candidate,
1874
- result: candidate.doi
1875
- ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, epmcTitle: candidate.title, budget }, unpaywall, ctx)
1876
- : undefined,
1877
- })));
1878
- for (const { candidate, result } of outcomes) {
1879
- if (result === undefined) {
1880
- chainByInput.get(candidate.pmid)?.push(doilessTierEntry(pmidDoiLookupFailure));
1881
- }
1882
- else if ('article' in result) {
1883
- fallbackArticles.push(result.article);
1884
- inputIdByArticle.set(result.article, candidate.pmid);
1885
- if (result.truncation)
1886
- truncatedArticles.push(result.truncation);
1887
- recoveredIds.add(candidate.pmid);
1888
- }
1889
- else {
1890
- const u = result.unavailable;
1891
- chainByInput.get(candidate.pmid)?.push({
1892
- tier: 'unpaywall',
1893
- outcome: unpaywallReasonToTierOutcome(u.reason),
1894
- ...(u.detail && { detail: u.detail }),
1895
- });
1896
- }
1893
+ catch (error) {
1894
+ if (ctx.signal.aborted)
1895
+ throw error;
1896
+ pmidDoiLookupFailure = error instanceof Error ? error.message : String(error);
1897
+ ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
1898
+ error: pmidDoiLookupFailure,
1899
+ pmidCount: pmidsNeedingDoi.length,
1900
+ });
1901
+ return new Map();
1902
+ }
1903
+ };
1904
+ const needingDoi = (idType) => candidates.filter((c) => c.idType === idType && !c.doi).map((c) => c.id);
1905
+ const [pmcidDois, pmidDois] = await Promise.all([
1906
+ unpaywall ? backfillPmcidDois(needingDoi('pmcid')) : new Map(),
1907
+ backfillPmidDois(needingDoi('pmid')),
1908
+ ]);
1909
+ const backfilled = { pmcid: pmcidDois, pmid: pmidDois, doi: new Map() };
1910
+ candidates = candidates.map((c) => {
1911
+ const doi = c.doi ?? backfilled[c.idType].get(c.id);
1912
+ return doi ? { ...c, doi } : c;
1913
+ });
1914
+ if (!unpaywall) {
1915
+ for (const c of candidates) {
1916
+ // `fetchPubmedDois` has already run, so a PMID with no DOI could not
1917
+ // have reached Unpaywall configured or not — that is `no-doi`, a real
1918
+ // answer, not an incomplete search, unless the lookup itself threw. A
1919
+ // PMC ID's DOI is looked up only for Unpaywall, so its absence means
1920
+ // "never looked up", and the tier stays a genuine unknown and is marked.
1921
+ if (!c.doi && c.idType === 'pmid') {
1922
+ chainByInput.get(c.id)?.push(doilessTierEntry(pmidDoiLookupFailure));
1923
+ continue;
1897
1924
  }
1925
+ chainByInput.get(c.id)?.push({
1926
+ tier: 'unpaywall',
1927
+ outcome: 'not-attempted',
1928
+ detail: 'UNPAYWALL_EMAIL is not set',
1929
+ });
1930
+ markUnqueried(c.id, 'unpaywall');
1898
1931
  }
1899
1932
  }
1900
- if (doiCandidates.length > 0) {
1901
- if (!unpaywall) {
1902
- // Every candidate on this branch is a DOI, so Unpaywall applies to all
1903
- // of them.
1904
- for (const c of doiCandidates) {
1905
- chainByInput.get(c.doi)?.push({
1906
- tier: 'unpaywall',
1907
- outcome: 'not-attempted',
1908
- detail: 'UNPAYWALL_EMAIL is not set',
1909
- });
1910
- markUnqueried(c.doi, 'unpaywall');
1933
+ else {
1934
+ // One resolve per DOI, case-insensitively: a later candidate carrying a
1935
+ // DOI an earlier one holds joins that chain, and the article is stamped
1936
+ // with the owner's identifier. (#192)
1937
+ const ownerByDoi = new Map();
1938
+ for (const c of candidates) {
1939
+ if (!c.doi) {
1940
+ const failure = c.idType === 'pmcid' ? pmcidDoiLookupFailure : pmidDoiLookupFailure;
1941
+ chainByInput.get(c.id)?.push(doilessTierEntry(failure));
1942
+ continue;
1911
1943
  }
1944
+ const owner = ownerByDoi.get(c.doi.toLowerCase());
1945
+ if (owner)
1946
+ joinChain(c.id, owner.id);
1947
+ else
1948
+ ownerByDoi.set(c.doi.toLowerCase(), { ...c, doi: c.doi });
1912
1949
  }
1913
- else {
1914
- // `resolveUnpaywall` catches its own failures so this Promise.all
1915
- // doesn't reject under normal operation.
1916
- const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
1950
+ // `resolveUnpaywall` catches its own failures so this Promise.all
1951
+ // doesn't reject under normal operation.
1952
+ const outcomes = await Promise.all([...ownerByDoi.values()].map(async (c) => ({
1953
+ inputId: c.id,
1954
+ // A PMC ID or PMID is stamped on the article so a partially-recovered
1955
+ // batch reports its successes and its failures under the same
1956
+ // identifier; Unpaywall itself only knows the DOI. (#92)
1957
+ result: await resolveUnpaywall({
1958
+ ...(c.idType === 'pmcid' && { pmcId: c.id }),
1959
+ ...(c.idType === 'pmid' && { pmid: c.id }),
1917
1960
  doi: c.doi,
1918
- result: await resolveUnpaywall({ doi: c.doi, epmcTitle: c.title, budget }, unpaywall, ctx),
1919
- })));
1920
- for (const { doi, result } of outcomes) {
1921
- if ('article' in result) {
1922
- fallbackArticles.push(result.article);
1923
- inputIdByArticle.set(result.article, doi);
1924
- if (result.truncation)
1925
- truncatedArticles.push(result.truncation);
1926
- recoveredIds.add(doi);
1927
- }
1928
- else {
1929
- const u = result.unavailable;
1930
- chainByInput.get(doi)?.push({
1931
- tier: 'unpaywall',
1932
- outcome: unpaywallReasonToTierOutcome(u.reason),
1933
- ...(u.detail && { detail: u.detail }),
1934
- });
1935
- }
1961
+ epmcTitle: c.title,
1962
+ budget,
1963
+ }, unpaywall, ctx),
1964
+ })));
1965
+ for (const { inputId, result } of outcomes) {
1966
+ if ('article' in result) {
1967
+ fallbackArticles.push(result.article);
1968
+ inputIdByArticle.set(result.article, inputId);
1969
+ if (result.truncation)
1970
+ truncatedArticles.push(result.truncation);
1971
+ recoveredIds.add(inputId);
1972
+ }
1973
+ else {
1974
+ const u = result.unavailable;
1975
+ chainByInput.get(inputId)?.push({
1976
+ tier: 'unpaywall',
1977
+ outcome: unpaywallReasonToTierOutcome(u.reason),
1978
+ ...(u.detail && { detail: u.detail }),
1979
+ });
1936
1980
  }
1937
1981
  }
1938
1982
  }
@@ -1942,12 +1986,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1942
1986
  if (recoveredIds.has(id))
1943
1987
  continue;
1944
1988
  const unqueried = unqueriedByInput.get(id);
1945
- for (const callerId of callerIds.get(id) ?? [id]) {
1989
+ for (const callerId of callerIds.get(id) ?? []) {
1990
+ const own = ownChainOf.get(callerId);
1991
+ const triedTiers = own ? [...own.tried, ...chain.slice(own.from)] : chain;
1946
1992
  unavailable.push({
1947
- id: callerId,
1948
- idType,
1949
- reason: reasonFromChain(chain),
1950
- triedTiers: chain,
1993
+ id: callerId.id,
1994
+ idType: callerId.idType,
1995
+ reason: reasonFromChain(triedTiers),
1996
+ triedTiers,
1951
1997
  ...(unqueried?.size && { unqueriedTiers: [...unqueried] }),
1952
1998
  });
1953
1999
  }
@@ -1962,17 +2008,32 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1962
2008
  const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
1963
2009
  const articles = fit?.kept ?? resolved;
1964
2010
  const nextDeferredCharacters = fit?.nextDeferredCharacters;
2011
+ // Each deferred article is handed back under the first spelling of the id
2012
+ // that owns it, in the field that id was sent in. Every recovery site
2013
+ // records the owner; a PMC record that came back without a PMC ID has none,
2014
+ // and is reported under the id it carries — the total-function fallback,
2015
+ // not an expected path.
2016
+ const deferredIds = (fit?.deferred ?? []).map((a) => {
2017
+ const key = inputIdByArticle.get(a);
2018
+ const owner = key === undefined ? undefined : callerIds.get(key)?.[0];
2019
+ return (owner ?? { id: articleDisplayId(a), idType: a.pmcId ? 'pmcid' : a.pmid ? 'pmid' : 'doi' });
2020
+ });
2021
+ const deferredFields = new Set(deferredIds.map((d) => d.idType));
2022
+ const [onlyField] = deferredFields;
2023
+ const deferredUnder = (idType) => deferredIds.filter((d) => d.idType === idType).map((d) => d.id);
1965
2024
  const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
1966
2025
  ? {
1967
2026
  maxResponseCharacters: ceiling,
1968
2027
  returnedCharacters: fit.keptCharacters,
1969
2028
  deferredCount: fit.deferred.length,
1970
- idType,
1971
- // Every recovery site records the input id; `articleDisplayId` is
1972
- // the total-function fallback, not an expected path.
1973
- ids: fit.deferred.map((a) => {
1974
- const id = inputIdByArticle.get(a) ?? articleDisplayId(a);
1975
- return callerIds.get(id)?.[0] ?? id;
2029
+ // One field names them all — always so for a single-field call — or
2030
+ // each field's list says where to resend its ids. (#192)
2031
+ ...(deferredFields.size === 1 && onlyField && { idType: onlyField }),
2032
+ ids: deferredIds.map((d) => d.id),
2033
+ ...(deferredFields.size > 1 && {
2034
+ ...(deferredFields.has('pmcid') && { pmcids: deferredUnder('pmcid') }),
2035
+ ...(deferredFields.has('pmid') && { pmids: deferredUnder('pmid') }),
2036
+ ...(deferredFields.has('doi') && { dois: deferredUnder('doi') }),
1976
2037
  }),
1977
2038
  nextDeferredCharacters,
1978
2039
  }
@@ -1992,7 +2053,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1992
2053
  }
1993
2054
  }
1994
2055
  ctx.log.info('pubmed_fetch_fulltext completed', {
1995
- requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
2056
+ requested: idCount,
1996
2057
  returned: articles.length,
1997
2058
  pmcHits: pmcArticles.filter((a) => a.viaSource === 'pmc').length,
1998
2059
  epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
@@ -2026,8 +2087,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2026
2087
  // Only the last ctx.enrich.notice survives, so the applicable fragments are
2027
2088
  // collected and emitted once.
2028
2089
  const notices = [];
2029
- if (input.sections?.length && sectionFilterMisses.length > 0) {
2030
- notices.push(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
2090
+ if (sectionFilterMisses.length > 0) {
2091
+ notices.push(buildSectionFilterMissNotice(sectionFilterMisses, sectionFilter.terms));
2031
2092
  }
2032
2093
  const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
2033
2094
  if (unrecoveredBodyless.length > 0)
@@ -2082,7 +2143,16 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2082
2143
  }
2083
2144
  if (result.deferred) {
2084
2145
  const d = result.deferred;
2085
- lines.push(`\n**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`, `Re-call \`pubmed_fetch_fulltext\` with these ${d.idType} ids as \`${d.idType}s\`: ${d.ids.join(', ')}`);
2146
+ lines.push(`\n**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`);
2147
+ // Rendered on presence, each independently: a response carries `idType`
2148
+ // or the per-field lists, never both.
2149
+ if (d.idType) {
2150
+ lines.push(`Re-call \`pubmed_fetch_fulltext\` with these ${d.idType} ids as \`${d.idType}s\`: ${d.ids.join(', ')}`);
2151
+ }
2152
+ const fieldLists = deferredFieldLists(d);
2153
+ if (fieldLists) {
2154
+ lines.push(`Re-call \`pubmed_fetch_fulltext\` with these ids, each under its own field — ${fieldLists}`);
2155
+ }
2086
2156
  }
2087
2157
  // An empty response under a budget is a deferral, not an absence — the
2088
2158
  // articles resolved and the ids above retrieve them.
@@ -2105,7 +2175,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2105
2175
  });
2106
2176
  /**
2107
2177
  * Merge what a Europe PMC hit carried onto a candidate bound for Unpaywall. A
2108
- * DOI the candidate already holds wins — for `dois` input it is the caller's
2178
+ * DOI the candidate already holds wins — for a `dois` entry it is the caller's
2109
2179
  * own identifier.
2110
2180
  */
2111
2181
  function carryEpmcHit(candidate, hit) {
@@ -2121,18 +2191,63 @@ function nonEmptyDisplayText(raw) {
2121
2191
  }
2122
2192
  /**
2123
2193
  * Run the Europe PMC step against everything that fell through PMC EFetch
2124
- * plus any direct DOI input. Each candidate goes through search-by-best-id →
2125
- * fullTextXML. Hits become `source: 'pmc'` articles with `viaSource: 'europepmc'`;
2126
- * misses flow through to the Unpaywall stage unchanged.
2194
+ * plus any DOI the converter could not place. Each candidate is searched by its
2195
+ * own id; each record the searches reach is then fetched as fullTextXML once,
2196
+ * for the first candidate in field order that reached it, and any later one
2197
+ * joins that candidate's chain. Hits become `source: 'pmc'` articles with
2198
+ * `viaSource: 'europepmc'`; misses flow through to the Unpaywall stage
2199
+ * unchanged.
2127
2200
  *
2128
- * Candidates run in parallel — the EPMC request queue caps concurrency so this
2129
- * stays polite without serializing. Errors are caught and logged inside the
2130
- * helpers; a transient EPMC failure must not block the downstream Unpaywall
2131
- * fallback.
2201
+ * Searches and fetches run in parallel — the EPMC request queue caps
2202
+ * concurrency so this stays polite without serializing. Errors are caught and
2203
+ * logged inside the helpers; a transient EPMC failure must not block the
2204
+ * downstream Unpaywall fallback.
2132
2205
  */
2133
2206
  async function runEpmcStage(epmc, args) {
2134
- const runOne = async (c, query, contextPmid) => {
2135
- const search = await searchEpmcSafe(epmc, query, args.ctx);
2207
+ /**
2208
+ * Query shapes are load-bearing and not interchangeable with their quoted
2209
+ * variants. Europe PMC matches zero records for `EXT_ID:"<pmid>" AND SRC:MED`
2210
+ * and `PMCID:"PMC<digits>"` — the quotes only survive as long as no `AND SRC:`
2211
+ * clause follows. `SRC:PMC` is likewise wrong for a PMCID lookup: EPMC's
2212
+ * canonical record for a PMC-indexed article has `source: MED` and carries the
2213
+ * PMCID as a field, so the filter excludes the very record being sought. DOIs
2214
+ * keep their quotes — they carry slashes and dots that need them. (#85)
2215
+ */
2216
+ const queryFor = (c) => {
2217
+ switch (c.idType) {
2218
+ case 'pmcid':
2219
+ return `PMCID:${c.id}`;
2220
+ case 'pmid':
2221
+ return `EXT_ID:${c.id} AND SRC:MED`;
2222
+ case 'doi':
2223
+ return `DOI:"${c.id}"`;
2224
+ }
2225
+ };
2226
+ const searched = await Promise.all(args.candidates.map(async (c) => ({
2227
+ c,
2228
+ search: await searchEpmcSafe(epmc, queryFor(c), args.ctx),
2229
+ })));
2230
+ // Every tier keys on the record: the first candidate whose search reached a
2231
+ // record owns it, and a later one joins its chain rather than fetching and
2232
+ // returning the same article again — as does one whose hit names a PMC ID
2233
+ // PMC EFetch already served, joining that article's owner. (#192)
2234
+ const joins = [];
2235
+ const ownerByRecord = new Map();
2236
+ const owned = searched.filter(({ c, search }) => {
2237
+ if (search.kind !== 'hit')
2238
+ return true;
2239
+ const record = `${search.hit.source}:${search.hit.id}`;
2240
+ const pmcid = search.hit.pmcid ?? (search.hit.source === 'PMC' ? search.hit.id : undefined);
2241
+ const servedOwner = pmcid === undefined ? undefined : args.servedByPmc.get(withPmcPrefix(normalizePmcId(pmcid)));
2242
+ const owner = servedOwner ?? ownerByRecord.get(record);
2243
+ if (owner === undefined) {
2244
+ ownerByRecord.set(record, c.id);
2245
+ return true;
2246
+ }
2247
+ joins.push({ id: c.id, owner });
2248
+ return false;
2249
+ });
2250
+ const runOne = async (c, search) => {
2136
2251
  if (search.kind === 'error') {
2137
2252
  return { c, outcome: { kind: 'service-error', detail: search.detail } };
2138
2253
  }
@@ -2143,6 +2258,7 @@ async function runEpmcStage(epmc, args) {
2143
2258
  ...(search.hit.doi && { doi: search.hit.doi }),
2144
2259
  ...(title && { title }),
2145
2260
  };
2261
+ const contextPmid = c.idType === 'pmid' ? c.id : undefined;
2146
2262
  const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
2147
2263
  if (fetched.kind === 'error') {
2148
2264
  return { c, ...hit, outcome: { kind: 'service-error', detail: fetched.detail } };
@@ -2167,34 +2283,11 @@ async function runEpmcStage(epmc, args) {
2167
2283
  ...(fetched.truncation && { truncation: fetched.truncation }),
2168
2284
  };
2169
2285
  };
2170
- /**
2171
- * Query shapes are load-bearing and not interchangeable with their quoted
2172
- * variants. Europe PMC matches zero records for `EXT_ID:"<pmid>" AND SRC:MED`
2173
- * and `PMCID:"PMC<digits>"` — the quotes only survive as long as no `AND SRC:`
2174
- * clause follows. `SRC:PMC` is likewise wrong for a PMCID lookup: EPMC's
2175
- * canonical record for a PMC-indexed article has `source: MED` and carries the
2176
- * PMCID as a field, so the filter excludes the very record being sought. DOIs
2177
- * keep their quotes — they carry slashes and dots that need them. (#85)
2178
- */
2179
- const fetchForPmid = (c) => runOne(c, `EXT_ID:${c.pmid} AND SRC:MED`, c.pmid);
2180
- const fetchForPmcid = (c) => {
2181
- const normalized = withPmcPrefix(c.pmcid);
2182
- return runOne({ c, normalized }, `PMCID:${normalized}`, undefined);
2183
- };
2184
- const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
2185
- const [pmidResults, pmcidResults, doiResults] = await Promise.all([
2186
- Promise.all(args.pmidFallbackCandidates.map(fetchForPmid)),
2187
- Promise.all(args.pmcidFallbackCandidates.map(fetchForPmcid)),
2188
- Promise.all(args.doiCandidates.map(fetchForDoi)),
2189
- ]);
2286
+ const runs = await Promise.all(owned.map(({ c, search }) => runOne(c, search)));
2190
2287
  const articles = [];
2191
2288
  const articleInputIds = new Map();
2192
- const remainingPmid = [];
2193
- const remainingPmcid = [];
2194
- const remainingDoi = [];
2195
- const pmidOutcomes = new Map();
2196
- const pmcidOutcomes = new Map();
2197
- const doiOutcomes = new Map();
2289
+ const remaining = [];
2290
+ const outcomes = new Map();
2198
2291
  const sectionFilterMisses = [];
2199
2292
  const truncatedArticles = [];
2200
2293
  let omittedSections = 0;
@@ -2207,39 +2300,22 @@ async function runEpmcStage(epmc, args) {
2207
2300
  truncatedArticles.push(run.truncation);
2208
2301
  omittedSections += run.omittedSections ?? 0;
2209
2302
  };
2210
- for (const run of pmidResults) {
2211
- pmidOutcomes.set(run.c.pmid, run.outcome);
2303
+ for (const run of runs) {
2304
+ outcomes.set(run.c.id, run.outcome);
2212
2305
  if (run.article)
2213
- collectHit(run.c.pmid, { ...run, article: run.article });
2306
+ collectHit(run.c.id, { ...run, article: run.article });
2214
2307
  // What the EPMC hit carried is evidence the next stage needs, whatever the
2215
- // fetch outcome was: its DOI spares Unpaywall a PubMed metadata round-trip
2216
- // (#119), and its title backs up Unpaywall's own record (#144).
2308
+ // fetch outcome was: its DOI spares Unpaywall a DOI lookup (#88, #119), and
2309
+ // its title backs up Unpaywall's own record (#144).
2217
2310
  else
2218
- remainingPmid.push(carryEpmcHit(run.c, run));
2219
- }
2220
- for (const run of pmcidResults) {
2221
- pmcidOutcomes.set(run.c.normalized, run.outcome);
2222
- if (run.article)
2223
- collectHit(run.c.normalized, { ...run, article: run.article });
2224
- else
2225
- remainingPmcid.push(carryEpmcHit(run.c.c, run));
2226
- }
2227
- for (const run of doiResults) {
2228
- doiOutcomes.set(run.c.doi, run.outcome);
2229
- if (run.article)
2230
- collectHit(run.c.doi, { ...run, article: run.article });
2231
- else
2232
- remainingDoi.push(carryEpmcHit(run.c, run));
2311
+ remaining.push(carryEpmcHit(run.c, run));
2233
2312
  }
2234
2313
  return {
2235
2314
  articles,
2236
2315
  articleInputIds,
2237
- remainingPmid,
2238
- remainingPmcid,
2239
- remainingDoi,
2240
- pmidOutcomes,
2241
- pmcidOutcomes,
2242
- doiOutcomes,
2316
+ joins,
2317
+ outcomes,
2318
+ remaining,
2243
2319
  sectionFilterMisses,
2244
2320
  truncatedArticles,
2245
2321
  omittedSections,
@@ -2381,8 +2457,8 @@ async function fetchPubmedDois(pmids, signal) {
2381
2457
  }
2382
2458
  /**
2383
2459
  * Resolve a DOI to an open-access article via Unpaywall. `pmcId` and `pmid`,
2384
- * when set, are stamped onto the resulting article so the branch that requested
2385
- * it carries its identifier through — Unpaywall itself only knows the DOI.
2460
+ * when set, are stamped onto the resulting article so the id that requested it
2461
+ * carries its identifier through — Unpaywall itself only knows the DOI.
2386
2462
  *
2387
2463
  * The article's title is the first one found in Unpaywall's own record, then
2388
2464
  * `epmcTitle` — the Europe PMC record the chain searched for this id — then,
@@ -2709,8 +2785,8 @@ function formatPmcArticle(a, lines, truncation, mode) {
2709
2785
  }
2710
2786
  if (a.affiliations?.length) {
2711
2787
  lines.push(`\n**Affiliations:**`);
2712
- for (const [i, aff] of a.affiliations.entries())
2713
- lines.push(`${i + 1}. ${aff}`);
2788
+ for (const aff of a.affiliations)
2789
+ lines.push(`- ${aff}`);
2714
2790
  }
2715
2791
  if (a.journal) {
2716
2792
  const parts = [];
@@ -2781,6 +2857,9 @@ function formatPmcArticle(a, lines, truncation, mode) {
2781
2857
  * `rowspan`, so a well-formed table arrives rectangular and every value renders
2782
2858
  * under the header it belongs to; a row still short of the widest is padded on
2783
2859
  * the right with empty cells only, never a neighbour's value.
2860
+ *
2861
+ * A grid row and the Footnotes line are single lines, so the `\n` a cell or the
2862
+ * footer keeps between its lines renders as {@link inlineLines} says. (#185)
2784
2863
  */
2785
2864
  function formatTables(tables, lines) {
2786
2865
  lines.push(`\n#### Tables (${tables.length})`);
@@ -2799,10 +2878,23 @@ function formatTables(tables, lines) {
2799
2878
  }
2800
2879
  if (table.rows.length > 0)
2801
2880
  lines.push(...renderTableGrid(table.rows, table.headerRowCount));
2802
- if (table.footnotes)
2803
- lines.push(`\nFootnotes: ${escapeMarkdownInline(table.footnotes)}`);
2881
+ if (table.footnotes) {
2882
+ lines.push(`\nFootnotes: ${escapeMarkdownInline(inlineLines(table.footnotes))}`);
2883
+ }
2804
2884
  }
2805
2885
  }
2886
+ /**
2887
+ * Lay a multi-line cell or footer value onto one Markdown line, its lines joined
2888
+ * by ` · ` — the joiner the header fold and the meta lines already use. A bare
2889
+ * space would run one footnote into the next, and a line per footnote would put
2890
+ * markers such as `*`, `#` and `1.` at the start of a line, where they become
2891
+ * Markdown structure. Callers escape the joined result, so emphasis pairing is
2892
+ * judged across the whole rendered line rather than one source line at a time.
2893
+ * (#185)
2894
+ */
2895
+ function inlineLines(text) {
2896
+ return text.replaceAll('\n', ' · ');
2897
+ }
2806
2898
  /**
2807
2899
  * Render every figure and supplementary item, so a `content[]` reader gets the
2808
2900
  * same caption, pointer and placement `structuredContent` carries rather than a
@@ -2868,7 +2960,7 @@ function foldHeaderColumn(headerRows, column) {
2868
2960
  /** One table's rows as Markdown grid lines, preceded by a blank line. */
2869
2961
  function renderTableGrid(rows, headerRowCount) {
2870
2962
  const columns = rows.reduce((widest, row) => Math.max(widest, row.length), 0);
2871
- const renderRow = (cells) => `| ${Array.from({ length: columns }, (_, i) => escapeMarkdownTableCell(cells[i] ?? '')).join(' | ')} |`;
2963
+ const renderRow = (cells) => `| ${Array.from({ length: columns }, (_, i) => escapeMarkdownTableCell(inlineLines(cells[i] ?? ''))).join(' | ')} |`;
2872
2964
  const headerRows = rows.slice(0, headerRowCount);
2873
2965
  const header = Array.from({ length: columns }, (_, i) => foldHeaderColumn(headerRows, i));
2874
2966
  return [