@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.21

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 (65) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +3 -3
  3. package/README.md +7 -6
  4. package/changelog/2.10.x/2.10.21.md +42 -0
  5. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -0
  6. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +36 -4
  8. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +21 -4
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +287 -127
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
  14. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
  16. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +8 -6
  19. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +0 -1
  22. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
  23. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  24. package/dist/services/europe-pmc/api-client.js +4 -3
  25. package/dist/services/europe-pmc/api-client.js.map +1 -1
  26. package/dist/services/fetch-redaction.d.ts +23 -0
  27. package/dist/services/fetch-redaction.d.ts.map +1 -0
  28. package/dist/services/fetch-redaction.js +62 -0
  29. package/dist/services/fetch-redaction.js.map +1 -0
  30. package/dist/services/ncbi/api-client.d.ts +3 -0
  31. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  32. package/dist/services/ncbi/api-client.js +21 -8
  33. package/dist/services/ncbi/api-client.js.map +1 -1
  34. package/dist/services/ncbi/formatting/citation-formatter.d.ts +19 -3
  35. package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
  36. package/dist/services/ncbi/formatting/citation-formatter.js +224 -46
  37. package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
  38. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  39. package/dist/services/ncbi/ncbi-service.js +2 -1
  40. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  41. package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
  42. package/dist/services/ncbi/parsing/article-parser.js +80 -34
  43. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  44. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
  45. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  46. package/dist/services/ncbi/parsing/pmc-article-parser.js +257 -57
  47. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  48. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +29 -0
  49. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  50. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +171 -6
  51. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  52. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  53. package/dist/services/ncbi/response-handler.js +3 -0
  54. package/dist/services/ncbi/response-handler.js.map +1 -1
  55. package/dist/services/ncbi/types.d.ts +42 -2
  56. package/dist/services/ncbi/types.d.ts.map +1 -1
  57. package/dist/services/openalex/api-client.d.ts.map +1 -1
  58. package/dist/services/openalex/api-client.js +4 -3
  59. package/dist/services/openalex/api-client.js.map +1 -1
  60. package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
  61. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  62. package/dist/services/unpaywall/unpaywall-service.js +18 -8
  63. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  64. package/package.json +5 -5
  65. 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
@@ -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)
339
363
  */
340
- function isBodylessArticle(before) {
341
- return before.sections.length === 0;
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)
372
+ */
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,7 +443,7 @@ 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'),
@@ -466,7 +499,7 @@ const TableSchema = z
466
499
  sectionTitle: z
467
500
  .string()
468
501
  .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.'),
502
+ .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
503
  headerRowCount: z
471
504
  .number()
472
505
  .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.'),
@@ -514,7 +547,7 @@ const AssetSchema = z
514
547
  sectionTitle: z
515
548
  .string()
516
549
  .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.'),
550
+ .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
551
  href: z
519
552
  .string()
520
553
  .optional()
@@ -555,7 +588,9 @@ const PmcArticleSchema = z
555
588
  keywords: z.array(z.string()).optional().describe('Keywords'),
556
589
  articleType: z.string().optional().describe('Article type'),
557
590
  publicationDate: PublicationDateSchema.optional(),
558
- sections: z.array(SectionSchema).describe('Article body sections'),
591
+ sections: z
592
+ .array(SectionSchema)
593
+ .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
594
  tables: z
560
595
  .array(TableSchema)
561
596
  .optional()
@@ -563,7 +598,7 @@ const PmcArticleSchema = z
563
598
  assets: z
564
599
  .array(AssetSchema)
565
600
  .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.'),
601
+ .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
602
  references: z.array(ReferenceSchema).optional().describe('Reference list'),
568
603
  epmcId: z
569
604
  .string()
@@ -738,7 +773,7 @@ const TruncatedSectionSchema = z
738
773
  .optional()
739
774
  .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
775
  })
741
- .describe('Character accounting for one body section of a budgeted article');
776
+ .describe('Character accounting for one section of a budgeted article');
742
777
  const TruncatedArticleSchema = z
743
778
  .object({
744
779
  id: z
@@ -746,7 +781,7 @@ const TruncatedArticleSchema = z
746
781
  .describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
747
782
  source: z
748
783
  .enum(['pmc', 'unpaywall'])
749
- .describe('Which output shape was budgeted: `pmc` budgets body sections and subsections, `unpaywall` budgets the single `content` body'),
784
+ .describe('Which output shape was budgeted: `pmc` budgets sections and subsections, `unpaywall` budgets the single `content` body'),
750
785
  originalCharacters: z
751
786
  .number()
752
787
  .describe('Body characters this article carried before the budget pass'),
@@ -766,7 +801,7 @@ const TruncatedArticleSchema = z
766
801
  omittedAssets: z
767
802
  .number()
768
803
  .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.'),
804
+ .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
805
  omittedAssetNames: z
771
806
  .array(z.string())
772
807
  .optional()
@@ -791,7 +826,7 @@ const TruncationSchema = z
791
826
  .describe('Body characters the shortened articles carry in this response'),
792
827
  omittedSections: z
793
828
  .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.'),
829
+ .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
830
  omittedTables: z
796
831
  .number()
797
832
  .optional()
@@ -840,6 +875,32 @@ const DeferredSchema = z
840
875
  .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.'),
841
876
  })
842
877
  .describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
878
+ const OverLimitSchema = z
879
+ .object({
880
+ limit: z
881
+ .number()
882
+ .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.'),
883
+ idType: z
884
+ .enum(['pmid', 'pmcid', 'doi'])
885
+ .optional()
886
+ .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.'),
887
+ ids: z
888
+ .array(z.string())
889
+ .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`.'),
890
+ pmcids: z
891
+ .array(z.string())
892
+ .optional()
893
+ .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.'),
894
+ pmids: z
895
+ .array(z.string())
896
+ .optional()
897
+ .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.'),
898
+ dois: z
899
+ .array(z.string())
900
+ .optional()
901
+ .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.'),
902
+ })
903
+ .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
904
  /** True when the request asked for any budget at all. Without one, every budget
844
905
  * helper returns its input untouched so the response is byte-identical. */
845
906
  function budgetRequested(budget) {
@@ -1045,11 +1106,16 @@ function allotSectionBudgets(sizes, budget) {
1045
1106
  * or cut — the budget spends on body text and table content, keeping every
1046
1107
  * article citable.
1047
1108
  *
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)
1109
+ * Body sections are served first, then tables, then assets, then back-matter
1110
+ * sections, each spending what `maxCharacters` has left, in document order. A
1111
+ * table or asset that does not fit is dropped whole and counted, never
1112
+ * truncated into a partial grid or a caption cut short. Back matter comes last
1113
+ * so it never displaces a table or figure: it is shortened or dropped the way
1114
+ * body sections are, from whatever the rest left. The first `bodySections`
1115
+ * entries of `article.sections` are the body; spend order aside, `sections` and
1116
+ * the ledger stay in document order. With no `maxCharacters` — a bare
1117
+ * `maxCharactersPerSection` request — nothing bounds the tables or assets and
1118
+ * every entry is kept. (#111, #130, #206)
1053
1119
  *
1054
1120
  * Returns the article untouched (same object identity) when no budget was
1055
1121
  * requested or nothing exceeded it. A section or subsection left with zero
@@ -1057,7 +1123,7 @@ function allotSectionBudgets(sizes, budget) {
1057
1123
  * keeps it as a heading-only entry. Dropped nodes still appear in the accounting
1058
1124
  * so the caller can see which headings exist. (#81, #143)
1059
1125
  */
1060
- function applyPmcBudget(article, budget) {
1126
+ function applyPmcBudget(article, budget, bodySections) {
1061
1127
  const tables = article.tables ?? [];
1062
1128
  const assets = article.assets ?? [];
1063
1129
  if (!budgetRequested(budget) ||
@@ -1068,31 +1134,37 @@ function applyPmcBudget(article, budget) {
1068
1134
  const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
1069
1135
  const assetsOriginal = assets.reduce((sum, asset) => sum + assetCharacters(asset), 0);
1070
1136
  const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal + assetsOriginal;
1071
- const allowances = allotSectionBudgets(sizes, budget);
1072
1137
  const kept = [];
1073
1138
  const sectionReports = [];
1074
1139
  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.
1140
+ // Each stage spends whatever `maxCharacters` has left. A bare per-section
1141
+ // budget sets no total, so nothing bounds the tables or assets.
1087
1142
  const remainingAllowance = () => budget.maxCharacters === undefined
1088
1143
  ? Number.POSITIVE_INFINITY
1089
1144
  : Math.max(budget.maxCharacters - returnedCharacters, 0);
1145
+ const fitSections = (sections, total) => {
1146
+ const allowances = allotSectionBudgets(sections.map(sectionCharacters), {
1147
+ ...budget,
1148
+ maxCharacters: total,
1149
+ });
1150
+ sections.forEach((section, i) => {
1151
+ const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
1152
+ const fit = fitSectionTree(section, fitted, { i: 0 }, budget.overflowMode);
1153
+ returnedCharacters += fit.entry.returnedCharacters;
1154
+ sectionReports.push(fit.entry);
1155
+ if (fit.kept)
1156
+ kept.push(fit.kept);
1157
+ });
1158
+ };
1159
+ fitSections(article.sections.slice(0, bodySections), budget.maxCharacters);
1090
1160
  const fittedTables = fitWholeNamed(tables, remainingAllowance(), tableCharacters, tableDisplayName);
1091
1161
  returnedCharacters += fittedTables.spent;
1092
1162
  const omittedTables = fittedTables.omittedNames.length;
1093
1163
  const fittedAssets = fitWholeNamed(assets, remainingAllowance(), assetCharacters, assetDisplayName);
1094
1164
  returnedCharacters += fittedAssets.spent;
1095
1165
  const omittedAssets = fittedAssets.omittedNames.length;
1166
+ fitSections(article.sections.slice(bodySections), budget.maxCharacters === undefined ? undefined : remainingAllowance());
1167
+ const omittedSections = countDroppedSections(sectionReports, budget.overflowMode);
1096
1168
  if (returnedCharacters === originalCharacters &&
1097
1169
  omittedSections === 0 &&
1098
1170
  omittedTables === 0 &&
@@ -1236,22 +1308,42 @@ function buildDeferralNotice(deferred) {
1236
1308
  : `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
1237
1309
  const resend = deferred.idType
1238
1310
  ? `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)})`;
1311
+ : `Re-call pubmed_fetch_fulltext with each id under its own field (${idFieldLists(deferred)})`;
1240
1312
  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
1313
  }
1242
1314
  /**
1243
- * The per-field lists of a deferral that spans fields, in field order —
1315
+ * Compose the recovery notice for identifiers past the per-call fetch limit:
1316
+ * how many were left unfetched, which ones, and where to resend them — so a
1317
+ * caller reading only `content[]` can retrieve the rest without inspecting
1318
+ * `overLimit`. (#222)
1319
+ */
1320
+ function buildOverLimitNotice(overLimit) {
1321
+ const resend = overLimit.idType
1322
+ ? `Re-call pubmed_fetch_fulltext with those ids under \`${overLimit.idType}s\``
1323
+ : `Re-call pubmed_fetch_fulltext with each id under its own field (${idFieldLists(overLimit)})`;
1324
+ 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.`;
1325
+ }
1326
+ /**
1327
+ * The per-field lists of ids handed back across fields, in field order —
1244
1328
  * `` `pmcids`: PMC1; `dois`: 10.1/x `` — or `''` when `idType` covers them all.
1245
1329
  */
1246
- function deferredFieldLists(deferred) {
1330
+ function idFieldLists(group) {
1247
1331
  return ['pmcids', 'pmids', 'dois']
1248
1332
  .flatMap((field) => {
1249
- const ids = deferred[field];
1333
+ const ids = group[field];
1250
1334
  return ids?.length ? [`\`${field}\`: ${ids.join(', ')}`] : [];
1251
1335
  })
1252
1336
  .join('; ');
1253
1337
  }
1254
1338
  // ─── Tool Definition ─────────────────────────────────────────────────────────
1339
+ /**
1340
+ * Distinct identifiers one call fetches across `pmcids`, `pmids`, and `dois`,
1341
+ * in routing order; the rest are returned unfetched in `overLimit`. Bounds the
1342
+ * candidates every tier works per call. (#192, #222)
1343
+ */
1344
+ const FETCH_LIMIT = 10;
1345
+ /** Distinct identifiers one call may name across the three fields. (#222) */
1346
+ const MAX_IDENTIFIERS = 50;
1255
1347
  /**
1256
1348
  * Compose the tool description for the fallback tiers enabled in this
1257
1349
  * deployment. PMC EFetch is always present; Europe PMC (`EUROPEPMC_ENABLED`)
@@ -1260,7 +1352,7 @@ function deferredFieldLists(deferred) {
1260
1352
  * recoveries that silently can't happen.
1261
1353
  */
1262
1354
  export function buildFulltextDescription(tiers) {
1263
- const base = 'Fetch full-text articles from PubMed Central with structured sections, tables, and references.';
1355
+ 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
1356
  const epmcClause = 'Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart)';
1265
1357
  const unpaywallClause = 'Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text';
1266
1358
  let fallback;
@@ -1284,13 +1376,11 @@ export function buildFulltextDescription(tiers) {
1284
1376
  : tiers.unpaywall
1285
1377
  ? '; DOIs with no PMC copy recover via Unpaywall open access'
1286
1378
  : '';
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.`;
1379
+ 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
1380
  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
1381
  return `${base} ${fallback} ${input} ${budget}`;
1290
1382
  }
1291
1383
  const serverConfig = getServerConfig();
1292
- /** Distinct identifiers one call may name across `pmcids`, `pmids`, and `dois`. (#192) */
1293
- const MAX_IDENTIFIERS = 10;
1294
1384
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1295
1385
  description: buildFulltextDescription({
1296
1386
  europePmc: serverConfig.europepmcEnabled,
@@ -1314,26 +1404,30 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1314
1404
  retryable: false,
1315
1405
  },
1316
1406
  ],
1407
+ // Never advertised; rewritten to the canonical key before the schema parses.
1408
+ // Singular spellings sibling tools and this tool's own output use; a lone
1409
+ // string under one becomes a one-element array. (#221)
1410
+ inputAliases: { pmcid: 'pmcids', pmcId: 'pmcids', pmid: 'pmids', doi: 'dois' },
1317
1411
  input: z
1318
1412
  .object({
1319
1413
  pmcids: z
1320
1414
  .array(pmcidStringSchema)
1321
1415
  .min(1)
1322
- .max(10)
1416
+ .max(MAX_IDENTIFIERS)
1323
1417
  .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.'),
1418
+ .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
1419
  pmids: z
1326
1420
  .array(pmidStringSchema)
1327
1421
  .min(1)
1328
- .max(10)
1422
+ .max(MAX_IDENTIFIERS)
1329
1423
  .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.'),
1424
+ .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
1425
  dois: z
1332
1426
  .array(doiStringSchema)
1333
1427
  .min(1)
1334
- .max(10)
1428
+ .max(MAX_IDENTIFIERS)
1335
1429
  .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.'),
1430
+ .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
1431
  includeReferences: z
1338
1432
  .boolean()
1339
1433
  .default(false)
@@ -1350,48 +1444,40 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1350
1444
  .number()
1351
1445
  .int()
1352
1446
  .min(1)
1353
- .max(50)
1354
1447
  .optional()
1355
- .describe('Maximum top-level body sections. Applies to `source=pmc` results only.'),
1448
+ .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
1449
  sections: z
1357
1450
  .array(z.string())
1358
1451
  .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.'),
1452
+ .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
1453
  maxCharacters: z
1361
1454
  .number()
1362
1455
  .int()
1363
1456
  .min(1)
1364
- .max(1_000_000)
1365
1457
  .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.'),
1458
+ .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
1459
  maxCharactersPerSection: z
1368
1460
  .number()
1369
1461
  .int()
1370
1462
  .min(1)
1371
- .max(1_000_000)
1372
1463
  .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.'),
1464
+ .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
1465
  maxResponseCharacters: z
1375
1466
  .number()
1376
1467
  .int()
1377
1468
  .min(1)
1378
- .max(1_000_000)
1379
1469
  .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.'),
1470
+ .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 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.'),
1381
1471
  overflowMode: z
1382
1472
  .enum(['truncate', 'outline'])
1383
1473
  .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.'),
1474
+ .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
1475
  })
1386
1476
  // 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)
1477
+ // ceiling is settled before any upstream request; the handler then fetches
1478
+ // the first FETCH_LIMIT the same way. (#192, #222)
1389
1479
  .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
- ]);
1480
+ const distinct = new Set(requestedIds(v).map((r) => r.key));
1395
1481
  if (distinct.size === 0) {
1396
1482
  refinement.addIssue({
1397
1483
  code: 'custom',
@@ -1401,7 +1487,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1401
1487
  else if (distinct.size > MAX_IDENTIFIERS) {
1402
1488
  refinement.addIssue({
1403
1489
  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.`,
1490
+ 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
1491
  });
1406
1492
  }
1407
1493
  }),
@@ -1413,26 +1499,27 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1413
1499
  unavailable: z
1414
1500
  .array(UnavailableSchema)
1415
1501
  .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.'),
1502
+ .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
1503
  truncation: TruncationSchema.optional(),
1418
1504
  deferred: DeferredSchema.optional(),
1505
+ overLimit: OverLimitSchema.optional(),
1419
1506
  }),
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
1507
+ // Recovery guidance for six cases — a `sections` filter that removed every
1508
+ // section (#80), a record the chain could only retrieve as front matter
1422
1509
  // (#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.
1510
+ // character budget shortened (#81), articles the whole-response budget
1511
+ // withheld (#100), and identifiers past the per-call fetch limit (#222).
1512
+ // Agent-facing context surfaced via ctx.enrich.notice() to structuredContent
1513
+ // and content[]; absent when none applies.
1427
1514
  enrichment: {
1428
1515
  notice: z
1429
1516
  .string()
1430
1517
  .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.'),
1518
+ .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
1519
  truncated: z
1433
1520
  .boolean()
1434
1521
  .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`.'),
1522
+ .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
1523
  },
1437
1524
  async handler(input, ctx) {
1438
1525
  const idCount = (input.pmcids?.length ?? 0) + (input.pmids?.length ?? 0) + (input.dois?.length ?? 0);
@@ -1450,6 +1537,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1450
1537
  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
1538
  }
1452
1539
  const filters = { ...input, sections: sectionFilter.terms };
1540
+ // Settled before any upstream request: every tier below works the fetched
1541
+ // ids alone, and the rest are handed back in `overLimit`. (#222)
1542
+ const { fetched, overLimit: overLimitIds } = splitAtFetchLimit(input);
1453
1543
  // ── Chain tracking ──────────────────────────────────────────────────────
1454
1544
  // Per-input-id tier history (the `triedTiers` array on unavailable entries).
1455
1545
  // Keys: canonical PMID for a `pmids` entry, prefixed PMCID for a `pmcids`
@@ -1477,9 +1567,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1477
1567
  // `dois` entry that named it — so a PMC miss can still reach Unpaywall
1478
1568
  // without another lookup, whichever field owns the record.
1479
1569
  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).
1570
+ // Ids of returned articles whose `sections` filter removed every section —
1571
+ // collected across the PMC and EPMC stages to drive one recovery notice via
1572
+ // ctx.enrich.notice (#80).
1483
1573
  const sectionFilterMisses = [];
1484
1574
  // Input ids whose PMC or EPMC record carried no body sections at all. Those
1485
1575
  // records are not full-text hits, so the chain continues past them; ids still
@@ -1574,7 +1664,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1574
1664
  };
1575
1665
  // `PMC123`, `pmc123`, `123` and `PMC0123` name one record; it runs the
1576
1666
  // chain once, keyed and reported in `PMC<digits>` form. (#170)
1577
- for (const id of input.pmcids ?? []) {
1667
+ for (const id of fetched.pmcids) {
1578
1668
  const normalized = normalizePmcId(id);
1579
1669
  const key = withPmcPrefix(normalized);
1580
1670
  if (!chainByInput.has(key))
@@ -1585,9 +1675,9 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1585
1675
  // The ID Converter parses `00000001` as PMID 1 yet reports it "not found
1586
1676
  // in PMC", and every later stage answers with NCBI's own PMID, so the chain
1587
1677
  // runs once per distinct PMID in its canonical form. (#161)
1588
- for (const id of input.pmids ?? [])
1678
+ for (const id of fetched.pmids)
1589
1679
  addCallerId(normalizePmid(id), { id, idType: 'pmid' });
1590
- const pmids = [...new Set((input.pmids ?? []).map(normalizePmid))];
1680
+ const pmids = [...new Set(fetched.pmids.map(normalizePmid))];
1591
1681
  // DOIs resolve through the converter too, so PMC-indexed DOIs reach PMC
1592
1682
  // EFetch instead of going straight to the EPMC/Unpaywall fallback (which
1593
1683
  // misses articles whose only OA copy is the PMC JATS).
@@ -1597,7 +1687,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1597
1687
  // DOI shares one chain, keyed by the first spelling submitted, and
1598
1688
  // converter records are matched to it case-insensitively. (#166)
1599
1689
  const chainKeyByDoi = new Map();
1600
- for (const doi of input.dois ?? []) {
1690
+ for (const doi of fetched.dois) {
1601
1691
  const key = chainKeyByDoi.get(doi.toLowerCase()) ?? doi;
1602
1692
  chainKeyByDoi.set(doi.toLowerCase(), key);
1603
1693
  addCallerId(key, { id: doi, idType: 'doi' });
@@ -1709,17 +1799,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1709
1799
  const bodylessPmcIds = new Set();
1710
1800
  const parsed = [];
1711
1801
  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);
1802
+ const parsedArticle = parsePmcArticle(node);
1803
+ if (isBodylessArticle(parsedArticle)) {
1804
+ if (parsedArticle.pmcId)
1805
+ bodylessPmcIds.add(parsedArticle.pmcId);
1716
1806
  continue;
1717
1807
  }
1808
+ const before = withBackMatter(parsedArticle);
1718
1809
  const after = applyPmcFilters(before, filters);
1719
1810
  if (isSectionFilterMiss(before, after, filters.sections)) {
1720
1811
  sectionFilterMisses.push(articleDisplayId(after));
1721
1812
  }
1722
- const budgeted = applyPmcBudget(after, budget);
1813
+ const budgeted = applyPmcBudget(after, budget, bodySectionCount(parsedArticle, filters));
1723
1814
  omittedSections += budgeted.omittedSections;
1724
1815
  if (budgeted.truncation) {
1725
1816
  truncatedArticles.push({
@@ -2018,26 +2109,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2018
2109
  const owner = key === undefined ? undefined : callerIds.get(key)?.[0];
2019
2110
  return (owner ?? { id: articleDisplayId(a), idType: a.pmcId ? 'pmcid' : a.pmid ? 'pmid' : 'doi' });
2020
2111
  });
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);
2024
2112
  const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
2025
2113
  ? {
2026
2114
  maxResponseCharacters: ceiling,
2027
2115
  returnedCharacters: fit.keptCharacters,
2028
2116
  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
- }),
2117
+ ...groupIdsByField(deferredIds),
2038
2118
  nextDeferredCharacters,
2039
2119
  }
2040
2120
  : undefined;
2121
+ const overLimit = overLimitIds.length > 0
2122
+ ? { limit: FETCH_LIMIT, ...groupIdsByField(overLimitIds) }
2123
+ : undefined;
2041
2124
  // A deferred article takes its body accounting out of the response with it —
2042
2125
  // those counts describe text the caller never received.
2043
2126
  if (fit) {
@@ -2060,6 +2143,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2060
2143
  unpaywallHits: fallbackArticles.length,
2061
2144
  unavailable: unavailable.length,
2062
2145
  ...(deferred && { deferred: deferred.deferredCount }),
2146
+ ...(overLimit && { overLimit: overLimit.ids.length }),
2063
2147
  });
2064
2148
  // Summed off the per-article entries rather than carried through every
2065
2149
  // stage, so a deferred article's dropped tables leave the roll-up together
@@ -2105,6 +2189,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2105
2189
  notices.push(buildDeferralNotice(deferred));
2106
2190
  ctx.enrich({ truncated: true });
2107
2191
  }
2192
+ if (overLimit) {
2193
+ notices.push(buildOverLimitNotice(overLimit));
2194
+ ctx.enrich({ truncated: true });
2195
+ }
2108
2196
  if (notices.length > 0)
2109
2197
  ctx.enrich.notice(notices.join(' '));
2110
2198
  return {
@@ -2113,6 +2201,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2113
2201
  ...(unavailable.length > 0 && { unavailable }),
2114
2202
  ...(truncation && { truncation }),
2115
2203
  ...(deferred && { deferred }),
2204
+ ...(overLimit && { overLimit }),
2116
2205
  };
2117
2206
  },
2118
2207
  format: (result) => {
@@ -2143,16 +2232,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2143
2232
  }
2144
2233
  if (result.deferred) {
2145
2234
  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
- }
2235
+ 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));
2236
+ }
2237
+ if (result.overLimit) {
2238
+ const o = result.overLimit;
2239
+ 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
2240
  }
2157
2241
  // An empty response under a budget is a deferral, not an absence — the
2158
2242
  // articles resolved and the ids above retrieve them.
@@ -2173,6 +2257,81 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
2173
2257
  return [{ type: 'text', text: lines.join('\n') }];
2174
2258
  },
2175
2259
  });
2260
+ /**
2261
+ * Every requested id in routing order — `pmcids`, then `pmids`, then `dois`,
2262
+ * request order within each — as sent. Every spelling one field accepts for an
2263
+ * identifier shares a key, and the same digits in two fields are two keys.
2264
+ * (#192, #222)
2265
+ */
2266
+ function requestedIds(fields) {
2267
+ return [
2268
+ ...(fields.pmcids ?? []).map((id) => ({ id, idType: 'pmcid', key: `pmcid:${normalizePmcId(id)}` })),
2269
+ ...(fields.pmids ?? []).map((id) => ({ id, idType: 'pmid', key: `pmid:${normalizePmid(id)}` })),
2270
+ ...(fields.dois ?? []).map((id) => ({ id, idType: 'doi', key: `doi:${id.toLowerCase()}` })),
2271
+ ];
2272
+ }
2273
+ /**
2274
+ * Split the requested ids at the per-call fetch limit, before any upstream
2275
+ * request. The first {@link FETCH_LIMIT} distinct identifiers in routing order
2276
+ * are fetched, each with every spelling that names it; each later identifier is
2277
+ * handed back once, under its first spelling — a PMC ID in the `PMC<digits>`
2278
+ * form every output reports it in. (#222)
2279
+ */
2280
+ function splitAtFetchLimit(fields) {
2281
+ const fetched = { pmcids: [], pmids: [], dois: [] };
2282
+ const overLimit = [];
2283
+ const admitted = new Set();
2284
+ const listed = new Set();
2285
+ for (const { id, idType, key } of requestedIds(fields)) {
2286
+ if (admitted.has(key) || admitted.size < FETCH_LIMIT) {
2287
+ admitted.add(key);
2288
+ fetched[`${idType}s`].push(id);
2289
+ }
2290
+ else if (!listed.has(key)) {
2291
+ listed.add(key);
2292
+ overLimit.push({ id: idType === 'pmcid' ? withPmcPrefix(normalizePmcId(id)) : id, idType });
2293
+ }
2294
+ }
2295
+ return { fetched, overLimit };
2296
+ }
2297
+ /**
2298
+ * Group ids handed back for a follow-up call: one field names them all —
2299
+ * always so for a single-field call — or each field's list says where to
2300
+ * resend its ids. (#192, #222)
2301
+ */
2302
+ function groupIdsByField(callerIds) {
2303
+ const fields = new Set(callerIds.map((c) => c.idType));
2304
+ const [onlyField] = fields;
2305
+ const under = (idType) => callerIds.filter((c) => c.idType === idType).map((c) => c.id);
2306
+ return {
2307
+ ...(fields.size === 1 && onlyField && { idType: onlyField }),
2308
+ ids: callerIds.map((c) => c.id),
2309
+ ...(fields.size > 1 && {
2310
+ ...(fields.has('pmcid') && { pmcids: under('pmcid') }),
2311
+ ...(fields.has('pmid') && { pmids: under('pmid') }),
2312
+ ...(fields.has('doi') && { dois: under('doi') }),
2313
+ }),
2314
+ };
2315
+ }
2316
+ /**
2317
+ * The `content[]` lines that resend a group of ids. Rendered on presence, each
2318
+ * independently: a group carries `idType` or the per-field lists, never both.
2319
+ */
2320
+ function resendLines(group) {
2321
+ const fieldLists = idFieldLists(group);
2322
+ return [
2323
+ ...(group.idType
2324
+ ? [
2325
+ `Re-call \`pubmed_fetch_fulltext\` with these ${group.idType} ids as \`${group.idType}s\`: ${group.ids.join(', ')}`,
2326
+ ]
2327
+ : []),
2328
+ ...(fieldLists
2329
+ ? [
2330
+ `Re-call \`pubmed_fetch_fulltext\` with these ids, each under its own field — ${fieldLists}`,
2331
+ ]
2332
+ : []),
2333
+ ];
2334
+ }
2176
2335
  /**
2177
2336
  * Merge what a Europe PMC hit carried onto a candidate bound for Unpaywall. A
2178
2337
  * DOI the candidate already holds wins — for a `dois` entry it is the caller's
@@ -2373,16 +2532,17 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
2373
2532
  if (!articleNode) {
2374
2533
  return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
2375
2534
  }
2376
- const beforeFilter = parsePmcArticle(articleNode);
2377
- if (isBodylessArticle(beforeFilter)) {
2535
+ const parsedArticle = parsePmcArticle(articleNode);
2536
+ if (isBodylessArticle(parsedArticle)) {
2378
2537
  return {
2379
2538
  kind: 'no-body',
2380
2539
  detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
2381
2540
  };
2382
2541
  }
2542
+ const beforeFilter = withBackMatter(parsedArticle);
2383
2543
  const parsed = applyPmcFilters(beforeFilter, args.input);
2384
2544
  const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
2385
- const budgeted = applyPmcBudget(parsed, args.budget);
2545
+ const budgeted = applyPmcBudget(parsed, args.budget, bodySectionCount(parsedArticle, args.input));
2386
2546
  // `parsePmcArticle` always returns string fields (sometimes empty). Strip
2387
2547
  // empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
2388
2548
  // optional shape is respected — agents read `epmcId`/`epmcSource` for those.
@@ -3033,7 +3193,7 @@ function sectionHeading(section) {
3033
3193
  return section.label ? escapeMarkdownInline(section.label) : UNTITLED_SECTION_LABEL;
3034
3194
  }
3035
3195
  /**
3036
- * Render one body section and everything nested under it, one markdown heading
3196
+ * Render one section and everything nested under it, one markdown heading
3037
3197
  * level per nesting level. Walks the full depth the output schema carries, so
3038
3198
  * `content[]` shows every section `structuredContent` does. Headings stop
3039
3199
  * deepening at `######`, the deepest markdown supports. (#112)