@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.22

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 (110) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +3 -3
  3. package/Dockerfile +3 -3
  4. package/README.md +14 -11
  5. package/changelog/2.10.x/2.10.21.md +42 -0
  6. package/changelog/2.10.x/2.10.22.md +44 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/_budget.d.ts +35 -6
  10. package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/_budget.js +26 -3
  12. package/dist/mcp-server/tools/definitions/_budget.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/_notices.d.ts +33 -0
  14. package/dist/mcp-server/tools/definitions/_notices.d.ts.map +1 -0
  15. package/dist/mcp-server/tools/definitions/_notices.js +35 -0
  16. package/dist/mcp-server/tools/definitions/_notices.js.map +1 -0
  17. package/dist/mcp-server/tools/definitions/_schemas.d.ts +14 -0
  18. package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/_schemas.js +9 -0
  20. package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +11 -0
  22. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +64 -19
  24. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +22 -4
  26. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +386 -167
  28. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  30. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/format-citations.tool.js +41 -8
  32. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +8 -6
  35. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +1 -1
  37. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +66 -76
  39. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -5
  41. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +11 -18
  43. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -4
  45. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +9 -8
  47. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +4 -4
  49. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/definitions/spell-check.tool.js +12 -8
  51. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  52. package/dist/services/error-contracts.d.ts +1 -1
  53. package/dist/services/error-contracts.d.ts.map +1 -1
  54. package/dist/services/error-contracts.js +1 -1
  55. package/dist/services/error-contracts.js.map +1 -1
  56. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  57. package/dist/services/europe-pmc/api-client.js +4 -3
  58. package/dist/services/europe-pmc/api-client.js.map +1 -1
  59. package/dist/services/europe-pmc/europe-pmc-service.d.ts +31 -11
  60. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  61. package/dist/services/europe-pmc/europe-pmc-service.js +122 -47
  62. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  63. package/dist/services/europe-pmc/types.d.ts +5 -1
  64. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  65. package/dist/services/fetch-redaction.d.ts +23 -0
  66. package/dist/services/fetch-redaction.d.ts.map +1 -0
  67. package/dist/services/fetch-redaction.js +62 -0
  68. package/dist/services/fetch-redaction.js.map +1 -0
  69. package/dist/services/ncbi/api-client.d.ts +3 -0
  70. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  71. package/dist/services/ncbi/api-client.js +21 -8
  72. package/dist/services/ncbi/api-client.js.map +1 -1
  73. package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
  74. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  75. package/dist/services/ncbi/formatting/citation-formatter.js +410 -138
  76. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  77. package/dist/services/ncbi/ncbi-service.d.ts +22 -0
  78. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  79. package/dist/services/ncbi/ncbi-service.js +115 -18
  80. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  81. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  82. package/dist/services/ncbi/parsing/article-parser.js +89 -36
  83. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  84. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +13 -8
  85. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  86. package/dist/services/ncbi/parsing/pmc-article-parser.js +326 -72
  87. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  88. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +42 -0
  89. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  90. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +275 -12
  91. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  92. package/dist/services/ncbi/parsing/text-helpers.d.ts +10 -0
  93. package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
  94. package/dist/services/ncbi/parsing/text-helpers.js +19 -0
  95. package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
  96. package/dist/services/ncbi/response-handler.d.ts +0 -11
  97. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  98. package/dist/services/ncbi/response-handler.js +24 -33
  99. package/dist/services/ncbi/response-handler.js.map +1 -1
  100. package/dist/services/ncbi/types.d.ts +72 -32
  101. package/dist/services/ncbi/types.d.ts.map +1 -1
  102. package/dist/services/openalex/api-client.d.ts.map +1 -1
  103. package/dist/services/openalex/api-client.js +4 -3
  104. package/dist/services/openalex/api-client.js.map +1 -1
  105. package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
  106. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  107. package/dist/services/unpaywall/unpaywall-service.js +18 -8
  108. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  109. package/package.json +7 -7
  110. package/server.json +3 -3
@@ -1,8 +1,9 @@
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 input fields, alone or together, naming at most 10 distinct
5
- * identifiers between them:
4
+ * Accepts three input fields, alone or together, naming at most 50 distinct
5
+ * identifiers between them. The first 10 in routing order are fetched; the rest
6
+ * are returned unfetched in `overLimit`, before any upstream request:
6
7
  *
7
8
  * - `pmcids` — fetch directly by PMC ID, once per record however it is
8
9
  * spelled (`PMC123`, `pmc123`, `123`, zero-padded `PMC0123`), and reported
@@ -17,12 +18,16 @@
17
18
  * DOIs are case-insensitive: every casing of one DOI runs the chain once,
18
19
  * and `unavailable[]` reports each casing as the caller wrote it.
19
20
  *
21
+ * The singular `pmcid`, `pmcId`, `pmid`, and `doi` are unadvertised aliases of
22
+ * these fields, and a lone string under any of them reads as a one-element
23
+ * array. (#221)
24
+ *
20
25
  * Routing runs `pmcids`, then `pmids`, then `dois`. Every tier keys its work on
21
26
  * the record — PMC on the PMCID, Europe PMC on the search hit's `source` + `id`,
22
27
  * Unpaywall on the lowercased DOI — and the first id to reach a record owns it:
23
28
  * 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.
29
+ * id, so the article is fetched and returned once. Each `unavailable[]` entry,
30
+ * deferred id, and over-limit id carries the field its id was sent in.
26
31
  *
27
32
  * Output uses a discriminated union on `source` (`pmc` | `unpaywall`) with an
28
33
  * extra `viaSource` discriminator that records which layer produced the
@@ -50,7 +55,7 @@ import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers
50
55
  import { toDisplayText } from '../../../services/ncbi/parsing/text-helpers.js';
51
56
  import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
52
57
  import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
53
- import { fitWholeItems } from './_budget.js';
58
+ import { buildDeferralNotice, fitWholeItems } from './_budget.js';
54
59
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
55
60
  import { doiStringSchema, normalizePmid, pmcidStringSchema, pmidStringSchema } from './_schemas.js';
56
61
  import { escapeMarkdownInline, escapeMarkdownTableCell, sliceAtWordBoundary } from './_text.js';
@@ -286,6 +291,28 @@ function applyAssetFilters(article, filters) {
286
291
  const surviving = matchedSectionTitles(article.sections, filters.sections.map(lowerCase));
287
292
  return withAssets(article, assets.filter((a) => a.sectionTitle !== undefined && surviving.has(a.sectionTitle)));
288
293
  }
294
+ /**
295
+ * The top-level sections the `sections` filter and the `maxSections` slice
296
+ * select, in order. Both act on each top-level section independently or cut a
297
+ * prefix, so applied to the body alone this returns exactly the body prefix of
298
+ * what it returns for body plus back matter — which is how {@link
299
+ * bodySectionCount} finds where the back matter starts.
300
+ */
301
+ function selectSections(sections, filters) {
302
+ const pruned = filters.sections?.length
303
+ ? pruneSections(sections, filters.sections.map(lowerCase))
304
+ : sections;
305
+ return filters.maxSections === undefined ? pruned : pruned.slice(0, filters.maxSections);
306
+ }
307
+ /**
308
+ * How many leading entries of the filtered `sections` are body sections, the
309
+ * rest being back matter: the filter applied to the parsed body alone. The depth
310
+ * clamp that follows the filter maps sections one to one, so the count holds for
311
+ * the filtered article. (#206)
312
+ */
313
+ function bodySectionCount(parsed, filters) {
314
+ return selectSections(parsed.sections, filters).length;
315
+ }
289
316
  /**
290
317
  * Apply the requested section/reference/table/asset filters, then clamp the
291
318
  * section tree to the depth the output schema carries. All of it runs here so every path
@@ -303,13 +330,7 @@ function applyAssetFilters(article, filters) {
303
330
  * folded-away section survives in the text it was folded into. (#111, #130)
304
331
  */
305
332
  function applyPmcFilters(article, filters) {
306
- let out = article;
307
- if (filters.sections?.length) {
308
- out = { ...out, sections: pruneSections(out.sections, filters.sections.map(lowerCase)) };
309
- }
310
- if (filters.maxSections !== undefined) {
311
- out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
312
- }
333
+ let out = { ...article, sections: selectSections(article.sections, filters) };
313
334
  if (!filters.includeReferences) {
314
335
  const { references: _, ...rest } = out;
315
336
  out = rest;
@@ -319,11 +340,12 @@ function applyPmcFilters(article, filters) {
319
340
  return { ...out, sections: clampSectionDepth(out.sections) };
320
341
  }
321
342
  /**
322
- * True when a `sections` filter removed every body section from an article that
323
- * actually had sections upstream — the signal that the requested headings
324
- * matched nothing, as opposed to the article genuinely shipping no body. Only
325
- * the `sections` filter can zero a non-empty list: `maxSections` carries a
326
- * `.min(1)` floor, so it never reduces to zero. (#80)
343
+ * True when a `sections` filter removed every section, back matter included,
344
+ * from an article that actually had sections upstream — the signal that the
345
+ * requested headings matched nothing, as opposed to the article genuinely
346
+ * shipping no body. Only the `sections` filter can zero a non-empty list:
347
+ * `maxSections` carries a `.min(1)` floor, so it never reduces to zero. (#80,
348
+ * #206)
327
349
  */
328
350
  function isSectionFilterMiss(before, after, sectionFilter) {
329
351
  return (Boolean(sectionFilter?.length) && before.sections.length > 0 && after.sections.length === 0);
@@ -335,10 +357,21 @@ function isSectionFilterMiss(before, after, sectionFilter) {
335
357
  * while carrying nothing to read. Distinct from {@link isSectionFilterMiss},
336
358
  * which needs a non-empty pre-filter body: the two never overlap. Evaluated
337
359
  * against the *pre-filter* article so a `sections` filter can't be mistaken for
338
- * an upstream absence. (#86)
360
+ * an upstream absence, and before {@link withBackMatter}, so acknowledgments and
361
+ * a data-availability note alone are no body and the chain still moves on to the
362
+ * next tier. (#86, #206)
363
+ */
364
+ function isBodylessArticle(parsed) {
365
+ return parsed.sections.length === 0;
366
+ }
367
+ /**
368
+ * The parsed article with its back-matter sections appended after the body
369
+ * ones, where every filter, slice, and budget downstream treats them as ordinary
370
+ * sections, and with no `backSections` field of its own left to reach the
371
+ * output. (#206)
339
372
  */
340
- function isBodylessArticle(before) {
341
- return before.sections.length === 0;
373
+ function withBackMatter({ backSections, ...article }) {
374
+ return backSections ? { ...article, sections: [...article.sections, ...backSections] } : article;
342
375
  }
343
376
  /** Pick the best human-readable identifier for an article, for recovery notices
344
377
  * and character-budget accounting. Treats empty strings as absent — EPMC-only
@@ -359,7 +392,7 @@ function articleDisplayId(a) {
359
392
  function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
360
393
  const terms = sectionFilter.join(', ');
361
394
  const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
362
- return `No section or subsection title, at any nesting depth, matched the requested section filter (${terms}) for ${subject}. The full text was retrieved but every body section was filtered out. Retry without \`sections\`, or filter on broader headings such as Introduction, Methods, Results, or Discussion.`;
395
+ return `No section or subsection title, at any nesting depth, matched the requested section filter (${terms}) for ${subject}. The full text was retrieved but every section, back matter included, was filtered out. Retry without \`sections\`, or filter on broader headings such as Introduction, Methods, Results, or Discussion.`;
363
396
  }
364
397
  /**
365
398
  * Compose the recovery notice for identifiers whose only retrievable record was
@@ -410,12 +443,16 @@ const SectionSchema = z
410
443
  text: z.string().describe('Section body text'),
411
444
  subsections: z.array(SubsectionSchema).optional().describe('Nested subsections'),
412
445
  })
413
- .describe('Article body section');
446
+ .describe('Article section, from the body or from the back matter after it');
414
447
  const AuthorSchema = z
415
448
  .object({
416
449
  collectiveName: z.string().optional().describe('Group name'),
417
450
  givenNames: z.string().optional().describe('Given names'),
418
451
  lastName: z.string().optional().describe('Last name'),
452
+ suffix: z
453
+ .string()
454
+ .optional()
455
+ .describe('Generational name suffix verbatim from the JATS `<suffix>` (e.g. "Jr", "IV"), never normalized. Absent when the deposit gives none.'),
419
456
  })
420
457
  .describe('Author entry');
421
458
  const JournalSchema = z
@@ -457,7 +494,10 @@ const ReferenceSchema = z
457
494
  */
458
495
  const TableSchema = z
459
496
  .object({
460
- label: z.string().optional().describe('Table label as printed, e.g. `TABLE 1`'),
497
+ label: z
498
+ .string()
499
+ .optional()
500
+ .describe('Table label as printed, e.g. `TABLE 1`, less one trailing ` |`, `.` or `:` (`Table 1.` reads `Table 1`)'),
461
501
  caption: z.string().optional().describe('Caption text, with the label excluded'),
462
502
  id: z
463
503
  .string()
@@ -466,7 +506,7 @@ const TableSchema = z
466
506
  sectionTitle: z
467
507
  .string()
468
508
  .optional()
469
- .describe('Title of the innermost section enclosing the table, wherever that section sits — body, `<back>` matter, or an appendix all count, and in back matter the section name is the only positional cue there is. Absent only for a table inside no section at all, such as a `<floats-group>` deposit.'),
509
+ .describe('Title of the innermost titled section enclosing the table — a body section or a back-matter one such as an appendix — as that section is titled in `sections`, or, for a heading nested deeper than `sections` carries, as its heading line inside the text of the section that contains it. A `sections` filter keeps the table with that section. In back matter the section name is the only positional cue there is. Absent for a table inside no titled section, such as a `<floats-group>` deposit.'),
470
510
  headerRowCount: z
471
511
  .number()
472
512
  .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.'),
@@ -505,7 +545,10 @@ const AssetSchema = z
505
545
  assetType: z
506
546
  .enum(['figure', 'supplementary-material'])
507
547
  .describe('Which captioned element this came from — `figure` for a `<fig>`, `supplementary-material` for a `<supplementary-material>` deposit'),
508
- label: z.string().optional().describe('Display label as printed, e.g. `Fig. 1`'),
548
+ label: z
549
+ .string()
550
+ .optional()
551
+ .describe('Display label as printed, e.g. `Fig. 1`, less one trailing ` |`, `.` or `:` (`Figure 1.` reads `Figure 1`). Some supplements carry a bare number (`1`), kept as deposited here'),
509
552
  caption: z.string().optional().describe('Caption text, with the label excluded'),
510
553
  id: z
511
554
  .string()
@@ -514,7 +557,7 @@ const AssetSchema = z
514
557
  sectionTitle: z
515
558
  .string()
516
559
  .optional()
517
- .describe('Title of the innermost section enclosing the asset, wherever that section sits — body, `<back>` matter, or an appendix all count. Absent for an asset inside no section at all, such as a `<floats-group>` deposit.'),
560
+ .describe('Title of the innermost titled section enclosing the asset — a body section or a back-matter one such as an appendix — as that section is titled in `sections`, or, for a heading nested deeper than `sections` carries, as its heading line inside the text of the section that contains it. Absent for an asset inside no titled section, such as a `<floats-group>` deposit.'),
518
561
  href: z
519
562
  .string()
520
563
  .optional()
@@ -555,7 +598,9 @@ const PmcArticleSchema = z
555
598
  keywords: z.array(z.string()).optional().describe('Keywords'),
556
599
  articleType: z.string().optional().describe('Article type'),
557
600
  publicationDate: PublicationDateSchema.optional(),
558
- sections: z.array(SectionSchema).describe('Article body sections'),
601
+ sections: z
602
+ .array(SectionSchema)
603
+ .describe('Article sections in source order: the body, then the back matter — acknowledgments, notes and declarations, footnotes, glossary, appendices. A back-matter element with no heading of its own is titled by its kind, such as `Footnotes` or `Appendix`.'),
559
604
  tables: z
560
605
  .array(TableSchema)
561
606
  .optional()
@@ -563,7 +608,7 @@ const PmcArticleSchema = z
563
608
  assets: z
564
609
  .array(AssetSchema)
565
610
  .optional()
566
- .describe('Every `<fig>` and `<supplementary-material>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Each one lifted from the body leaves a `[Figure: <label>]` or `[Supplementary: <label>]` marker at its position in the section text, so reading order survives the lift. Absent when the article deposits none, when `includeAssets` is false, or when a `sections` filter left none standing.'),
611
+ .describe('Every `<fig>` and `<supplementary-material>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Each one lifted from a section, body or back matter, leaves a `[Figure: <label>]` or `[Supplementary: <label>]` marker at its position in the section text, so reading order survives the lift. Absent when the article deposits none, when `includeAssets` is false, or when a `sections` filter left none standing.'),
567
612
  references: z.array(ReferenceSchema).optional().describe('Reference list'),
568
613
  epmcId: z
569
614
  .string()
@@ -738,7 +783,7 @@ const TruncatedSectionSchema = z
738
783
  .optional()
739
784
  .describe('Per-subsection accounting for a shortened section, in document order, including subsections dropped for budget — where inside the section the cut landed. Absent when the section was returned whole or carries no subsections.'),
740
785
  })
741
- .describe('Character accounting for one body section of a budgeted article');
786
+ .describe('Character accounting for one section of a budgeted article');
742
787
  const TruncatedArticleSchema = z
743
788
  .object({
744
789
  id: z
@@ -746,7 +791,7 @@ const TruncatedArticleSchema = z
746
791
  .describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
747
792
  source: z
748
793
  .enum(['pmc', 'unpaywall'])
749
- .describe('Which output shape was budgeted: `pmc` budgets body sections and subsections, `unpaywall` budgets the single `content` body'),
794
+ .describe('Which output shape was budgeted: `pmc` budgets sections and subsections, `unpaywall` budgets the single `content` body'),
750
795
  originalCharacters: z
751
796
  .number()
752
797
  .describe('Body characters this article carried before the budget pass'),
@@ -766,11 +811,11 @@ const TruncatedArticleSchema = z
766
811
  omittedAssets: z
767
812
  .number()
768
813
  .optional()
769
- .describe('Figures and supplementary items this article dropped whole because the budget left no room once sections and tables were served. An asset is never returned with a truncated caption, so it is either returned complete or counted here. Absent when none were dropped.'),
814
+ .describe('Figures and supplementary items this article dropped whole because the budget left no room once body sections and tables were served. An asset is never returned with a truncated caption, so it is either returned complete or counted here. Absent when none were dropped.'),
770
815
  omittedAssetNames: z
771
816
  .array(z.string())
772
817
  .optional()
773
- .describe("The dropped assets by name, in document order — each asset's label, else its `id`, else `asset <n>` for its position in the article. Contiguous for the same reason `omittedTableNames` is: admission stops at the first asset that did not fit rather than skipping ahead to a smaller one. Absent when none were dropped."),
818
+ .describe("The dropped assets by name, in document order — each asset's label (a bare-number label led by its type, `Supplementary material 1`), else its `id`, else `asset <n>` for its position in the article. Contiguous for the same reason `omittedTableNames` is: admission stops at the first asset that did not fit rather than skipping ahead to a smaller one. Absent when none were dropped."),
774
819
  })
775
820
  .describe('Character accounting for one article the budget shortened');
776
821
  const TruncationSchema = z
@@ -791,7 +836,7 @@ const TruncationSchema = z
791
836
  .describe('Body characters the shortened articles carry in this response'),
792
837
  omittedSections: z
793
838
  .number()
794
- .describe('Body sections and subsections dropped entirely because an article budget was exhausted before reaching them. A dropped section counts once, together with its subsections. Always 0 in `outline` mode, which keeps every heading.'),
839
+ .describe('Sections and subsections, back matter included, dropped entirely because an article budget was exhausted before reaching them. Back matter is reached last, after tables and assets. A dropped section counts once, together with its subsections. Always 0 in `outline` mode, which keeps every heading.'),
795
840
  omittedTables: z
796
841
  .number()
797
842
  .optional()
@@ -822,24 +867,50 @@ const DeferredSchema = z
822
867
  .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.'),
823
868
  ids: z
824
869
  .array(z.string())
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`.'),
870
+ .describe('Identifiers of the deferred articles, in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — 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
871
  pmcids: z
827
872
  .array(z.string())
828
873
  .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.'),
874
+ .describe('The deferred ids sent as PMC IDs, in routing 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
875
  pmids: z
831
876
  .array(z.string())
832
877
  .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.'),
878
+ .describe('The deferred ids sent as PMIDs, in routing 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
879
  dois: z
835
880
  .array(z.string())
836
881
  .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.'),
882
+ .describe('The deferred ids sent as DOIs, in routing 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.'),
838
883
  nextDeferredCharacters: z
839
884
  .number()
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.'),
885
+ .describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped — and the continuation ceiling: re-calling with the deferred ids needs `maxResponseCharacters` ≥ this to return anything, and a smaller article further down `ids` cannot be reached until this one fits. Repeating this same call needs `maxResponseCharacters` ≥ `returnedCharacters` + this to return this article as well.'),
841
886
  })
842
887
  .describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
888
+ const OverLimitSchema = z
889
+ .object({
890
+ limit: z
891
+ .number()
892
+ .describe('Distinct identifiers one call fetches. The first this many, in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — were fetched; the ids below were not.'),
893
+ idType: z
894
+ .enum(['pmid', 'pmcid', 'doi'])
895
+ .optional()
896
+ .describe('The input field every over-limit id was sent in — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Absent when the over-limit ids came from more than one field; `pmcids` / `pmids` / `dois` then group them.'),
897
+ ids: z
898
+ .array(z.string())
899
+ .describe('Identifiers past the per-call limit, none of them fetched, in routing order — each listed once, under the first spelling sent (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, at most `limit` per call. Never contains an id from `unavailable` or `deferred`.'),
900
+ pmcids: z
901
+ .array(z.string())
902
+ .optional()
903
+ .describe('The over-limit ids sent as PMC IDs, in routing order — re-submit them as `pmcids`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a PMC ID.'),
904
+ pmids: z
905
+ .array(z.string())
906
+ .optional()
907
+ .describe('The over-limit ids sent as PMIDs, in routing order — re-submit them as `pmids`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a PMID.'),
908
+ dois: z
909
+ .array(z.string())
910
+ .optional()
911
+ .describe('The over-limit ids sent as DOIs, in routing order — re-submit them as `dois`. Present only when the over-limit ids came from more than one field (so `idType` is absent) and one of them was a DOI.'),
912
+ })
913
+ .describe('Identifiers the call named past the per-call fetch limit, returned unfetched for a follow-up call. Present only when the call named more than `limit` distinct identifiers. Separate from `deferred`, which lists articles that were fetched but withheld by `maxResponseCharacters`.');
843
914
  /** True when the request asked for any budget at all. Without one, every budget
844
915
  * helper returns its input untouched so the response is byte-identical. */
845
916
  function budgetRequested(budget) {
@@ -1045,11 +1116,16 @@ function allotSectionBudgets(sizes, budget) {
1045
1116
  * or cut — the budget spends on body text and table content, keeping every
1046
1117
  * article citable.
1047
1118
  *
1048
- * Body sections are served first, then tables, then assets, each spending what
1049
- * `maxCharacters` has left, in document order: an item that does not fit is
1050
- * dropped whole and counted, never truncated into a partial grid or a caption cut
1051
- * short. With no `maxCharacters` — a bare `maxCharactersPerSection` request —
1052
- * nothing bounds either list and every entry is kept. (#111, #130)
1119
+ * Body sections are served first, then tables, then assets, then back-matter
1120
+ * sections, each spending what `maxCharacters` has left, in document order. A
1121
+ * table or asset that does not fit is dropped whole and counted, never
1122
+ * truncated into a partial grid or a caption cut short. Back matter comes last
1123
+ * so it never displaces a table or figure: it is shortened or dropped the way
1124
+ * body sections are, from whatever the rest left. The first `bodySections`
1125
+ * entries of `article.sections` are the body; spend order aside, `sections` and
1126
+ * the ledger stay in document order. With no `maxCharacters` — a bare
1127
+ * `maxCharactersPerSection` request — nothing bounds the tables or assets and
1128
+ * every entry is kept. (#111, #130, #206)
1053
1129
  *
1054
1130
  * Returns the article untouched (same object identity) when no budget was
1055
1131
  * requested or nothing exceeded it. A section or subsection left with zero
@@ -1057,7 +1133,7 @@ function allotSectionBudgets(sizes, budget) {
1057
1133
  * keeps it as a heading-only entry. Dropped nodes still appear in the accounting
1058
1134
  * so the caller can see which headings exist. (#81, #143)
1059
1135
  */
1060
- function applyPmcBudget(article, budget) {
1136
+ function applyPmcBudget(article, budget, bodySections) {
1061
1137
  const tables = article.tables ?? [];
1062
1138
  const assets = article.assets ?? [];
1063
1139
  if (!budgetRequested(budget) ||
@@ -1068,31 +1144,37 @@ function applyPmcBudget(article, budget) {
1068
1144
  const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
1069
1145
  const assetsOriginal = assets.reduce((sum, asset) => sum + assetCharacters(asset), 0);
1070
1146
  const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal + assetsOriginal;
1071
- const allowances = allotSectionBudgets(sizes, budget);
1072
1147
  const kept = [];
1073
1148
  const sectionReports = [];
1074
1149
  let returnedCharacters = 0;
1075
- article.sections.forEach((section, i) => {
1076
- const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
1077
- const fit = fitSectionTree(section, fitted, { i: 0 }, budget.overflowMode);
1078
- returnedCharacters += fit.entry.returnedCharacters;
1079
- sectionReports.push(fit.entry);
1080
- if (fit.kept)
1081
- kept.push(fit.kept);
1082
- });
1083
- const omittedSections = countDroppedSections(sectionReports, budget.overflowMode);
1084
- // Sections are served first, then tables, then assets — each spending whatever
1085
- // `maxCharacters` has left. A bare per-section budget sets no total, so nothing
1086
- // bounds either list.
1150
+ // Each stage spends whatever `maxCharacters` has left. A bare per-section
1151
+ // budget sets no total, so nothing bounds the tables or assets.
1087
1152
  const remainingAllowance = () => budget.maxCharacters === undefined
1088
1153
  ? Number.POSITIVE_INFINITY
1089
1154
  : Math.max(budget.maxCharacters - returnedCharacters, 0);
1155
+ const fitSections = (sections, total) => {
1156
+ const allowances = allotSectionBudgets(sections.map(sectionCharacters), {
1157
+ ...budget,
1158
+ maxCharacters: total,
1159
+ });
1160
+ sections.forEach((section, i) => {
1161
+ const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
1162
+ const fit = fitSectionTree(section, fitted, { i: 0 }, budget.overflowMode);
1163
+ returnedCharacters += fit.entry.returnedCharacters;
1164
+ sectionReports.push(fit.entry);
1165
+ if (fit.kept)
1166
+ kept.push(fit.kept);
1167
+ });
1168
+ };
1169
+ fitSections(article.sections.slice(0, bodySections), budget.maxCharacters);
1090
1170
  const fittedTables = fitWholeNamed(tables, remainingAllowance(), tableCharacters, tableDisplayName);
1091
1171
  returnedCharacters += fittedTables.spent;
1092
1172
  const omittedTables = fittedTables.omittedNames.length;
1093
1173
  const fittedAssets = fitWholeNamed(assets, remainingAllowance(), assetCharacters, assetDisplayName);
1094
1174
  returnedCharacters += fittedAssets.spent;
1095
1175
  const omittedAssets = fittedAssets.omittedNames.length;
1176
+ fitSections(article.sections.slice(bodySections), budget.maxCharacters === undefined ? undefined : remainingAllowance());
1177
+ const omittedSections = countDroppedSections(sectionReports, budget.overflowMode);
1096
1178
  if (returnedCharacters === originalCharacters &&
1097
1179
  omittedSections === 0 &&
1098
1180
  omittedTables === 0 &&
@@ -1145,7 +1227,23 @@ function tableDisplayName(table, index) {
1145
1227
  }
1146
1228
  /** How an asset is named in a notice: its label, else its id, else its position. */
1147
1229
  function assetDisplayName(asset, index) {
1148
- return asset.label ?? asset.id ?? `asset ${index + 1}`;
1230
+ return assetLabelName(asset) ?? asset.id ?? `asset ${index + 1}`;
1231
+ }
1232
+ /** The word a bare-number asset label is named with, by asset type. */
1233
+ const ASSET_TYPE_NAMES = {
1234
+ figure: 'Figure',
1235
+ 'supplementary-material': 'Supplementary material',
1236
+ };
1237
+ /**
1238
+ * An asset's label as a name in a list or heading, its type leading a label that
1239
+ * is a bare number: NIH-manuscript supplements are labelled `1`, which reads as a
1240
+ * count among names (`7 asset(s) dropped whole: 1, Figure 1`). `label` and the
1241
+ * prose marker keep the number as deposited — the marker (`[Supplementary: 1]`)
1242
+ * names the kind already. (#240)
1243
+ */
1244
+ function assetLabelName(asset) {
1245
+ const { label } = asset;
1246
+ return label && /^\d+$/.test(label) ? `${ASSET_TYPE_NAMES[asset.assetType]} ${label}` : label;
1149
1247
  }
1150
1248
  /**
1151
1249
  * Collect the returned tables that carry no cell values. Read off the articles
@@ -1225,33 +1323,38 @@ function buildTruncationNotice(truncation) {
1225
1323
  return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted}${omittedTables}${omittedAssets} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
1226
1324
  }
1227
1325
  /**
1228
- * Compose the recovery notice for articles the whole-response budget withheld.
1229
- * Names what was spent, which identifiers are still retrievable, and the ceiling
1230
- * the next call has to clear — so a caller reading only `content[]` can resume
1231
- * without inspecting `deferred`. (#100)
1326
+ * Compose the recovery notice for identifiers past the per-call fetch limit:
1327
+ * how many were left unfetched, which ones, and where to resend them — so a
1328
+ * caller reading only `content[]` can retrieve the rest without inspecting
1329
+ * `overLimit`. (#222)
1232
1330
  */
1233
- function buildDeferralNotice(deferred) {
1234
- const spent = deferred.returnedCharacters === 0
1235
- ? `The first article alone exceeds the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
1236
- : `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
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.`;
1331
+ function buildOverLimitNotice(overLimit) {
1332
+ const resend = overLimit.idType
1333
+ ? `Re-call pubmed_fetch_fulltext with those ids under \`${overLimit.idType}s\``
1334
+ : `Re-call pubmed_fetch_fulltext with each id under its own field (${idFieldLists(overLimit)})`;
1335
+ return `This call fetched the first ${overLimit.limit} distinct identifiers; ${overLimit.ids.length} more were not fetched: ${overLimit.ids.join(', ')}. ${resend}, at most ${overLimit.limit} per call, to retrieve them.`;
1241
1336
  }
1242
1337
  /**
1243
- * The per-field lists of a deferral that spans fields, in field order —
1338
+ * The per-field lists of ids handed back across fields, in field order —
1244
1339
  * `` `pmcids`: PMC1; `dois`: 10.1/x `` — or `''` when `idType` covers them all.
1245
1340
  */
1246
- function deferredFieldLists(deferred) {
1341
+ function idFieldLists(group) {
1247
1342
  return ['pmcids', 'pmids', 'dois']
1248
1343
  .flatMap((field) => {
1249
- const ids = deferred[field];
1344
+ const ids = group[field];
1250
1345
  return ids?.length ? [`\`${field}\`: ${ids.join(', ')}`] : [];
1251
1346
  })
1252
1347
  .join('; ');
1253
1348
  }
1254
1349
  // ─── Tool Definition ─────────────────────────────────────────────────────────
1350
+ /**
1351
+ * Distinct identifiers one call fetches across `pmcids`, `pmids`, and `dois`,
1352
+ * in routing order; the rest are returned unfetched in `overLimit`. Bounds the
1353
+ * candidates every tier works per call. (#192, #222)
1354
+ */
1355
+ const FETCH_LIMIT = 10;
1356
+ /** Distinct identifiers one call may name across the three fields. (#222) */
1357
+ const MAX_IDENTIFIERS = 50;
1255
1358
  /**
1256
1359
  * Compose the tool description for the fallback tiers enabled in this
1257
1360
  * deployment. PMC EFetch is always present; Europe PMC (`EUROPEPMC_ENABLED`)
@@ -1260,7 +1363,7 @@ function deferredFieldLists(deferred) {
1260
1363
  * recoveries that silently can't happen.
1261
1364
  */
1262
1365
  export function buildFulltextDescription(tiers) {
1263
- const base = 'Fetch full-text articles from PubMed Central with structured sections, tables, and references.';
1366
+ const base = 'Fetch full-text articles from PubMed Central as structured sections — the body, then back matter such as acknowledgments, declarations, and appendices — with tables and references.';
1264
1367
  const epmcClause = 'Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart)';
1265
1368
  const unpaywallClause = 'Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text';
1266
1369
  let fallback;
@@ -1284,13 +1387,11 @@ export function buildFulltextDescription(tiers) {
1284
1387
  : tiers.unpaywall
1285
1388
  ? '; DOIs with no PMC copy recover via Unpaywall open access'
1286
1389
  : '';
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.`;
1390
+ 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 ${MAX_IDENTIFIERS} distinct identifiers; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`; an article several of them name is fetched and returned once.`;
1288
1391
  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.';
1289
1392
  return `${base} ${fallback} ${input} ${budget}`;
1290
1393
  }
1291
1394
  const serverConfig = getServerConfig();
1292
- /** Distinct identifiers one call may name across `pmcids`, `pmids`, and `dois`. (#192) */
1293
- const MAX_IDENTIFIERS = 10;
1294
1395
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1295
1396
  description: buildFulltextDescription({
1296
1397
  europePmc: serverConfig.europepmcEnabled,
@@ -1314,26 +1415,30 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1314
1415
  retryable: false,
1315
1416
  },
1316
1417
  ],
1418
+ // Never advertised; rewritten to the canonical key before the schema parses.
1419
+ // Singular spellings sibling tools and this tool's own output use; a lone
1420
+ // string under one becomes a one-element array. (#221)
1421
+ inputAliases: { pmcid: 'pmcids', pmcId: 'pmcids', pmid: 'pmids', doi: 'dois' },
1317
1422
  input: z
1318
1423
  .object({
1319
1424
  pmcids: z
1320
1425
  .array(pmcidStringSchema)
1321
1426
  .min(1)
1322
- .max(10)
1427
+ .max(MAX_IDENTIFIERS)
1323
1428
  .optional()
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.'),
1429
+ .describe(`PMC IDs to fetch (e.g. ["PMC9575052"]). Combinable with \`pmids\` and \`dois\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.`),
1325
1430
  pmids: z
1326
1431
  .array(pmidStringSchema)
1327
1432
  .min(1)
1328
- .max(10)
1433
+ .max(MAX_IDENTIFIERS)
1329
1434
  .optional()
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.'),
1435
+ .describe(`PubMed IDs. Combinable with \`pmcids\` and \`dois\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. 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.`),
1331
1436
  dois: z
1332
1437
  .array(doiStringSchema)
1333
1438
  .min(1)
1334
- .max(10)
1439
+ .max(MAX_IDENTIFIERS)
1335
1440
  .optional()
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.'),
1441
+ .describe(`DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]), one per element. Combinable with \`pmcids\` and \`pmids\` — up to ${MAX_IDENTIFIERS} distinct identifiers across the three; the first ${FETCH_LIMIT} are fetched per call, the rest returned in \`overLimit\`. 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.`),
1337
1442
  includeReferences: z
1338
1443
  .boolean()
1339
1444
  .default(false)
@@ -1350,48 +1455,40 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1350
1455
  .number()
1351
1456
  .int()
1352
1457
  .min(1)
1353
- .max(50)
1354
1458
  .optional()
1355
- .describe('Maximum top-level body sections. Applies to `source=pmc` results only.'),
1459
+ .describe('Maximum top-level sections. Back-matter sections count and follow the body, so a cap drops them first. Applies to `source=pmc` results only.'),
1356
1460
  sections: z
1357
1461
  .array(z.string())
1358
1462
  .optional()
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.'),
1463
+ .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". Back-matter sections match the same way: by their own titles, or by their kind when they carry neither a title nor a label — "appendix" matches a section titled "Appendix A", "footnotes" an untitled footnote group. A label is never matched, so an appendix labelled "Appendix A" under a title of its own matches only on that title. 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.'),
1360
1464
  maxCharacters: z
1361
1465
  .number()
1362
1466
  .int()
1363
1467
  .min(1)
1364
- .max(1_000_000)
1365
1468
  .optional()
1366
- .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text — which carries the inline blocks the parser renders in place, such as lists, definition lists, block quotes, boxed text, preformatted blocks and displayed formulae — plus table label, caption, cell and footnote text and asset label, caption and `href` text; or the `source=unpaywall` `content` body. Titles, abstracts, identifiers, and references are never counted or shortened. Shortened text ends at the last word boundary inside its allowance, so it can come back a few characters under it. The counted unit is that text alone — the Markdown grid `content[]` renders around the cells (pipes, padding, the divider row, headings) is scaffolding this budget does not measure, so a table renders longer than it costs here. Sections are served first, then tables, then assets, each spending what is left, in document order — admission stops at the first entry that does not fit, and every entry from there on is dropped whole rather than cut mid-row or returned with a shortened caption, counted in `truncation.omittedTables` / `truncation.omittedAssets` and named in `truncation.articles[].omittedTableNames` / `omittedAssetNames`. Applied after `sections`, `maxSections`, `includeReferences`, `includeTables`, and `includeAssets`, so semantic filtering is unaffected. This knob alone bounds only bodies: the response-wide ceiling it implies is this value times the number of articles returned, plus every uncounted field. Use `maxResponseCharacters` for a true whole-response ceiling. Omit for the full body.'),
1469
+ .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text — which carries the inline blocks the parser renders in place, such as lists, definition lists, block quotes, boxed text, preformatted blocks and displayed formulae — plus table label, caption, cell and footnote text and asset label, caption and `href` text; or the `source=unpaywall` `content` body. Titles, abstracts, identifiers, and references are never counted or shortened. Shortened text ends at the last word boundary inside its allowance, so it can come back a few characters under it. The counted unit is that text alone — the Markdown grid `content[]` renders around the cells (pipes, padding, the divider row, headings) is scaffolding this budget does not measure, so a table renders longer than it costs here. Body sections are served first, then tables, then assets, then back-matter sections, each spending what is left, in document order. For tables and assets, admission stops at the first entry that does not fit, and every entry from there on is dropped whole rather than cut mid-row or returned with a shortened caption, counted in `truncation.omittedTables` / `truncation.omittedAssets` and named in `truncation.articles[].omittedTableNames` / `omittedAssetNames`. Back matter takes only what remains and is shortened or dropped as body sections are; `sections` still returns it after the body. Applied after `sections`, `maxSections`, `includeReferences`, `includeTables`, and `includeAssets`, so semantic filtering is unaffected. This knob alone bounds only bodies: the response-wide ceiling it implies is this value times the number of articles returned, plus every uncounted field. Use `maxResponseCharacters` for a true whole-response ceiling. Omit for the full body.'),
1367
1470
  maxCharactersPerSection: z
1368
1471
  .number()
1369
1472
  .int()
1370
1473
  .min(1)
1371
- .max(1_000_000)
1372
1474
  .optional()
1373
- .describe('Budget for a single top-level body section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
1475
+ .describe('Budget for a single top-level section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
1374
1476
  maxResponseCharacters: z
1375
1477
  .number()
1376
1478
  .int()
1377
1479
  .min(1)
1378
- .max(1_000_000)
1379
1480
  .optional()
1380
- .describe('Opt-in ceiling for the whole response, in characters — the true response-wide counterpart to the per-article `maxCharacters`. Each article is measured as the JSON record it is returned as, after every filter and the per-article body budget: title, abstract, body sections, references, identifiers, license and source metadata — every field it carries. One ledger covers all tiers, so PMC-, Europe PMC-, and Unpaywall-served articles spend the same budget. Articles are kept in response order until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailable`, `truncation`, `deferred` itself — are not counted. Omit to return every resolved article.'),
1481
+ .describe('Opt-in ceiling for the whole response, in characters — the true response-wide counterpart to the per-article `maxCharacters`. Each article is measured as the JSON record it is returned as, after every filter and the per-article body budget: title, abstract, sections, references, identifiers, license and source metadata — every field it carries. One ledger covers all tiers, so PMC-, Europe PMC-, and Unpaywall-served articles spend the same budget. Articles are kept in the order `articles` lists them until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailable`, `truncation`, `deferred` itself — are not counted. Omit to return every resolved article.'),
1381
1482
  overflowMode: z
1382
1483
  .enum(['truncate', 'outline'])
1383
1484
  .default('truncate')
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.'),
1485
+ .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. In both modes the body sections spend the budget first and back-matter sections share what the body, tables, and assets leave. 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.'),
1385
1486
  })
1386
1487
  // 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)
1488
+ // ceiling is settled before any upstream request; the handler then fetches
1489
+ // the first FETCH_LIMIT the same way. (#192, #222)
1389
1490
  .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
- ]);
1491
+ const distinct = new Set(requestedIds(v).map((r) => r.key));
1395
1492
  if (distinct.size === 0) {
1396
1493
  refinement.addIssue({
1397
1494
  code: 'custom',
@@ -1401,38 +1498,41 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1401
1498
  else if (distinct.size > MAX_IDENTIFIERS) {
1402
1499
  refinement.addIssue({
1403
1500
  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.`,
1501
+ message: `\`pmcids\`, \`pmids\`, and \`dois\` together name ${distinct.size} distinct identifiers; a call accepts at most ${MAX_IDENTIFIERS} and fetches the first ${FETCH_LIMIT} — split them across calls.`,
1405
1502
  });
1406
1503
  }
1407
1504
  }),
1408
1505
  output: z.object({
1409
- articles: z.array(ArticleSchema).describe('Full-text articles'),
1506
+ articles: z
1507
+ .array(ArticleSchema)
1508
+ .describe('Full-text articles in routing order — `pmcids`, then `pmids`, then `dois`, request order within each — each at the position of the id it would be deferred under, whichever tier served it. An article several ids name appears once.'),
1410
1509
  totalReturned: z
1411
1510
  .number()
1412
1511
  .describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
1413
1512
  unavailable: z
1414
1513
  .array(UnavailableSchema)
1415
1514
  .optional()
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.'),
1515
+ .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. Ids past the per-call limit are listed in `overLimit`, never here.'),
1417
1516
  truncation: TruncationSchema.optional(),
1418
1517
  deferred: DeferredSchema.optional(),
1518
+ overLimit: OverLimitSchema.optional(),
1419
1519
  }),
1420
- // Recovery guidance for five cases — a `sections` filter that removed every
1421
- // body section (#80), a record the chain could only retrieve as front matter
1520
+ // Recovery guidance for six cases — a `sections` filter that removed every
1521
+ // section (#80), a record the chain could only retrieve as front matter
1422
1522
  // (#86), a table returned with no cell values (#111), a body the per-article
1423
- // character budget shortened (#81), and articles the whole-response budget
1424
- // withheld (#100). Agent-facing context surfaced via
1425
- // ctx.enrich.notice() to structuredContent and content[]; absent when none
1426
- // applies.
1523
+ // character budget shortened (#81), articles the whole-response budget
1524
+ // withheld (#100), and identifiers past the per-call fetch limit (#222).
1525
+ // Agent-facing context surfaced via ctx.enrich.notice() to structuredContent
1526
+ // and content[]; absent when none applies.
1427
1527
  enrichment: {
1428
1528
  notice: z
1429
1529
  .string()
1430
1530
  .optional()
1431
- .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A table returned with no cell values names the affected table(s), the article each came from, and why the cells cannot be recovered. A budgeted response names the characters returned versus carried and points at `truncation`. A response-wide budget that deferred articles names the ids to re-request. Absent when none of those applies.'),
1531
+ .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A table returned with no cell values names the affected table(s), the article each came from, and why the cells cannot be recovered. A budgeted response names the characters returned versus carried and points at `truncation`. A response-wide budget that deferred articles names the ids to re-request. Identifiers past the per-call limit are named with the field to resend each under. Absent when none of those applies.'),
1432
1532
  truncated: z
1433
1533
  .boolean()
1434
1534
  .optional()
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`.'),
1535
+ .describe('True when a character budget shortened at least one returned body or withheld a whole article, or when the call named more distinct identifiers than one call fetches. Absent when every requested identifier was fetched and 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`; the unfetched ids are in `overLimit`.'),
1436
1536
  },
1437
1537
  async handler(input, ctx) {
1438
1538
  const idCount = (input.pmcids?.length ?? 0) + (input.pmids?.length ?? 0) + (input.dois?.length ?? 0);
@@ -1450,6 +1550,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1450
1550
  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
1551
  }
1452
1552
  const filters = { ...input, sections: sectionFilter.terms };
1553
+ // Settled before any upstream request: every tier below works the fetched
1554
+ // ids alone, and the rest are handed back in `overLimit`. (#222)
1555
+ const { fetched, overLimit: overLimitIds } = splitAtFetchLimit(input);
1453
1556
  // ── Chain tracking ──────────────────────────────────────────────────────
1454
1557
  // Per-input-id tier history (the `triedTiers` array on unavailable entries).
1455
1558
  // Keys: canonical PMID for a `pmids` entry, prefixed PMCID for a `pmcids`
@@ -1477,9 +1580,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1477
1580
  // `dois` entry that named it — so a PMC miss can still reach Unpaywall
1478
1581
  // without another lookup, whichever field owns the record.
1479
1582
  const doiByPmcRecord = new Map();
1480
- // Ids of returned articles whose `sections` filter removed every body
1481
- // section — collected across the PMC and EPMC stages to drive one recovery
1482
- // notice via ctx.enrich.notice (#80).
1583
+ // Ids of returned articles whose `sections` filter removed every section —
1584
+ // collected across the PMC and EPMC stages to drive one recovery notice via
1585
+ // ctx.enrich.notice (#80).
1483
1586
  const sectionFilterMisses = [];
1484
1587
  // Input ids whose PMC or EPMC record carried no body sections at all. Those
1485
1588
  // records are not full-text hits, so the chain continues past them; ids still
@@ -1574,7 +1677,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1574
1677
  };
1575
1678
  // `PMC123`, `pmc123`, `123` and `PMC0123` name one record; it runs the
1576
1679
  // chain once, keyed and reported in `PMC<digits>` form. (#170)
1577
- for (const id of input.pmcids ?? []) {
1680
+ for (const id of fetched.pmcids) {
1578
1681
  const normalized = normalizePmcId(id);
1579
1682
  const key = withPmcPrefix(normalized);
1580
1683
  if (!chainByInput.has(key))
@@ -1585,9 +1688,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1585
1688
  // The ID Converter parses `00000001` as PMID 1 yet reports it "not found
1586
1689
  // in PMC", and every later stage answers with NCBI's own PMID, so the chain
1587
1690
  // runs once per distinct PMID in its canonical form. (#161)
1588
- for (const id of input.pmids ?? [])
1691
+ for (const id of fetched.pmids)
1589
1692
  addCallerId(normalizePmid(id), { id, idType: 'pmid' });
1590
- const pmids = [...new Set((input.pmids ?? []).map(normalizePmid))];
1693
+ const pmids = [...new Set(fetched.pmids.map(normalizePmid))];
1591
1694
  // DOIs resolve through the converter too, so PMC-indexed DOIs reach PMC
1592
1695
  // EFetch instead of going straight to the EPMC/Unpaywall fallback (which
1593
1696
  // misses articles whose only OA copy is the PMC JATS).
@@ -1597,7 +1700,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1597
1700
  // DOI shares one chain, keyed by the first spelling submitted, and
1598
1701
  // converter records are matched to it case-insensitively. (#166)
1599
1702
  const chainKeyByDoi = new Map();
1600
- for (const doi of input.dois ?? []) {
1703
+ for (const doi of fetched.dois) {
1601
1704
  const key = chainKeyByDoi.get(doi.toLowerCase()) ?? doi;
1602
1705
  chainKeyByDoi.set(doi.toLowerCase(), key);
1603
1706
  addCallerId(key, { id: doi, idType: 'doi' });
@@ -1709,17 +1812,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1709
1812
  const bodylessPmcIds = new Set();
1710
1813
  const parsed = [];
1711
1814
  for (const node of findAll(articleSet, 'article')) {
1712
- const before = parsePmcArticle(node);
1713
- if (isBodylessArticle(before)) {
1714
- if (before.pmcId)
1715
- bodylessPmcIds.add(before.pmcId);
1815
+ const parsedArticle = parsePmcArticle(node);
1816
+ if (isBodylessArticle(parsedArticle)) {
1817
+ if (parsedArticle.pmcId)
1818
+ bodylessPmcIds.add(parsedArticle.pmcId);
1716
1819
  continue;
1717
1820
  }
1821
+ const before = withBackMatter(parsedArticle);
1718
1822
  const after = applyPmcFilters(before, filters);
1719
1823
  if (isSectionFilterMiss(before, after, filters.sections)) {
1720
1824
  sectionFilterMisses.push(articleDisplayId(after));
1721
1825
  }
1722
- const budgeted = applyPmcBudget(after, budget);
1826
+ const budgeted = applyPmcBudget(after, budget, bodySectionCount(parsedArticle, filters));
1723
1827
  omittedSections += budgeted.omittedSections;
1724
1828
  if (budgeted.truncation) {
1725
1829
  truncatedArticles.push({
@@ -1998,46 +2102,56 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1998
2102
  });
1999
2103
  }
2000
2104
  }
2001
- // Whole-response budget: fill with complete records in response order and
2105
+ // Each article is handed back under the first spelling of the id that owns
2106
+ // it, in the field that id was sent in. Every recovery site records the
2107
+ // owner; a PMC record that came back without a PMC ID has none, and is
2108
+ // reported under the id it carries — the total-function fallback, not an
2109
+ // expected path.
2110
+ const handBackId = (a) => {
2111
+ const key = inputIdByArticle.get(a);
2112
+ const owner = key === undefined ? undefined : callerIds.get(key)?.[0];
2113
+ return (owner ?? { id: articleDisplayId(a), idType: a.pmcId ? 'pmcid' : a.pmid ? 'pmid' : 'doi' });
2114
+ };
2115
+ // Articles run in routing order, each at the position of the id it is
2116
+ // handed back under: PMC EFetch answers in an order that is neither the
2117
+ // request's nor stable between calls, and the tiers finish in tier order,
2118
+ // so neither can decide what a budget keeps. An article with no owner sorts
2119
+ // last; the sort is stable. (#235)
2120
+ const routingPosition = new Map();
2121
+ for (const [index, { key }] of requestedIds(fetched).entries()) {
2122
+ if (!routingPosition.has(key))
2123
+ routingPosition.set(key, index);
2124
+ }
2125
+ const positionOf = (a) => {
2126
+ const { idType, id } = handBackId(a);
2127
+ return routingPosition.get(requestKey(idType, id)) ?? routingPosition.size;
2128
+ };
2129
+ const resolved = [...pmcArticles, ...fallbackArticles]
2130
+ .map((article) => ({ article, position: positionOf(article) }))
2131
+ .sort((a, b) => a.position - b.position)
2132
+ .map(({ article }) => article);
2133
+ // Whole-response budget: fill with complete records in routing order and
2002
2134
  // hand the rest back as identifiers the caller can re-submit. One ledger for
2003
2135
  // every tier — a PMC-served article and an Unpaywall-served one spend the
2004
- // same characters. Without `maxResponseCharacters` nothing is measured and
2005
- // the response is exactly what it was before the budget existed. (#100)
2006
- const resolved = [...pmcArticles, ...fallbackArticles];
2136
+ // same characters. Without `maxResponseCharacters` nothing is measured.
2137
+ // (#100)
2007
2138
  const ceiling = input.maxResponseCharacters;
2008
2139
  const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
2009
2140
  const articles = fit?.kept ?? resolved;
2010
2141
  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);
2142
+ const deferredIds = (fit?.deferred ?? []).map(handBackId);
2024
2143
  const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
2025
2144
  ? {
2026
2145
  maxResponseCharacters: ceiling,
2027
2146
  returnedCharacters: fit.keptCharacters,
2028
2147
  deferredCount: fit.deferred.length,
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') }),
2037
- }),
2148
+ ...groupIdsByField(deferredIds),
2038
2149
  nextDeferredCharacters,
2039
2150
  }
2040
2151
  : undefined;
2152
+ const overLimit = overLimitIds.length > 0
2153
+ ? { limit: FETCH_LIMIT, ...groupIdsByField(overLimitIds) }
2154
+ : undefined;
2041
2155
  // A deferred article takes its body accounting out of the response with it —
2042
2156
  // those counts describe text the caller never received.
2043
2157
  if (fit) {
@@ -2052,6 +2166,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2052
2166
  }
2053
2167
  }
2054
2168
  }
2169
+ // The per-article ledgers fill as the tiers finish; listed in the returned
2170
+ // articles' routing order so identical calls report them identically. (#235)
2171
+ const articleRank = new Map(articles.map((a, i) => [articleDisplayId(a), i]));
2172
+ const rankOf = (id) => articleRank.get(id) ?? articleRank.size;
2173
+ truncatedArticles.sort((a, b) => rankOf(a.id) - rankOf(b.id));
2174
+ sectionFilterMisses.sort((a, b) => rankOf(a) - rankOf(b));
2055
2175
  ctx.log.info('pubmed_fetch_fulltext completed', {
2056
2176
  requested: idCount,
2057
2177
  returned: articles.length,
@@ -2060,6 +2180,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2060
2180
  unpaywallHits: fallbackArticles.length,
2061
2181
  unavailable: unavailable.length,
2062
2182
  ...(deferred && { deferred: deferred.deferredCount }),
2183
+ ...(overLimit && { overLimit: overLimit.ids.length }),
2063
2184
  });
2064
2185
  // Summed off the per-article entries rather than carried through every
2065
2186
  // stage, so a deferred article's dropped tables leave the roll-up together
@@ -2102,7 +2223,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2102
2223
  ctx.enrich({ truncated: true });
2103
2224
  }
2104
2225
  if (deferred) {
2105
- notices.push(buildDeferralNotice(deferred));
2226
+ notices.push(buildDeferralNotice(deferred, {
2227
+ noun: 'article',
2228
+ ids: deferred.ids.join(', '),
2229
+ recall: deferred.idType
2230
+ ? `pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\``
2231
+ : `pubmed_fetch_fulltext with each id under its own field (${idFieldLists(deferred)})`,
2232
+ }));
2233
+ ctx.enrich({ truncated: true });
2234
+ }
2235
+ if (overLimit) {
2236
+ notices.push(buildOverLimitNotice(overLimit));
2106
2237
  ctx.enrich({ truncated: true });
2107
2238
  }
2108
2239
  if (notices.length > 0)
@@ -2113,6 +2244,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2113
2244
  ...(unavailable.length > 0 && { unavailable }),
2114
2245
  ...(truncation && { truncation }),
2115
2246
  ...(deferred && { deferred }),
2247
+ ...(overLimit && { overLimit }),
2116
2248
  };
2117
2249
  },
2118
2250
  format: (result) => {
@@ -2143,16 +2275,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2143
2275
  }
2144
2276
  if (result.deferred) {
2145
2277
  const d = result.deferred;
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
- }
2278
+ 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`, ...resendLines(d));
2279
+ }
2280
+ if (result.overLimit) {
2281
+ const o = result.overLimit;
2282
+ lines.push(`\n**Over the per-call limit:** ${o.ids.length} identifier(s) not fetched — a call fetches the first ${o.limit} distinct identifiers`, ...resendLines(o));
2156
2283
  }
2157
2284
  // An empty response under a budget is a deferral, not an absence — the
2158
2285
  // articles resolved and the ids above retrieve them.
@@ -2173,6 +2300,93 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2173
2300
  return [{ type: 'text', text: lines.join('\n') }];
2174
2301
  },
2175
2302
  });
2303
+ /**
2304
+ * Every requested id in routing order — `pmcids`, then `pmids`, then `dois`,
2305
+ * request order within each — as sent. Every spelling one field accepts for an
2306
+ * identifier shares a key, and the same digits in two fields are two keys.
2307
+ * (#192, #222)
2308
+ */
2309
+ function requestedIds(fields) {
2310
+ const inField = (idType, ids) => (ids ?? []).map((id) => ({ id, idType, key: requestKey(idType, id) }));
2311
+ return [
2312
+ ...inField('pmcid', fields.pmcids),
2313
+ ...inField('pmid', fields.pmids),
2314
+ ...inField('doi', fields.dois),
2315
+ ];
2316
+ }
2317
+ /** The key {@link requestedIds} gives an id sent in `idType`'s field. */
2318
+ function requestKey(idType, id) {
2319
+ switch (idType) {
2320
+ case 'pmcid':
2321
+ return `pmcid:${normalizePmcId(id)}`;
2322
+ case 'pmid':
2323
+ return `pmid:${normalizePmid(id)}`;
2324
+ case 'doi':
2325
+ return `doi:${id.toLowerCase()}`;
2326
+ }
2327
+ }
2328
+ /**
2329
+ * Split the requested ids at the per-call fetch limit, before any upstream
2330
+ * request. The first {@link FETCH_LIMIT} distinct identifiers in routing order
2331
+ * are fetched, each with every spelling that names it; each later identifier is
2332
+ * handed back once, under its first spelling — a PMC ID in the `PMC<digits>`
2333
+ * form every output reports it in. (#222)
2334
+ */
2335
+ function splitAtFetchLimit(fields) {
2336
+ const fetched = { pmcids: [], pmids: [], dois: [] };
2337
+ const overLimit = [];
2338
+ const admitted = new Set();
2339
+ const listed = new Set();
2340
+ for (const { id, idType, key } of requestedIds(fields)) {
2341
+ if (admitted.has(key) || admitted.size < FETCH_LIMIT) {
2342
+ admitted.add(key);
2343
+ fetched[`${idType}s`].push(id);
2344
+ }
2345
+ else if (!listed.has(key)) {
2346
+ listed.add(key);
2347
+ overLimit.push({ id: idType === 'pmcid' ? withPmcPrefix(normalizePmcId(id)) : id, idType });
2348
+ }
2349
+ }
2350
+ return { fetched, overLimit };
2351
+ }
2352
+ /**
2353
+ * Group ids handed back for a follow-up call: one field names them all —
2354
+ * always so for a single-field call — or each field's list says where to
2355
+ * resend its ids. (#192, #222)
2356
+ */
2357
+ function groupIdsByField(callerIds) {
2358
+ const fields = new Set(callerIds.map((c) => c.idType));
2359
+ const [onlyField] = fields;
2360
+ const under = (idType) => callerIds.filter((c) => c.idType === idType).map((c) => c.id);
2361
+ return {
2362
+ ...(fields.size === 1 && onlyField && { idType: onlyField }),
2363
+ ids: callerIds.map((c) => c.id),
2364
+ ...(fields.size > 1 && {
2365
+ ...(fields.has('pmcid') && { pmcids: under('pmcid') }),
2366
+ ...(fields.has('pmid') && { pmids: under('pmid') }),
2367
+ ...(fields.has('doi') && { dois: under('doi') }),
2368
+ }),
2369
+ };
2370
+ }
2371
+ /**
2372
+ * The `content[]` lines that resend a group of ids. Rendered on presence, each
2373
+ * independently: a group carries `idType` or the per-field lists, never both.
2374
+ */
2375
+ function resendLines(group) {
2376
+ const fieldLists = idFieldLists(group);
2377
+ return [
2378
+ ...(group.idType
2379
+ ? [
2380
+ `Re-call \`pubmed_fetch_fulltext\` with these ${group.idType} ids as \`${group.idType}s\`: ${group.ids.join(', ')}`,
2381
+ ]
2382
+ : []),
2383
+ ...(fieldLists
2384
+ ? [
2385
+ `Re-call \`pubmed_fetch_fulltext\` with these ids, each under its own field — ${fieldLists}`,
2386
+ ]
2387
+ : []),
2388
+ ];
2389
+ }
2176
2390
  /**
2177
2391
  * Merge what a Europe PMC hit carried onto a candidate bound for Unpaywall. A
2178
2392
  * DOI the candidate already holds wins — for a `dois` entry it is the caller's
@@ -2373,16 +2587,17 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
2373
2587
  if (!articleNode) {
2374
2588
  return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
2375
2589
  }
2376
- const beforeFilter = parsePmcArticle(articleNode);
2377
- if (isBodylessArticle(beforeFilter)) {
2590
+ const parsedArticle = parsePmcArticle(articleNode);
2591
+ if (isBodylessArticle(parsedArticle)) {
2378
2592
  return {
2379
2593
  kind: 'no-body',
2380
2594
  detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
2381
2595
  };
2382
2596
  }
2597
+ const beforeFilter = withBackMatter(parsedArticle);
2383
2598
  const parsed = applyPmcFilters(beforeFilter, args.input);
2384
2599
  const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
2385
- const budgeted = applyPmcBudget(parsed, args.budget);
2600
+ const budgeted = applyPmcBudget(parsed, args.budget, bodySectionCount(parsedArticle, args.input));
2386
2601
  // `parsePmcArticle` always returns string fields (sometimes empty). Strip
2387
2602
  // empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
2388
2603
  // optional shape is respected — agents read `epmcId`/`epmcSource` for those.
@@ -2807,7 +3022,11 @@ function formatPmcArticle(a, lines, truncation, mode) {
2807
3022
  lines.push(`**Type:** ${a.articleType}`);
2808
3023
  if (a.publicationDate) {
2809
3024
  const d = a.publicationDate;
2810
- const dateParts = [d.year, d.month, d.day].filter(Boolean);
3025
+ // JATS deposits often write `<month>7</month>`; the rendered date pads a
3026
+ // one-digit numeric month or day so it reads as ISO, while the structured
3027
+ // parts stay as deposited. A named month (`Jul`) passes through. (#242)
3028
+ const pad = (part) => (part && /^\d$/.test(part) ? `0${part}` : part);
3029
+ const dateParts = [d.year, pad(d.month), pad(d.day)].filter(Boolean);
2811
3030
  if (dateParts.length)
2812
3031
  lines.push(`**Published:** ${dateParts.join('-')}`);
2813
3032
  }
@@ -2913,7 +3132,7 @@ function formatAssets(assets, lines) {
2913
3132
  lines.push(`\n#### Assets (${assets.length})`);
2914
3133
  lines.push(`\n> \`file\` is the pointer exactly as deposited — a name inside the PMC deposit, not a fetchable URL. Open the article at the PMC link above to view it.`);
2915
3134
  for (const asset of assets) {
2916
- const heading = [asset.label, asset.caption].filter(Boolean).join(' — ');
3135
+ const heading = [assetLabelName(asset), asset.caption].filter(Boolean).join(' — ');
2917
3136
  lines.push(`\n##### ${escapeMarkdownInline(heading || 'Asset')}`);
2918
3137
  const meta = [
2919
3138
  asset.assetType,
@@ -3009,7 +3228,7 @@ function formatPmcAuthor(au) {
3009
3228
  const parts = [];
3010
3229
  if (au.collectiveName)
3011
3230
  parts.push(`${au.collectiveName} (collective)`);
3012
- const name = [au.givenNames, au.lastName].filter(Boolean).join(' ');
3231
+ const name = [au.givenNames, au.lastName, au.suffix].filter(Boolean).join(' ');
3013
3232
  if (name)
3014
3233
  parts.push(name);
3015
3234
  return parts.join(' — ') || 'Unknown';
@@ -3033,7 +3252,7 @@ function sectionHeading(section) {
3033
3252
  return section.label ? escapeMarkdownInline(section.label) : UNTITLED_SECTION_LABEL;
3034
3253
  }
3035
3254
  /**
3036
- * Render one body section and everything nested under it, one markdown heading
3255
+ * Render one section and everything nested under it, one markdown heading
3037
3256
  * level per nesting level. Walks the full depth the output schema carries, so
3038
3257
  * `content[]` shows every section `structuredContent` does. Headings stop
3039
3258
  * deepening at `######`, the deepest markdown supports. (#112)