@cyanheads/pubmed-mcp-server 2.10.8 → 2.10.9

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 (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +4 -3
  4. package/dist/mcp-server/tools/definitions/_text.d.ts +23 -1
  5. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
  6. package/dist/mcp-server/tools/definitions/_text.js +25 -1
  7. package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +18 -0
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +409 -31
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  12. package/dist/services/europe-pmc/europe-pmc-service.d.ts +3 -3
  13. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  14. package/dist/services/europe-pmc/europe-pmc-service.js +8 -16
  15. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  16. package/dist/services/ncbi/parsing/ordered-xml-parser-options.d.ts +44 -0
  17. package/dist/services/ncbi/parsing/ordered-xml-parser-options.d.ts.map +1 -0
  18. package/dist/services/ncbi/parsing/ordered-xml-parser-options.js +41 -0
  19. package/dist/services/ncbi/parsing/ordered-xml-parser-options.js.map +1 -0
  20. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +47 -7
  21. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  22. package/dist/services/ncbi/parsing/pmc-article-parser.js +322 -48
  23. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  24. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +18 -0
  25. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  26. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +39 -3
  27. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  28. package/dist/services/ncbi/response-handler.d.ts +3 -9
  29. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  30. package/dist/services/ncbi/response-handler.js +6 -29
  31. package/dist/services/ncbi/response-handler.js.map +1 -1
  32. package/dist/services/ncbi/types.d.ts +39 -0
  33. package/dist/services/ncbi/types.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/server.json +3 -3
@@ -31,16 +31,66 @@ import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-serv
31
31
  import { fitWholeItems } from './_budget.js';
32
32
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
33
33
  import { pmidStringSchema } from './_schemas.js';
34
- import { escapeMarkdownInline, sliceCodeUnits } from './_text.js';
34
+ import { escapeMarkdownInline, escapeMarkdownTableCell, sliceCodeUnits } from './_text.js';
35
35
  function normalizePmcId(id) {
36
36
  return id.replace(/^PMC/i, '');
37
37
  }
38
38
  function withPmcPrefix(id) {
39
39
  return id.startsWith('PMC') ? id : `PMC${id}`;
40
40
  }
41
- function filterSections(sections, sectionFilter) {
42
- const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
43
- return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
41
+ /** Case-insensitive substring match of one section heading against the filter. */
42
+ function matchesSectionFilter(title, lowerFilter) {
43
+ const lowered = title?.toLowerCase();
44
+ return lowered !== undefined && lowerFilter.some((f) => lowered.includes(f));
45
+ }
46
+ function lowerCase(s) {
47
+ return s.toLowerCase();
48
+ }
49
+ /**
50
+ * Prune a section tree to the branches a `sections` filter selects, matching
51
+ * titles at every nesting depth rather than the top level alone. (#126)
52
+ *
53
+ * A section whose own title matches is returned whole — object identity
54
+ * included, so an unfiltered subtree is never rebuilt. A section kept only
55
+ * because a descendant matched becomes a breadcrumb: its `title` and `label`
56
+ * survive so the match can be placed in the document, its own `text` is cleared
57
+ * because the caller filtered that prose away, and it carries just the matching
58
+ * branch of its subsections. Promoting the match to the top level instead would
59
+ * make `maxSections` — which caps genuine top-level sections — count nested
60
+ * content as top-level.
61
+ */
62
+ function pruneSections(sections, lowerFilter) {
63
+ const kept = [];
64
+ for (const section of sections) {
65
+ if (matchesSectionFilter(section.title, lowerFilter)) {
66
+ kept.push(section);
67
+ continue;
68
+ }
69
+ const subsections = pruneSections(section.subsections ?? [], lowerFilter);
70
+ if (subsections.length > 0)
71
+ kept.push({ ...section, text: '', subsections });
72
+ }
73
+ return kept;
74
+ }
75
+ /**
76
+ * Titles of the sections a `sections` filter actually selected — a section that
77
+ * matched directly plus everything beneath it. Breadcrumb ancestors are left
78
+ * out: their own text was cleared because the caller did not ask for it, and a
79
+ * table sitting in one was not asked for either. Walks the *pruned* tree, so a
80
+ * top-level section the `maxSections` slice removed contributes nothing.
81
+ */
82
+ function matchedSectionTitles(sections, lowerFilter) {
83
+ const titles = new Set();
84
+ const walk = (nodes, inherited) => {
85
+ for (const section of nodes) {
86
+ const matched = inherited || matchesSectionFilter(section.title, lowerFilter);
87
+ if (matched && section.title)
88
+ titles.add(section.title);
89
+ walk(section.subsections ?? [], matched);
90
+ }
91
+ };
92
+ walk(sections, false);
93
+ return titles;
44
94
  }
45
95
  /**
46
96
  * Render a section subtree as text blocks, in document order: each section's
@@ -73,16 +123,52 @@ function clampSectionDepth(sections, depth = 1) {
73
123
  });
74
124
  }
75
125
  /**
76
- * Apply the requested section/reference filters, then clamp the section tree to
77
- * the depth the output schema carries. Both run here so every path producing a
78
- * `pmc` article PMC EFetch and the Europe PMC stage shares one shape, and
79
- * the budget helpers downstream count the text that will actually survive
80
- * validation. (#112)
126
+ * Replace an article's table list, dropping the field entirely when nothing is
127
+ * left. An empty array would read as "this article has no tables", which is the
128
+ * one thing an absent field already says and a filtered-to-nothing list does
129
+ * not mean.
130
+ */
131
+ function withTables(article, tables) {
132
+ const { tables: _replaced, ...rest } = article;
133
+ return (tables.length > 0 ? { ...rest, tables } : rest);
134
+ }
135
+ /**
136
+ * Narrow the table list to what the request asked for. `includeTables: false`
137
+ * is the wholesale off switch. An active `sections` filter narrows tables with
138
+ * it: a table names the section it sat in — body, back matter or appendix
139
+ * alike — so it survives when that section did, and a table that names no
140
+ * section, such as a `<floats-group>` deposit, is dropped because the caller
141
+ * asked for named headings and it belongs to none. With no `sections` filter
142
+ * every table is returned. (#111)
143
+ */
144
+ function applyTableFilters(article, filters) {
145
+ if (!article.tables?.length)
146
+ return article;
147
+ if (!filters.includeTables)
148
+ return withTables(article, []);
149
+ if (!filters.sections?.length)
150
+ return article;
151
+ const surviving = matchedSectionTitles(article.sections, filters.sections.map(lowerCase));
152
+ return withTables(article, article.tables.filter((t) => t.sectionTitle !== undefined && surviving.has(t.sectionTitle)));
153
+ }
154
+ /**
155
+ * Apply the requested section/reference/table filters, then clamp the section
156
+ * tree to the depth the output schema carries. All of it runs here so every path
157
+ * producing a `pmc` article — PMC EFetch and the Europe PMC stage — shares one
158
+ * shape, and the budget helpers downstream count the text that will actually
159
+ * survive validation. (#112)
160
+ *
161
+ * Order is load-bearing. Tables are matched against the section tree *after*
162
+ * `filterSections` and the `maxSections` slice, so they narrow with exactly what
163
+ * the response returns, and *before* `clampSectionDepth`, which folds sections
164
+ * past {@link MAX_SECTION_DEPTH} into a parent's text — their titles vanish from
165
+ * the output while a table still names them, so a title set built after the
166
+ * clamp would drop tables that should have survived. (#111)
81
167
  */
82
168
  function applyPmcFilters(article, filters) {
83
169
  let out = article;
84
170
  if (filters.sections?.length) {
85
- out = { ...out, sections: filterSections(out.sections, filters.sections) };
171
+ out = { ...out, sections: pruneSections(out.sections, filters.sections.map(lowerCase)) };
86
172
  }
87
173
  if (filters.maxSections !== undefined) {
88
174
  out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
@@ -91,6 +177,7 @@ function applyPmcFilters(article, filters) {
91
177
  const { references: _, ...rest } = out;
92
178
  out = rest;
93
179
  }
180
+ out = applyTableFilters(out, filters);
94
181
  return { ...out, sections: clampSectionDepth(out.sections) };
95
182
  }
96
183
  /**
@@ -125,11 +212,16 @@ function articleDisplayId(a) {
125
212
  * Compose the single recovery notice for `sections`-filter misses. Names the
126
213
  * requested terms and the affected article id(s) so the agent can distinguish a
127
214
  * filtered-empty body from one absent upstream, and points at the recovery. (#80)
215
+ *
216
+ * States the scope the filter actually searches — section and subsection titles
217
+ * at every depth — so a caller who named a nested heading learns the term itself
218
+ * matched nothing, rather than being left to suspect the filter never looked
219
+ * that deep. (#126)
128
220
  */
129
221
  function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
130
222
  const terms = sectionFilter.join(', ');
131
223
  const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
132
- return `No body sections 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.`;
224
+ 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.`;
133
225
  }
134
226
  /**
135
227
  * Compose the recovery notice for identifiers whose only retrievable record was
@@ -204,6 +296,48 @@ const ReferenceSchema = z
204
296
  label: z.string().optional().describe('Reference label'),
205
297
  })
206
298
  .describe('Reference entry');
299
+ /**
300
+ * One `<table-wrap>`, hung off the article rather than off a section.
301
+ *
302
+ * The article is the only level that can hold every table: roughly a quarter of
303
+ * real `<table-wrap>` elements sit in `<floats-group>`, `<back>`, a
304
+ * `<table-wrap-group>` or an appendix, with no `<sec>` to attach to. A table
305
+ * inside a section names it in {@link sectionTitle} instead of being placed by
306
+ * position.
307
+ *
308
+ * The shape also lands exactly on the `format-parity` sentinel walker's eight-hop
309
+ * budget — `articles[]` → the article union → `tables[]` → `rows[][]` — with no
310
+ * headroom, and a section-hung variant would spend a hop the existing
311
+ * `sections[]` → `subsections[]` chain already needs. Re-run
312
+ * `bun run lint:mcp` after any change here; a level added anywhere inside puts
313
+ * its own leaves out of the walker's reach and ships their `format()` parity
314
+ * unverified. (#111, #112)
315
+ */
316
+ const TableSchema = z
317
+ .object({
318
+ label: z.string().optional().describe('Table label as printed, e.g. `TABLE 1`'),
319
+ caption: z.string().optional().describe('Caption text, with the label excluded'),
320
+ id: z
321
+ .string()
322
+ .optional()
323
+ .describe('JATS `id` attribute — the target body-text cross-references point at'),
324
+ sectionTitle: z
325
+ .string()
326
+ .optional()
327
+ .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.'),
328
+ headerRowCount: z
329
+ .number()
330
+ .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.'),
331
+ rows: z
332
+ .array(z.array(z.string()).describe('One row, as cell text by grid column'))
333
+ .describe('Cell text by row, in document order, one entry per grid column. `colspan` and `rowspan` are expanded, so a cell covering several columns or rows repeats its text across each cell it covers and a well-formed table is rectangular — align on position from the left, and read a repeated value as one spanning cell rather than several measurements. Empty when `unextractableReason` is set.'),
334
+ footnotes: z.string().optional().describe('`<table-wrap-foot>` text, flattened to one string'),
335
+ unextractableReason: z
336
+ .enum(['cals-tgroup', 'graphic-only', 'no-rows'])
337
+ .optional()
338
+ .describe('Why `rows` is empty — set only then. graphic-only: the table was deposited as an image with no underlying markup. cals-tgroup: the table uses the CALS `<tgroup>` model, which this server does not extract (0 of 283 tables in an open-access survey used it). no-rows: the markup carried no rows. The label and caption are still returned, so a table that could not be read is visible rather than silently missing.'),
339
+ })
340
+ .describe('One table from the article, with its cells, caption, and owning section');
207
341
  const PublicationDateSchema = z
208
342
  .object({
209
343
  year: z.string().optional().describe('Publication year'),
@@ -239,6 +373,10 @@ const PmcArticleSchema = z
239
373
  articleType: z.string().optional().describe('Article type'),
240
374
  publicationDate: PublicationDateSchema.optional(),
241
375
  sections: z.array(SectionSchema).describe('Article body sections'),
376
+ tables: z
377
+ .array(TableSchema)
378
+ .optional()
379
+ .describe('Every `<table-wrap>` the article carries, in document order — from the body and from `<floats-group>`, `<back>` and appendices alike. Absent when the article deposits none, when `includeTables` is false, or when a `sections` filter left none standing.'),
242
380
  references: z.array(ReferenceSchema).optional().describe('Reference list'),
243
381
  epmcId: z
244
382
  .string()
@@ -379,6 +517,14 @@ const TruncatedArticleSchema = z
379
517
  .array(TruncatedSectionSchema)
380
518
  .optional()
381
519
  .describe('Per-section accounting for `source: pmc` articles, in document order, including sections dropped for budget. Absent for `source: unpaywall`, whose body has no section structure.'),
520
+ omittedTables: z
521
+ .number()
522
+ .optional()
523
+ .describe('Tables this article dropped whole because the budget left no room for them. A table is never cut mid-row, so it is either returned complete or counted here. Absent when none were dropped.'),
524
+ omittedTableNames: z
525
+ .array(z.string())
526
+ .optional()
527
+ .describe("The dropped tables by name, in document order — each table's label, else its `id`, else `table <n>` for its position in the article. Names the tables a bare count only hints at, the way `deferred.ids` names deferred articles. Every table from the first that did not fit onward is here: admission stops at that table rather than skipping ahead to a smaller one, so these are contiguous. Absent when none were dropped."),
382
528
  })
383
529
  .describe('Character accounting for one article the budget shortened');
384
530
  const TruncationSchema = z
@@ -400,6 +546,10 @@ const TruncationSchema = z
400
546
  omittedSections: z
401
547
  .number()
402
548
  .describe('Body sections dropped entirely because an article budget was exhausted before reaching them. Always 0 in `outline` mode, which keeps every heading.'),
549
+ omittedTables: z
550
+ .number()
551
+ .optional()
552
+ .describe('Tables dropped whole across every budgeted article, because the budget left no room once body sections were served. Absent when none were dropped. Re-request the affected articles with a higher `maxCharacters`, or with `sections` narrowed, to receive them.'),
403
553
  articles: z
404
554
  .array(TruncatedArticleSchema)
405
555
  .describe('Per-article accounting, covering only the articles the budget shortened'),
@@ -436,6 +586,10 @@ function budgetRequested(budget) {
436
586
  function sectionTextFields(section) {
437
587
  return [section.text, ...(section.subsections ?? []).flatMap(sectionTextFields)];
438
588
  }
589
+ /** Combined length of the strings given, skipping the absent ones. */
590
+ function totalLength(parts) {
591
+ return parts.reduce((n, part) => n + (part?.length ?? 0), 0);
592
+ }
439
593
  /**
440
594
  * Body characters a section carries — its own text plus every nested
441
595
  * subsection's. Measured off {@link sectionTextFields} rather than its own walk,
@@ -443,7 +597,46 @@ function sectionTextFields(section) {
443
597
  * exactly the fields {@link fitFields} shortens.
444
598
  */
445
599
  function sectionCharacters(section) {
446
- return sectionTextFields(section).reduce((n, text) => n + text.length, 0);
600
+ return totalLength(sectionTextFields(section));
601
+ }
602
+ /**
603
+ * Characters a table costs the budget: everything it renders — label, caption,
604
+ * every cell, footnotes. The whole figure is what admitting the table spends,
605
+ * and a table is admitted or dropped whole, so there is no partial measure to
606
+ * take. (#111)
607
+ */
608
+ function tableCharacters(table) {
609
+ return totalLength([table.label, table.caption, table.footnotes, ...table.rows.flat()]);
610
+ }
611
+ /**
612
+ * Admit tables in document order until the allowance is spent, then drop the
613
+ * rest whole and name them.
614
+ *
615
+ * Admission stops at the first table that does not fit rather than skipping past
616
+ * it to a smaller one further down: the returned set stays a document-order
617
+ * prefix, so a caller reading it knows where the response stopped instead of
618
+ * receiving a late table with nothing saying the earlier ones exist. A table
619
+ * that does not fit is never cut either — half a grid reads as a complete one
620
+ * carrying values that were never deposited, the defect the table extraction
621
+ * exists to fix. Both mirror how `maxResponseCharacters` defers a whole article
622
+ * rather than half-populating it. (#111)
623
+ */
624
+ function fitTables(tables, allowance) {
625
+ const kept = [];
626
+ let spent = 0;
627
+ for (const [index, table] of tables.entries()) {
628
+ const size = tableCharacters(table);
629
+ if (spent + size > allowance) {
630
+ return {
631
+ kept,
632
+ omittedNames: tables.slice(index).map((t, i) => tableDisplayName(t, index + i)),
633
+ spent,
634
+ };
635
+ }
636
+ kept.push(table);
637
+ spent += size;
638
+ }
639
+ return { kept, omittedNames: [], spent };
447
640
  }
448
641
  /**
449
642
  * Rebuild a section subtree from `fitted`, consuming one entry per node in the
@@ -530,9 +723,16 @@ function allotSectionBudgets(sizes, budget) {
530
723
  /**
531
724
  * Apply the character budget to a JATS article's body. Runs as a pure
532
725
  * post-processing pass after `applyPmcFilters`, so `sections` / `maxSections` /
533
- * `includeReferences` and the empty-body signals they feed are unaffected.
534
- * Titles, abstracts, identifiers, and references are never counted or cut —
535
- * the budget only spends on body text, keeping every article citable.
726
+ * `includeReferences` / `includeTables` and the empty-body signals they feed are
727
+ * unaffected. Titles, abstracts, identifiers, and references are never counted
728
+ * or cut — the budget spends on body text and table content, keeping every
729
+ * article citable.
730
+ *
731
+ * Body sections are served first and tables spend what `maxCharacters` leaves,
732
+ * in document order: a table that does not fit is dropped whole and counted,
733
+ * never truncated into a partial grid. With no `maxCharacters` — a bare
734
+ * `maxCharactersPerSection` request — nothing bounds the tables and every one is
735
+ * kept. (#111)
536
736
  *
537
737
  * Returns the article untouched (same object identity) when no budget was
538
738
  * requested or nothing exceeded it. A section left with zero characters is
@@ -541,11 +741,13 @@ function allotSectionBudgets(sizes, budget) {
541
741
  * caller can see which headings exist. (#81)
542
742
  */
543
743
  function applyPmcBudget(article, budget) {
544
- if (!budgetRequested(budget) || article.sections.length === 0) {
744
+ const tables = article.tables ?? [];
745
+ if (!budgetRequested(budget) || (article.sections.length === 0 && tables.length === 0)) {
545
746
  return { article, omittedSections: 0 };
546
747
  }
547
748
  const sizes = article.sections.map(sectionCharacters);
548
- const originalCharacters = sizes.reduce((sum, size) => sum + size, 0);
749
+ const tablesOriginal = tables.reduce((sum, table) => sum + tableCharacters(table), 0);
750
+ const originalCharacters = sizes.reduce((sum, size) => sum + size, 0) + tablesOriginal;
549
751
  const allowances = allotSectionBudgets(sizes, budget);
550
752
  const kept = [];
551
753
  const sectionReports = [];
@@ -554,7 +756,7 @@ function applyPmcBudget(article, budget) {
554
756
  article.sections.forEach((section, i) => {
555
757
  const original = sizes[i] ?? 0;
556
758
  const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
557
- const returned = fitted.reduce((sum, text) => sum + text.length, 0);
759
+ const returned = totalLength(fitted);
558
760
  returnedCharacters += returned;
559
761
  sectionReports.push({
560
762
  ...(section.title !== undefined && { title: section.title }),
@@ -568,13 +770,29 @@ function applyPmcBudget(article, budget) {
568
770
  }
569
771
  kept.push(withFittedTexts(section, fitted, { i: 0 }));
570
772
  });
571
- if (returnedCharacters === originalCharacters && omittedSections === 0) {
773
+ // Sections are served first; the tables spend whatever `maxCharacters` has
774
+ // left. A bare per-section budget sets no total, so nothing bounds them.
775
+ const tableAllowance = budget.maxCharacters === undefined
776
+ ? Number.POSITIVE_INFINITY
777
+ : Math.max(budget.maxCharacters - returnedCharacters, 0);
778
+ const fittedTables = fitTables(tables, tableAllowance);
779
+ returnedCharacters += fittedTables.spent;
780
+ const omittedTables = fittedTables.omittedNames.length;
781
+ if (returnedCharacters === originalCharacters && omittedSections === 0 && omittedTables === 0) {
572
782
  return { article, omittedSections: 0 };
573
783
  }
574
784
  return {
575
- article: { ...article, sections: kept },
785
+ article: withTables({ ...article, sections: kept }, fittedTables.kept),
576
786
  omittedSections,
577
- truncation: { originalCharacters, returnedCharacters, sections: sectionReports },
787
+ truncation: {
788
+ originalCharacters,
789
+ returnedCharacters,
790
+ sections: sectionReports,
791
+ ...(omittedTables > 0 && {
792
+ omittedTables,
793
+ omittedTableNames: fittedTables.omittedNames,
794
+ }),
795
+ },
578
796
  };
579
797
  }
580
798
  /**
@@ -593,6 +811,62 @@ function applyContentBudget(content, budget) {
593
811
  truncation: { originalCharacters: content.length, returnedCharacters: kept.length },
594
812
  };
595
813
  }
814
+ /** Why a table arrived with no rows, in the reader's terms. */
815
+ const UNEXTRACTABLE_TABLE_EXPLANATIONS = {
816
+ 'cals-tgroup': 'it uses the CALS `<tgroup>` model, which this server does not extract',
817
+ 'graphic-only': 'it was deposited as an image, with no underlying markup to read',
818
+ 'no-rows': 'its markup carried no rows',
819
+ };
820
+ /** How a table is named in a notice: its label, else its id, else its position. */
821
+ function tableDisplayName(table, index) {
822
+ return table.label ?? table.id ?? `table ${index + 1}`;
823
+ }
824
+ /**
825
+ * Collect the returned tables that carry no cell values. Read off the articles
826
+ * the response actually ships — after every filter and both budgets — so a table
827
+ * the `sections` filter removed, or one belonging to a deferred article, is
828
+ * never named as though the caller received it. (#111)
829
+ */
830
+ function collectUnextractableTables(articles) {
831
+ const entries = [];
832
+ for (const article of articles) {
833
+ if (article.source !== 'pmc')
834
+ continue;
835
+ for (const [index, table] of (article.tables ?? []).entries()) {
836
+ const reason = table.unextractableReason;
837
+ if (!reason)
838
+ continue;
839
+ entries.push({
840
+ articleId: articleDisplayId(article),
841
+ name: tableDisplayName(table, index),
842
+ reason,
843
+ });
844
+ }
845
+ }
846
+ return entries;
847
+ }
848
+ /**
849
+ * Compose the recovery notice for tables returned with a label and caption but
850
+ * no cells. Without it a table-bearing response carries no response-level signal
851
+ * that some of the numbers the caller asked for are absent — the per-table
852
+ * `unextractableReason` only helps a reader who already went looking at that
853
+ * table. One notice covers the whole response, aggregated across articles, the
854
+ * way the `sections`-filter miss notice does. (#111)
855
+ *
856
+ * The wording turns on recoverability: nothing the caller changes produces these
857
+ * cells, because the markup does not exist upstream. Tables the character budget
858
+ * dropped are the opposite case — recoverable by raising `maxCharacters` — so
859
+ * they stay with the rest of the budget accounting in {@link
860
+ * buildTruncationNotice} rather than being mixed in here.
861
+ */
862
+ function buildUnextractableTablesNotice(entries) {
863
+ const subject = entries.length === 1 ? '1 table was' : `${entries.length} tables were`;
864
+ const named = entries.map((e) => `${e.name} (${e.articleId}, ${e.reason})`).join(', ');
865
+ const reasons = [...new Set(entries.map((e) => e.reason))]
866
+ .map((reason) => `${reason} — ${UNEXTRACTABLE_TABLE_EXPLANATIONS[reason]}`)
867
+ .join('; ');
868
+ return `${subject} returned with a label and caption but no cell values: ${named}. Re-calling will not recover the cells (${reasons}); see \`unextractableReason\` on each table.`;
869
+ }
596
870
  /**
597
871
  * Compose the recovery notice for a budgeted response. Names what was spent and
598
872
  * where the detail lives so an agent reading only `content[]` knows the body it
@@ -603,13 +877,20 @@ function buildTruncationNotice(truncation) {
603
877
  const omitted = truncation.omittedSections > 0
604
878
  ? ` ${truncation.omittedSections} section(s) were dropped once the budget ran out.`
605
879
  : '';
880
+ // Tables are admitted after sections and only whole, so a dropped one is
881
+ // absent rather than partial — say so, and name them: a bare count leaves the
882
+ // reader unable to tell which numbers are missing from what they received.
883
+ const droppedNames = truncation.articles.flatMap((a) => a.omittedTableNames ?? []);
884
+ const omittedTables = truncation.omittedTables
885
+ ? ` ${truncation.omittedTables} table(s) were dropped whole rather than cut mid-row: ${droppedNames.join(', ')}.`
886
+ : '';
606
887
  // Name only the budgets the request actually set — pointing at `maxCharacters`
607
888
  // when the caller only capped per-section sends them to a knob that is unset.
608
889
  const knobs = [
609
890
  truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
610
891
  truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
611
892
  ].filter((k) => k !== undefined);
612
- 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} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
893
+ 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} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
613
894
  }
614
895
  /**
615
896
  * Compose the recovery notice for articles the whole-response budget withheld.
@@ -643,7 +924,7 @@ function countOmittedSections(entry, mode) {
643
924
  * recoveries that silently can't happen.
644
925
  */
645
926
  export function buildFulltextDescription(tiers) {
646
- const base = 'Fetch full-text articles from PubMed Central with structured sections and references.';
927
+ const base = 'Fetch full-text articles from PubMed Central with structured sections, tables, and references.';
647
928
  const epmcClause = 'Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart)';
648
929
  const unpaywallClause = 'Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text';
649
930
  let fallback;
@@ -711,6 +992,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
711
992
  .boolean()
712
993
  .default(false)
713
994
  .describe('Include reference list. Applies to `source=pmc` results only.'),
995
+ includeTables: z
996
+ .boolean()
997
+ .default(true)
998
+ .describe("Include the article's tables — cells, captions, labels and footnotes. On by default because a dropped table takes its numbers with it. Table-dense articles pay for it: rendered tables typically add 12–17% to an article record and can more than double it. Set false to omit them, or cap the cost with `maxCharacters`, which drops tables it cannot fit whole. Applies to `source=pmc` results only."),
714
999
  maxSections: z
715
1000
  .number()
716
1001
  .int()
@@ -721,14 +1006,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
721
1006
  sections: z
722
1007
  .array(z.string())
723
1008
  .optional()
724
- .describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
1009
+ .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 narrow with the filter: one whose section did not survive, or that names no section, is dropped. Applies to `source=pmc` results only.'),
725
1010
  maxCharacters: z
726
1011
  .number()
727
1012
  .int()
728
1013
  .min(1)
729
1014
  .max(1_000_000)
730
1015
  .optional()
731
- .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, 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.'),
1016
+ .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text plus table label, caption, cell and footnote text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. 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 and tables spend what is left, in document order — admission stops at the first table that does not fit, and every table from there on is dropped whole rather than cut mid-row, counted in `truncation.omittedTables` and named in `truncation.articles[].omittedTableNames`. Applied after `sections`, `maxSections`, `includeReferences`, and `includeTables`, 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.'),
732
1017
  maxCharactersPerSection: z
733
1018
  .number()
734
1019
  .int()
@@ -763,17 +1048,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
763
1048
  truncation: TruncationSchema.optional(),
764
1049
  deferred: DeferredSchema.optional(),
765
1050
  }),
766
- // Recovery guidance for four cases — a `sections` filter that removed every
1051
+ // Recovery guidance for five cases — a `sections` filter that removed every
767
1052
  // body section (#80), a record the chain could only retrieve as front matter
768
- // (#86), a body the per-article character budget shortened (#81), and articles
769
- // the whole-response budget withheld (#100). Agent-facing context surfaced via
1053
+ // (#86), a table returned with no cell values (#111), a body the per-article
1054
+ // character budget shortened (#81), and articles the whole-response budget
1055
+ // withheld (#100). Agent-facing context surfaced via
770
1056
  // ctx.enrich.notice() to structuredContent and content[]; absent when none
771
1057
  // applies.
772
1058
  enrichment: {
773
1059
  notice: z
774
1060
  .string()
775
1061
  .optional()
776
- .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 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.'),
1062
+ .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.'),
777
1063
  truncated: z
778
1064
  .boolean()
779
1065
  .optional()
@@ -1361,6 +1647,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1361
1647
  unavailable: unavailable.length,
1362
1648
  ...(deferred && { deferred: deferred.deferredCount }),
1363
1649
  });
1650
+ // Summed off the per-article entries rather than carried through every
1651
+ // stage, so a deferred article's dropped tables leave the roll-up together
1652
+ // with its entry when the splice above removes it. (#111)
1653
+ const omittedTables = truncatedArticles.reduce((n, a) => n + (a.omittedTables ?? 0), 0);
1364
1654
  // Rolled up only when the budget actually removed characters, so an
1365
1655
  // under-budget request returns exactly what it did before the budget
1366
1656
  // controls existed. (#81)
@@ -1374,6 +1664,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1374
1664
  originalCharacters: truncatedArticles.reduce((n, a) => n + a.originalCharacters, 0),
1375
1665
  returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
1376
1666
  omittedSections,
1667
+ ...(omittedTables > 0 && { omittedTables }),
1377
1668
  articles: truncatedArticles,
1378
1669
  }
1379
1670
  : undefined;
@@ -1386,6 +1677,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1386
1677
  const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
1387
1678
  if (unrecoveredBodyless.length > 0)
1388
1679
  notices.push(buildBodylessNotice(unrecoveredBodyless));
1680
+ const unextractableTables = collectUnextractableTables(articles);
1681
+ if (unextractableTables.length > 0) {
1682
+ notices.push(buildUnextractableTablesNotice(unextractableTables));
1683
+ }
1389
1684
  if (truncation) {
1390
1685
  notices.push(buildTruncationNotice(truncation));
1391
1686
  ctx.enrich({ truncated: true });
@@ -1942,7 +2237,8 @@ function formatUnqueriedTiers(tiers, chain) {
1942
2237
  * separators — so the numbers stay greppable. (#81)
1943
2238
  */
1944
2239
  function formatTruncation(t, lines) {
1945
- lines.push(`\n**Truncated (${t.mode} mode):** ${t.returnedCharacters} of ${t.originalCharacters} body characters returned across ${t.articles.length} article(s); ${t.omittedSections} section(s) omitted`);
2240
+ const tablesOmitted = t.omittedTables === undefined ? '' : `; ${t.omittedTables} table(s) omitted whole`;
2241
+ lines.push(`\n**Truncated (${t.mode} mode):** ${t.returnedCharacters} of ${t.originalCharacters} body characters returned across ${t.articles.length} article(s); ${t.omittedSections} section(s) omitted${tablesOmitted}`);
1946
2242
  const budgets = [
1947
2243
  t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
1948
2244
  t.maxCharactersPerSection === undefined
@@ -1952,7 +2248,10 @@ function formatTruncation(t, lines) {
1952
2248
  if (budgets.length)
1953
2249
  lines.push(`Budget applied: ${budgets.join(', ')}`);
1954
2250
  for (const a of t.articles) {
1955
- lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters`);
2251
+ const tablesDropped = a.omittedTables === undefined
2252
+ ? ''
2253
+ : `, ${a.omittedTables} table(s) dropped whole: ${(a.omittedTableNames ?? []).join(', ')}`;
2254
+ lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters${tablesDropped}`);
1956
2255
  for (const s of a.sections ?? []) {
1957
2256
  lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
1958
2257
  }
@@ -2021,6 +2320,8 @@ function formatPmcArticle(a, lines, truncation) {
2021
2320
  lines.push(`\n#### Abstract\n${a.abstract}`);
2022
2321
  for (const sec of a.sections)
2023
2322
  formatSection(sec, lines, 4);
2323
+ if (a.tables?.length)
2324
+ formatTables(a.tables, lines);
2024
2325
  if (a.references?.length) {
2025
2326
  lines.push(`\n#### References (${a.references.length})`);
2026
2327
  for (const ref of a.references) {
@@ -2029,6 +2330,83 @@ function formatPmcArticle(a, lines, truncation) {
2029
2330
  }
2030
2331
  }
2031
2332
  }
2333
+ /**
2334
+ * Render every table as a Markdown grid, so a `content[]` reader gets the same
2335
+ * cells `structuredContent` carries rather than a note that tables exist. (#111)
2336
+ *
2337
+ * Cell text goes through {@link escapeMarkdownTableCell} — the inline escape
2338
+ * plus the `|` a cell cannot carry raw. The parser expands `colspan` and
2339
+ * `rowspan`, so a well-formed table arrives rectangular and every value renders
2340
+ * under the header it belongs to; a row still short of the widest is padded on
2341
+ * the right with empty cells only, never a neighbour's value.
2342
+ */
2343
+ function formatTables(tables, lines) {
2344
+ lines.push(`\n#### Tables (${tables.length})`);
2345
+ for (const table of tables) {
2346
+ const heading = [table.label, table.caption].filter(Boolean).join(' — ');
2347
+ lines.push(`\n##### ${escapeMarkdownInline(heading || 'Table')}`);
2348
+ const meta = [
2349
+ table.sectionTitle ? `Section: ${escapeMarkdownInline(table.sectionTitle)}` : undefined,
2350
+ table.id ? `id: ${escapeMarkdownInline(table.id)}` : undefined,
2351
+ describeHeaderRows(table),
2352
+ ].filter((part) => part !== undefined);
2353
+ if (meta.length)
2354
+ lines.push(`*${meta.join(' · ')}*`);
2355
+ if (table.unextractableReason) {
2356
+ lines.push(`\n> Table body could not be read (${table.unextractableReason}) — ${UNEXTRACTABLE_TABLE_EXPLANATIONS[table.unextractableReason]}. The label and caption above are all this deposit carries; no cell values exist to return.`);
2357
+ }
2358
+ if (table.rows.length > 0)
2359
+ lines.push(...renderTableGrid(table.rows, table.headerRowCount));
2360
+ if (table.footnotes)
2361
+ lines.push(`\nFootnotes: ${escapeMarkdownInline(table.footnotes)}`);
2362
+ }
2363
+ }
2364
+ /**
2365
+ * The meta-line clause describing a table's header rows.
2366
+ *
2367
+ * A Markdown grid carries exactly one header row, so a table declaring several
2368
+ * has them folded into it — say how many were folded, or the grid understates
2369
+ * what the deposit declared. A table declaring none still needs the empty header
2370
+ * row Markdown requires above the divider; naming that keeps a reader from
2371
+ * taking the blank row for a header the publisher deposited and left empty.
2372
+ */
2373
+ function describeHeaderRows(table) {
2374
+ if (table.rows.length === 0)
2375
+ return;
2376
+ if (table.headerRowCount === 0)
2377
+ return 'header rows: none declared — every row below is data';
2378
+ if (table.headerRowCount === 1)
2379
+ return 'header rows: 1';
2380
+ return `header rows: ${table.headerRowCount} (folded into one)`;
2381
+ }
2382
+ /**
2383
+ * Join one grid column's header cells into the single header path Markdown can
2384
+ * carry. Consecutive repeats — what expanding a `colspan` produces — collapse to
2385
+ * one, so a group header spanning three columns reads once per column rather
2386
+ * than three times in each.
2387
+ */
2388
+ function foldHeaderColumn(headerRows, column) {
2389
+ const path = [];
2390
+ for (const row of headerRows) {
2391
+ const cell = row[column] ?? '';
2392
+ if (cell && cell !== path.at(-1))
2393
+ path.push(cell);
2394
+ }
2395
+ return path.join(' · ');
2396
+ }
2397
+ /** One table's rows as Markdown grid lines, preceded by a blank line. */
2398
+ function renderTableGrid(rows, headerRowCount) {
2399
+ const columns = rows.reduce((widest, row) => Math.max(widest, row.length), 0);
2400
+ const renderRow = (cells) => `| ${Array.from({ length: columns }, (_, i) => escapeMarkdownTableCell(cells[i] ?? '')).join(' | ')} |`;
2401
+ const headerRows = rows.slice(0, headerRowCount);
2402
+ const header = Array.from({ length: columns }, (_, i) => foldHeaderColumn(headerRows, i));
2403
+ return [
2404
+ '',
2405
+ renderRow(header),
2406
+ `| ${Array.from({ length: columns }, () => '---').join(' | ')} |`,
2407
+ ...rows.slice(headerRowCount).map(renderRow),
2408
+ ];
2409
+ }
2032
2410
  function formatUnpaywallArticle(a, lines, truncation) {
2033
2411
  const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
2034
2412
  const heading = a.title ?? requestedId;