@cyanheads/pubmed-mcp-server 2.9.9 → 2.10.0

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 (37) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +19 -4
  4. package/dist/index.js +3 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp-server/tools/definitions/_text.d.ts +22 -0
  7. package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -0
  8. package/dist/mcp-server/tools/definitions/_text.js +32 -0
  9. package/dist/mcp-server/tools/definitions/_text.js.map +1 -0
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +35 -0
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +634 -123
  13. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +87 -0
  15. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -0
  16. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +195 -0
  17. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -0
  18. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +1 -0
  19. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +29 -8
  21. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  22. package/dist/services/europe-pmc/api-client.d.ts +14 -1
  23. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  24. package/dist/services/europe-pmc/api-client.js +21 -25
  25. package/dist/services/europe-pmc/api-client.js.map +1 -1
  26. package/dist/services/europe-pmc/europe-pmc-service.d.ts +21 -7
  27. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  28. package/dist/services/europe-pmc/europe-pmc-service.js +39 -6
  29. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  30. package/dist/services/europe-pmc/types.d.ts +9 -0
  31. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  32. package/dist/services/openalex/api-client.d.ts +15 -1
  33. package/dist/services/openalex/api-client.d.ts.map +1 -1
  34. package/dist/services/openalex/api-client.js +21 -24
  35. package/dist/services/openalex/api-client.js.map +1 -1
  36. package/package.json +1 -1
  37. package/server.json +3 -3
@@ -30,6 +30,7 @@ import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
30
30
  import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
31
31
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
32
32
  import { pmidStringSchema } from './_schemas.js';
33
+ import { sliceCodeUnits } from './_text.js';
33
34
  function normalizePmcId(id) {
34
35
  return id.replace(/^PMC/i, '');
35
36
  }
@@ -64,10 +65,22 @@ function applyPmcFilters(article, filters) {
64
65
  function isSectionFilterMiss(before, after, sectionFilter) {
65
66
  return (Boolean(sectionFilter?.length) && before.sections.length > 0 && after.sections.length === 0);
66
67
  }
67
- /** Pick the best human-readable identifier for an article whose section filter
68
- * missed, for the recovery notice. Treats empty strings as absentEPMC-only
68
+ /**
69
+ * True when the upstream JATS carried no body sections at all front matter and
70
+ * abstract only. Publishers that block full-text XML download still return an
71
+ * `<article>` with a populated `<front>`, so the parsed article looks like a hit
72
+ * while carrying nothing to read. Distinct from {@link isSectionFilterMiss},
73
+ * which needs a non-empty pre-filter body: the two never overlap. Evaluated
74
+ * against the *pre-filter* article so a `sections` filter can't be mistaken for
75
+ * an upstream absence. (#86)
76
+ */
77
+ function isBodylessArticle(before) {
78
+ return before.sections.length === 0;
79
+ }
80
+ /** Pick the best human-readable identifier for an article, for recovery notices
81
+ * and character-budget accounting. Treats empty strings as absent — EPMC-only
69
82
  * records carry an empty `pmcId`. */
70
- function articleSectionMissId(a) {
83
+ function articleDisplayId(a) {
71
84
  return [a.pmcId, a.pmid, a.doi, a.epmcId].find((v) => v && v.length > 0) ?? 'article';
72
85
  }
73
86
  /**
@@ -80,6 +93,21 @@ function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
80
93
  const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
81
94
  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.`;
82
95
  }
96
+ /**
97
+ * Compose the recovery notice for identifiers whose only retrievable record was
98
+ * metadata-only — PMC or Europe PMC returned front matter with no body, and no
99
+ * later tier recovered a full-text copy. Points at the tool that still serves
100
+ * the abstract so the metadata isn't simply lost.
101
+ *
102
+ * States what the chain observed rather than asserting the article has no body:
103
+ * a later tier may well have located an open-access copy and failed to download
104
+ * it (`unpaywall:fetch-failed`), so the per-tier outcomes are the honest answer
105
+ * and the notice defers to them. (#86)
106
+ */
107
+ function buildBodylessNotice(affectedIds) {
108
+ const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
109
+ return `No body text could be retrieved for ${subject} — the full-text source returned front matter and abstract only, and no later tier recovered a copy. See \`triedTiers\` on the \`unavailable\` entry for what each tier reported, and use \`pubmed_fetch_articles\` for the abstract and metadata.`;
110
+ }
83
111
  // ─── Schemas ─────────────────────────────────────────────────────────────────
84
112
  const SubsectionSchema = z
85
113
  .object({
@@ -173,10 +201,14 @@ const UnpaywallArticleSchema = z
173
201
  contentFormat: z
174
202
  .enum(['html-markdown', 'pdf-text'])
175
203
  .describe('How `content` was extracted. html-markdown: Defuddle extracted Markdown from an HTML landing page; light section structure may survive but is not guaranteed. pdf-text: unpdf extracted plain text from a PDF; no section, reference, or heading structure.'),
204
+ pmcId: z
205
+ .string()
206
+ .optional()
207
+ .describe('PMC ID this article was requested under, in `PMC<digits>` form — present for `pmcids` input, absent for `pmids` and `dois` input. Ties the article back to the requested identifier, which `unavailable[]` keys on for the ids that found nothing.'),
176
208
  pmid: z
177
209
  .string()
178
210
  .optional()
179
- .describe('PubMed ID when input was `pmids`; absent for `dois` input'),
211
+ .describe('PubMed ID when input was `pmids`; absent for `pmcids` and `dois` input'),
180
212
  pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
181
213
  doi: z.string().describe('DOI used to locate the open-access copy'),
182
214
  sourceUrl: z.string().describe('URL the content was fetched from'),
@@ -209,25 +241,27 @@ const UnavailableReasonSchema = z
209
241
  'not-found',
210
242
  'no-pmc-fallback-disabled',
211
243
  'no-epmc-fulltext',
244
+ 'no-body',
212
245
  'no-doi',
213
246
  'no-oa',
214
247
  'fetch-failed',
215
248
  'parse-failed',
216
249
  'service-error',
217
250
  ])
218
- .describe('Why no full text was returned. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data).');
251
+ .describe('Why no full text was returned. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data).');
219
252
  const TierOutcomeSchema = z
220
253
  .enum([
221
254
  'not-attempted',
222
255
  'miss',
223
256
  'no-fulltext',
257
+ 'no-body',
224
258
  'no-doi',
225
259
  'no-oa',
226
260
  'fetch-failed',
227
261
  'parse-failed',
228
262
  'service-error',
229
263
  ])
230
- .describe('Per-tier outcome. not-attempted: tier was skipped. miss: tier returned no record. no-fulltext: EPMC indexed the record but publishes no fullTextXML. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall reports no open-access copy. fetch-failed: OA copy download failed. parse-failed: extraction produced empty content. service-error: tier service threw.');
264
+ .describe('Per-tier outcome. not-attempted: tier was skipped. miss: tier returned no record. no-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the tier returned a record with front matter and abstract but no body sections, so the chain continued. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall reports no open-access copy. fetch-failed: OA copy download failed. parse-failed: extraction produced empty content. service-error: tier service threw.');
231
265
  const TriedTierSchema = z
232
266
  .object({
233
267
  tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
@@ -247,6 +281,234 @@ const UnavailableSchema = z
247
281
  .describe('Per-tier outcomes the chain produced for this id, in execution order. Covers `pmc`, `europepmc`, and `unpaywall` — the same tiers the tool description references. Tiers that the chain skipped appear as `outcome: not-attempted` with a `detail` explaining why.'),
248
282
  })
249
283
  .describe('One identifier that could not be returned, with the full chain it traversed');
284
+ // ─── Character-budget schemas ────────────────────────────────────────────────
285
+ const TruncatedSectionSchema = z
286
+ .object({
287
+ title: z.string().optional().describe('Section heading, when the section carries one'),
288
+ originalCharacters: z
289
+ .number()
290
+ .describe('Body characters this section carried before the budget pass'),
291
+ returnedCharacters: z
292
+ .number()
293
+ .describe('Body characters this section carries in the response. Zero means the section was dropped in `truncate` mode, or kept as a heading-only entry in `outline` mode.'),
294
+ truncated: z
295
+ .boolean()
296
+ .describe('True when the section returned fewer characters than it originally carried'),
297
+ })
298
+ .describe('Character accounting for one body section of a budgeted article');
299
+ const TruncatedArticleSchema = z
300
+ .object({
301
+ id: z
302
+ .string()
303
+ .describe('Identifier for the article — PMCID, PMID, DOI, or Europe PMC id, whichever the article carries first'),
304
+ source: z
305
+ .enum(['pmc', 'unpaywall'])
306
+ .describe('Which output shape was budgeted: `pmc` budgets body sections and subsections, `unpaywall` budgets the single `content` body'),
307
+ originalCharacters: z
308
+ .number()
309
+ .describe('Body characters this article carried before the budget pass'),
310
+ returnedCharacters: z.number().describe('Body characters this article carries in the response'),
311
+ sections: z
312
+ .array(TruncatedSectionSchema)
313
+ .optional()
314
+ .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.'),
315
+ })
316
+ .describe('Character accounting for one article the budget shortened');
317
+ const TruncationSchema = z
318
+ .object({
319
+ mode: z
320
+ .enum(['truncate', 'outline'])
321
+ .describe('The `overflowMode` that produced these results'),
322
+ maxCharacters: z.number().optional().describe('The `maxCharacters` budget applied, when set'),
323
+ maxCharactersPerSection: z
324
+ .number()
325
+ .optional()
326
+ .describe('The `maxCharactersPerSection` budget applied, when set'),
327
+ originalCharacters: z
328
+ .number()
329
+ .describe('Body characters the shortened articles carried before the budget pass'),
330
+ returnedCharacters: z
331
+ .number()
332
+ .describe('Body characters the shortened articles carry in this response'),
333
+ omittedSections: z
334
+ .number()
335
+ .describe('Body sections dropped entirely because an article budget was exhausted before reaching them. Always 0 in `outline` mode, which keeps every heading.'),
336
+ articles: z
337
+ .array(TruncatedArticleSchema)
338
+ .describe('Per-article accounting, covering only the articles the budget shortened'),
339
+ })
340
+ .describe('Character accounting for full text the budget shortened. Present only when a budget actually removed characters — its absence means every returned article carries its full post-filter body.');
341
+ /** True when the request asked for any budget at all. Without one, every budget
342
+ * helper returns its input untouched so the response is byte-identical. */
343
+ function budgetRequested(budget) {
344
+ return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
345
+ }
346
+ /** Body characters a top-level section carries — its own text plus its subsections'. */
347
+ function sectionCharacters(section) {
348
+ return (section.text.length + (section.subsections?.reduce((n, sub) => n + sub.text.length, 0) ?? 0));
349
+ }
350
+ /**
351
+ * Shorten an ordered list of text fields so their combined length fits
352
+ * `allowance`. Fields are filled in order, so earlier fields survive whole and
353
+ * later ones absorb the shortfall — the section's own text before its
354
+ * subsections. Cuts at the character boundary with no appended marker so the
355
+ * reported `returnedCharacters` is exact; `format()` carries the human-visible
356
+ * note. A cut that would split a surrogate pair backs off a code unit, so a
357
+ * field can return one character under its share — counts are measured off the
358
+ * returned text, never off the allowance. (#93)
359
+ */
360
+ function fitFields(fields, allowance) {
361
+ let remaining = Math.max(allowance, 0);
362
+ return fields.map((text) => {
363
+ const kept = sliceCodeUnits(text, remaining);
364
+ remaining -= kept.length;
365
+ return kept;
366
+ });
367
+ }
368
+ /**
369
+ * Split `total` evenly across sections, then hand the leftover from sections
370
+ * that need less than their share back to the ones still capped, until the
371
+ * budget is spent or every section holds all it can. Equal shares alone would
372
+ * strand budget on short sections — a ten-section article with two one-line
373
+ * sections would return well under what the caller asked for.
374
+ */
375
+ function evenShares(caps, total) {
376
+ const allowances = caps.map(() => 0);
377
+ let remaining = total;
378
+ while (remaining > 0) {
379
+ const hungry = caps.reduce((acc, cap, i) => {
380
+ if ((allowances[i] ?? 0) < cap)
381
+ acc.push(i);
382
+ return acc;
383
+ }, []);
384
+ if (hungry.length === 0)
385
+ break;
386
+ const share = Math.floor(remaining / hungry.length);
387
+ // Fewer characters left than sections still wanting them: hand out the
388
+ // remainder one character at a time so the budget is fully spent.
389
+ for (const i of hungry) {
390
+ const want = (caps[i] ?? 0) - (allowances[i] ?? 0);
391
+ const give = Math.min(share === 0 ? 1 : share, want, remaining);
392
+ allowances[i] = (allowances[i] ?? 0) + give;
393
+ remaining -= give;
394
+ if (remaining === 0)
395
+ break;
396
+ }
397
+ }
398
+ return allowances;
399
+ }
400
+ /**
401
+ * Decide how many characters each top-level section may keep.
402
+ *
403
+ * `truncate` fills sections greedily in document order: early sections keep
404
+ * their full text and sections reached after the budget is spent get nothing.
405
+ * `outline` spreads `maxCharacters` across every section instead, so each
406
+ * heading survives with an excerpt rather than the budget being consumed by the
407
+ * first sections. `maxCharactersPerSection` caps each section under either mode.
408
+ */
409
+ function allotSectionBudgets(sizes, budget) {
410
+ const perSection = budget.maxCharactersPerSection;
411
+ const total = budget.maxCharacters;
412
+ if (budget.overflowMode === 'outline' && total !== undefined) {
413
+ return evenShares(sizes.map((size) => Math.min(perSection ?? size, size)), total);
414
+ }
415
+ let remaining = total ?? sizes.reduce((sum, size) => sum + size, 0);
416
+ return sizes.map((size) => {
417
+ const allowance = Math.min(perSection ?? size, size, remaining);
418
+ remaining -= allowance;
419
+ return allowance;
420
+ });
421
+ }
422
+ /**
423
+ * Apply the character budget to a JATS article's body. Runs as a pure
424
+ * post-processing pass after `applyPmcFilters`, so `sections` / `maxSections` /
425
+ * `includeReferences` and the empty-body signals they feed are unaffected.
426
+ * Titles, abstracts, identifiers, and references are never counted or cut —
427
+ * the budget only spends on body text, keeping every article citable.
428
+ *
429
+ * Returns the article untouched (same object identity) when no budget was
430
+ * requested or nothing exceeded it. A section left with zero characters is
431
+ * dropped in `truncate` mode and counted as omitted; `outline` keeps it as a
432
+ * heading-only entry. Dropped sections still appear in the accounting so the
433
+ * caller can see which headings exist. (#81)
434
+ */
435
+ function applyPmcBudget(article, budget) {
436
+ if (!budgetRequested(budget) || article.sections.length === 0) {
437
+ return { article, omittedSections: 0 };
438
+ }
439
+ const sizes = article.sections.map(sectionCharacters);
440
+ const originalCharacters = sizes.reduce((sum, size) => sum + size, 0);
441
+ const allowances = allotSectionBudgets(sizes, budget);
442
+ const kept = [];
443
+ const sectionReports = [];
444
+ let omittedSections = 0;
445
+ let returnedCharacters = 0;
446
+ article.sections.forEach((section, i) => {
447
+ const original = sizes[i] ?? 0;
448
+ const fitted = fitFields([section.text, ...(section.subsections?.map((sub) => sub.text) ?? [])], allowances[i] ?? 0);
449
+ const returned = fitted.reduce((sum, text) => sum + text.length, 0);
450
+ returnedCharacters += returned;
451
+ sectionReports.push({
452
+ ...(section.title !== undefined && { title: section.title }),
453
+ originalCharacters: original,
454
+ returnedCharacters: returned,
455
+ truncated: returned < original,
456
+ });
457
+ if (returned === 0 && original > 0 && budget.overflowMode === 'truncate') {
458
+ omittedSections += 1;
459
+ return;
460
+ }
461
+ kept.push({
462
+ ...section,
463
+ text: fitted[0] ?? '',
464
+ ...(section.subsections && {
465
+ subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
466
+ }),
467
+ });
468
+ });
469
+ if (returnedCharacters === originalCharacters && omittedSections === 0) {
470
+ return { article, omittedSections: 0 };
471
+ }
472
+ return {
473
+ article: { ...article, sections: kept },
474
+ omittedSections,
475
+ truncation: { originalCharacters, returnedCharacters, sections: sectionReports },
476
+ };
477
+ }
478
+ /**
479
+ * Apply the character budget to an Unpaywall body. That body is one
480
+ * unstructured blob — HTML-as-Markdown or PDF-as-text — so only `maxCharacters`
481
+ * applies, and `outline` mode has no headings to preserve and behaves like
482
+ * `truncate`. (#81)
483
+ */
484
+ function applyContentBudget(content, budget) {
485
+ const cap = budget.maxCharacters;
486
+ if (cap === undefined || content.length <= cap)
487
+ return { content };
488
+ const kept = sliceCodeUnits(content, cap);
489
+ return {
490
+ content: kept,
491
+ truncation: { originalCharacters: content.length, returnedCharacters: kept.length },
492
+ };
493
+ }
494
+ /**
495
+ * Compose the recovery notice for a budgeted response. Names what was spent and
496
+ * where the detail lives so an agent reading only `content[]` knows the body it
497
+ * received is partial. (#81)
498
+ */
499
+ function buildTruncationNotice(truncation) {
500
+ const subject = truncation.articles.length === 1 ? '1 article' : `${truncation.articles.length} articles`;
501
+ const omitted = truncation.omittedSections > 0
502
+ ? ` ${truncation.omittedSections} section(s) were dropped once the budget ran out.`
503
+ : '';
504
+ // Name only the budgets the request actually set — pointing at `maxCharacters`
505
+ // when the caller only capped per-section sends them to a knob that is unset.
506
+ const knobs = [
507
+ truncation.maxCharacters !== undefined ? '`maxCharacters`' : undefined,
508
+ truncation.maxCharactersPerSection !== undefined ? '`maxCharactersPerSection`' : undefined,
509
+ ].filter((k) => k !== undefined);
510
+ 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.`;
511
+ }
250
512
  // ─── Tool Definition ─────────────────────────────────────────────────────────
251
513
  /**
252
514
  * Compose the tool description for the fallback tiers enabled in this
@@ -306,7 +568,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
306
568
  .min(1)
307
569
  .max(10)
308
570
  .optional()
309
- .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`.'),
571
+ .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`. PMC IDs with no retrievable full text fall through to Europe PMC, then to Unpaywall on the DOI the chain resolves for them.'),
310
572
  pmids: z
311
573
  .array(pmidStringSchema)
312
574
  .min(1)
@@ -334,6 +596,24 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
334
596
  .array(z.string())
335
597
  .optional()
336
598
  .describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
599
+ maxCharacters: z
600
+ .number()
601
+ .int()
602
+ .min(1)
603
+ .max(1_000_000)
604
+ .optional()
605
+ .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. The response-wide ceiling is this value times the number of articles returned. Omit for the full body.'),
606
+ maxCharactersPerSection: z
607
+ .number()
608
+ .int()
609
+ .min(1)
610
+ .max(1_000_000)
611
+ .optional()
612
+ .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.'),
613
+ overflowMode: z
614
+ .enum(['truncate', 'outline'])
615
+ .default('truncate')
616
+ .describe('How to spend `maxCharacters` across an article that exceeds it. truncate: fill sections in document order, so early sections stay whole and sections past the budget are dropped (counted in `truncation.omittedSections`). outline: split the budget evenly so every section keeps its heading, and an excerpt as far as the budget reaches — 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.'),
337
617
  })
338
618
  .refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
339
619
  message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
@@ -345,15 +625,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
345
625
  .array(UnavailableSchema)
346
626
  .optional()
347
627
  .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
628
+ truncation: TruncationSchema.optional(),
348
629
  }),
349
- // Recovery guidance when a `sections` filter removes every body section from
350
- // an article that has one agent-facing context surfaced via ctx.enrich.notice()
351
- // to structuredContent and content[]; absent when no filter ran or sections matched. (#80)
630
+ // Recovery guidance for three cases — a `sections` filter that removed every
631
+ // body section (#80), a record the chain could only retrieve as front matter
632
+ // (#86), and a body the character budget shortened (#81). Agent-facing context
633
+ // surfaced via ctx.enrich.notice() to structuredContent and content[]; absent
634
+ // when none applies.
352
635
  enrichment: {
353
636
  notice: z
354
637
  .string()
355
638
  .optional()
356
- .describe('Optional guidance when a `sections` filter removed every body section names the requested section terms and the affected article id(s), and suggests retrying without `sections` or using broader headings. Absent when no section filter was applied or sections matched.'),
639
+ .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`. Absent when none of those applies.'),
357
640
  },
358
641
  async handler(input, ctx) {
359
642
  ctx.log.info('Executing pubmed_fetch_fulltext', {
@@ -380,6 +663,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
380
663
  // section — collected across the PMC and EPMC stages to drive one recovery
381
664
  // notice via ctx.enrich.notice (#80).
382
665
  const sectionFilterMisses = [];
666
+ // Input ids whose PMC or EPMC record carried no body sections at all. Those
667
+ // records are not full-text hits, so the chain continues past them; ids still
668
+ // unrecovered at the end drive the metadata-only recovery notice (#86).
669
+ const bodylessInputIds = new Set();
670
+ // Per-article character accounting collected across all three stages, plus
671
+ // the running count of sections the budget dropped. Empty when no budget was
672
+ // requested or nothing exceeded it (#81).
673
+ const truncatedArticles = [];
674
+ let omittedSections = 0;
675
+ const budget = {
676
+ overflowMode: input.overflowMode,
677
+ ...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
678
+ ...(input.maxCharactersPerSection !== undefined && {
679
+ maxCharactersPerSection: input.maxCharactersPerSection,
680
+ }),
681
+ };
383
682
  const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
384
683
  // ── Branch routing → produce buckets the staged chain consumes ──────────
385
684
  let pmcIds = [];
@@ -511,19 +810,35 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
511
810
  if (!articleSet) {
512
811
  throw new Error('PMC EFetch response missing pmc-articleset wrapper');
513
812
  }
514
- const parsed = findAll(articleSet, 'article').map((node) => {
813
+ // A parsed article with no body sections is front matter only — PMC
814
+ // returns one whenever the publisher blocks full-text XML download. It
815
+ // is not a hit: it never enters `articles[]`, and its id is routed to
816
+ // the remaining tiers like any other PMC miss. (#86)
817
+ const bodylessPmcIds = new Set();
818
+ const parsed = [];
819
+ for (const node of findAll(articleSet, 'article')) {
515
820
  const before = parsePmcArticle(node);
821
+ if (isBodylessArticle(before)) {
822
+ if (before.pmcId)
823
+ bodylessPmcIds.add(before.pmcId);
824
+ continue;
825
+ }
516
826
  const after = applyPmcFilters(before, input);
517
827
  if (isSectionFilterMiss(before, after, input.sections)) {
518
- sectionFilterMisses.push(articleSectionMissId(after));
828
+ sectionFilterMisses.push(articleDisplayId(after));
519
829
  }
520
- return after;
521
- });
522
- pmcArticles = parsed.map((a) => ({
523
- source: 'pmc',
524
- viaSource: 'pmc',
525
- ...a,
526
- }));
830
+ const budgeted = applyPmcBudget(after, budget);
831
+ omittedSections += budgeted.omittedSections;
832
+ if (budgeted.truncation) {
833
+ truncatedArticles.push({
834
+ id: articleDisplayId(after),
835
+ source: 'pmc',
836
+ ...budgeted.truncation,
837
+ });
838
+ }
839
+ parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
840
+ }
841
+ pmcArticles = parsed;
527
842
  const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
528
843
  for (const prefixed of returnedPmcIds) {
529
844
  recoveredIds.add(pmcidToInputId.get(prefixed) ?? prefixed);
@@ -533,7 +848,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
533
848
  .filter((id) => !returnedPmcIds.has(id));
534
849
  for (const prefixed of missing) {
535
850
  const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
536
- chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
851
+ if (bodylessPmcIds.has(prefixed)) {
852
+ bodylessInputIds.add(inputId);
853
+ chainByInput.get(inputId)?.push({
854
+ tier: 'pmc',
855
+ outcome: 'no-body',
856
+ detail: 'PMC returned front matter and abstract only, with no body sections',
857
+ });
858
+ }
859
+ else {
860
+ chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
861
+ }
537
862
  }
538
863
  routePmcMissesToFallback(missing);
539
864
  }
@@ -559,6 +884,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
559
884
  pmcidFallbackCandidates,
560
885
  doiCandidates,
561
886
  input,
887
+ budget,
562
888
  ctx,
563
889
  })
564
890
  : {
@@ -570,8 +896,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
570
896
  pmcidOutcomes: new Map(),
571
897
  doiOutcomes: new Map(),
572
898
  sectionFilterMisses: [],
899
+ truncatedArticles: [],
900
+ omittedSections: 0,
573
901
  };
574
902
  pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
903
+ truncatedArticles.push(...epmcOutcomes.truncatedArticles);
904
+ omittedSections += epmcOutcomes.omittedSections;
575
905
  // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
576
906
  // recovered, so their ids are added to `recoveredIds` here.
577
907
  if (!epmc) {
@@ -590,28 +920,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
590
920
  chainByInput.get(c.doi)?.push(epmcDisabledEntry);
591
921
  }
592
922
  else {
593
- for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes) {
594
- if (outcome.kind === 'hit') {
595
- recoveredIds.add(pmid);
596
- continue;
597
- }
598
- chainByInput.get(pmid)?.push(epmcTierFromOutcome(outcome));
599
- }
600
- for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
601
- const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
923
+ const foldEpmcOutcome = (inputId, outcome) => {
602
924
  if (outcome.kind === 'hit') {
603
925
  recoveredIds.add(inputId);
604
- continue;
926
+ return;
605
927
  }
928
+ if (outcome.kind === 'no-body')
929
+ bodylessInputIds.add(inputId);
606
930
  chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
931
+ };
932
+ for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes)
933
+ foldEpmcOutcome(pmid, outcome);
934
+ for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
935
+ foldEpmcOutcome(pmcidToInputId.get(prefixed) ?? prefixed, outcome);
607
936
  }
608
- for (const [doi, outcome] of epmcOutcomes.doiOutcomes) {
609
- if (outcome.kind === 'hit') {
610
- recoveredIds.add(doi);
611
- continue;
612
- }
613
- chainByInput.get(doi)?.push(epmcTierFromOutcome(outcome));
614
- }
937
+ for (const [doi, outcome] of epmcOutcomes.doiOutcomes)
938
+ foldEpmcOutcome(doi, outcome);
615
939
  }
616
940
  pmidFallbackCandidates = epmcOutcomes.remainingPmid;
617
941
  pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
@@ -620,15 +944,78 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
620
944
  // ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
621
945
  const unpaywall = getUnpaywallService();
622
946
  const fallbackArticles = [];
623
- // PMC misses on `pmcids` input don't get an Unpaywall attempt the current
624
- // implementation doesn't resolve PMCID DOI for that branch.
625
- for (const c of pmcidFallbackCandidates) {
626
- const prefixed = withPmcPrefix(c.pmcid);
627
- chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
628
- tier: 'unpaywall',
629
- outcome: 'not-attempted',
630
- detail: 'pmcids input does not resolve a DOI for Unpaywall',
631
- });
947
+ // `pmcids` input reaches Unpaywall on the DOI the chain already holds: the
948
+ // EPMC stage searches by PMCID and its hit carries one, captured on non-hit
949
+ // outcomes too. PMCIDs EPMC never resolved fall back to the PMC ID
950
+ // Converter, which returns DOIs for PMC-indexed records. (#88)
951
+ if (pmcidFallbackCandidates.length > 0) {
952
+ if (!unpaywall) {
953
+ for (const c of pmcidFallbackCandidates) {
954
+ const prefixed = withPmcPrefix(c.pmcid);
955
+ chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
956
+ tier: 'unpaywall',
957
+ outcome: 'not-attempted',
958
+ detail: 'UNPAYWALL_EMAIL is not set',
959
+ });
960
+ }
961
+ }
962
+ else {
963
+ const needDoi = pmcidFallbackCandidates
964
+ .filter((c) => !c.doi)
965
+ .map((c) => withPmcPrefix(c.pmcid));
966
+ if (needDoi.length > 0) {
967
+ try {
968
+ const records = await getNcbiService().idConvert(needDoi, 'pmcid', ctx.signal ? { signal: ctx.signal } : undefined);
969
+ const doiByPmcid = new Map();
970
+ for (const r of records) {
971
+ if (r.pmcid && r.doi) {
972
+ doiByPmcid.set(withPmcPrefix(normalizePmcId(String(r.pmcid))), String(r.doi));
973
+ }
974
+ }
975
+ pmcidFallbackCandidates = pmcidFallbackCandidates.map((c) => {
976
+ if (c.doi)
977
+ return c;
978
+ const doi = doiByPmcid.get(withPmcPrefix(c.pmcid));
979
+ return doi ? { ...c, doi } : c;
980
+ });
981
+ }
982
+ catch (error) {
983
+ ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
984
+ error: error instanceof Error ? error.message : String(error),
985
+ pmcidCount: needDoi.length,
986
+ });
987
+ }
988
+ }
989
+ const outcomes = await Promise.all(pmcidFallbackCandidates.map(async (candidate) => {
990
+ // The prefixed PMCID is the id `unavailable[]` keys on, so stamping
991
+ // it on the article makes a partially-recovered batch report its
992
+ // successes and its failures under the same identifier. (#92)
993
+ const pmcId = withPmcPrefix(candidate.pmcid);
994
+ return {
995
+ pmcId,
996
+ result: candidate.doi
997
+ ? await resolveUnpaywall({ pmcId, doi: candidate.doi, budget }, unpaywall, ctx)
998
+ : { unavailable: { reason: 'no-doi' } },
999
+ };
1000
+ }));
1001
+ for (const { pmcId, result } of outcomes) {
1002
+ const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
1003
+ if ('article' in result) {
1004
+ fallbackArticles.push(result.article);
1005
+ if (result.truncation)
1006
+ truncatedArticles.push(result.truncation);
1007
+ recoveredIds.add(inputId);
1008
+ }
1009
+ else {
1010
+ const u = result.unavailable;
1011
+ chainByInput.get(inputId)?.push({
1012
+ tier: 'unpaywall',
1013
+ outcome: unpaywallReasonToTierOutcome(u.reason),
1014
+ ...(u.detail && { detail: u.detail }),
1015
+ });
1016
+ }
1017
+ }
1018
+ }
632
1019
  }
633
1020
  if (pmidFallbackCandidates.length > 0) {
634
1021
  // The PMC ID Converter only returns DOIs for articles it has in PMC, so
@@ -665,12 +1052,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
665
1052
  const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
666
1053
  candidate,
667
1054
  result: candidate.doi
668
- ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
1055
+ ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, budget }, unpaywall, ctx)
669
1056
  : { unavailable: { reason: 'no-doi' } },
670
1057
  })));
671
1058
  for (const { candidate, result } of outcomes) {
672
1059
  if ('article' in result) {
673
1060
  fallbackArticles.push(result.article);
1061
+ if (result.truncation)
1062
+ truncatedArticles.push(result.truncation);
674
1063
  recoveredIds.add(candidate.pmid);
675
1064
  }
676
1065
  else {
@@ -699,11 +1088,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
699
1088
  // doesn't reject under normal operation.
700
1089
  const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
701
1090
  doi: c.doi,
702
- result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
1091
+ result: await resolveUnpaywall({ doi: c.doi, budget }, unpaywall, ctx),
703
1092
  })));
704
1093
  for (const { doi, result } of outcomes) {
705
1094
  if ('article' in result) {
706
1095
  fallbackArticles.push(result.article);
1096
+ if (result.truncation)
1097
+ truncatedArticles.push(result.truncation);
707
1098
  recoveredIds.add(doi);
708
1099
  }
709
1100
  else {
@@ -738,13 +1129,40 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
738
1129
  unpaywallHits: fallbackArticles.length,
739
1130
  unavailable: unavailable.length,
740
1131
  });
1132
+ // Rolled up only when the budget actually removed characters, so an
1133
+ // under-budget request returns exactly what it did before the budget
1134
+ // controls existed. (#81)
1135
+ const truncation = truncatedArticles.length > 0
1136
+ ? {
1137
+ mode: input.overflowMode,
1138
+ ...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
1139
+ ...(input.maxCharactersPerSection !== undefined && {
1140
+ maxCharactersPerSection: input.maxCharactersPerSection,
1141
+ }),
1142
+ originalCharacters: truncatedArticles.reduce((n, a) => n + a.originalCharacters, 0),
1143
+ returnedCharacters: truncatedArticles.reduce((n, a) => n + a.returnedCharacters, 0),
1144
+ omittedSections,
1145
+ articles: truncatedArticles,
1146
+ }
1147
+ : undefined;
1148
+ // Only the last ctx.enrich.notice survives, so the applicable fragments are
1149
+ // collected and emitted once.
1150
+ const notices = [];
741
1151
  if (input.sections?.length && sectionFilterMisses.length > 0) {
742
- ctx.enrich.notice(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
1152
+ notices.push(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
743
1153
  }
1154
+ const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
1155
+ if (unrecoveredBodyless.length > 0)
1156
+ notices.push(buildBodylessNotice(unrecoveredBodyless));
1157
+ if (truncation)
1158
+ notices.push(buildTruncationNotice(truncation));
1159
+ if (notices.length > 0)
1160
+ ctx.enrich.notice(notices.join(' '));
744
1161
  return {
745
1162
  articles,
746
1163
  totalReturned: articles.length,
747
1164
  ...(unavailable.length > 0 && { unavailable }),
1165
+ ...(truncation && { truncation }),
748
1166
  };
749
1167
  },
750
1168
  format: (result) => {
@@ -766,12 +1184,16 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
766
1184
  if (result.totalReturned === 0) {
767
1185
  lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC, Europe PMC, or recoverable via Unpaywall to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
768
1186
  }
1187
+ if (result.truncation)
1188
+ formatTruncation(result.truncation, lines);
1189
+ const truncationById = new Map(result.truncation?.articles.map((t) => [t.id, t]) ?? []);
769
1190
  for (const a of result.articles) {
770
1191
  lines.push('');
1192
+ const t = truncationById.get(articleDisplayId(a));
771
1193
  if (a.source === 'pmc')
772
- formatPmcArticle(a, lines);
1194
+ formatPmcArticle(a, lines, t);
773
1195
  else
774
- formatUnpaywallArticle(a, lines);
1196
+ formatUnpaywallArticle(a, lines, t);
775
1197
  }
776
1198
  return [{ type: 'text', text: lines.join('\n') }];
777
1199
  },
@@ -795,27 +1217,44 @@ async function runEpmcStage(epmc, args) {
795
1217
  }
796
1218
  if (search.kind === 'miss')
797
1219
  return { c, outcome: { kind: 'miss' } };
1220
+ const doi = search.hit.doi ? { doi: search.hit.doi } : {};
798
1221
  const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
799
1222
  if (fetched.kind === 'error') {
800
- return { c, outcome: { kind: 'service-error', detail: fetched.detail } };
1223
+ return { c, ...doi, outcome: { kind: 'service-error', detail: fetched.detail } };
801
1224
  }
802
1225
  if (fetched.kind === 'no-fulltext') {
803
1226
  return {
804
1227
  c,
1228
+ ...doi,
805
1229
  outcome: { kind: 'no-fulltext', ...(fetched.detail && { detail: fetched.detail }) },
806
1230
  };
807
1231
  }
1232
+ if (fetched.kind === 'no-body') {
1233
+ return { c, ...doi, outcome: { kind: 'no-body', detail: fetched.detail } };
1234
+ }
808
1235
  return {
809
1236
  c,
1237
+ ...doi,
810
1238
  outcome: { kind: 'hit' },
811
1239
  article: fetched.article,
812
1240
  sectionFilterMiss: fetched.sectionFilterMiss,
1241
+ omittedSections: fetched.omittedSections,
1242
+ ...(fetched.truncation && { truncation: fetched.truncation }),
813
1243
  };
814
1244
  };
815
- const fetchForPmid = (c) => runOne(c, `EXT_ID:"${c.pmid}" AND SRC:MED`, c.pmid);
1245
+ /**
1246
+ * Query shapes are load-bearing and not interchangeable with their quoted
1247
+ * variants. Europe PMC matches zero records for `EXT_ID:"<pmid>" AND SRC:MED`
1248
+ * and `PMCID:"PMC<digits>"` — the quotes only survive as long as no `AND SRC:`
1249
+ * clause follows. `SRC:PMC` is likewise wrong for a PMCID lookup: EPMC's
1250
+ * canonical record for a PMC-indexed article has `source: MED` and carries the
1251
+ * PMCID as a field, so the filter excludes the very record being sought. DOIs
1252
+ * keep their quotes — they carry slashes and dots that need them. (#85)
1253
+ */
1254
+ const fetchForPmid = (c) => runOne(c, `EXT_ID:${c.pmid} AND SRC:MED`, c.pmid);
816
1255
  const fetchForPmcid = (c) => {
817
1256
  const normalized = withPmcPrefix(c.pmcid);
818
- return runOne({ c, normalized }, `PMCID:"${normalized}" AND SRC:PMC`, undefined);
1257
+ return runOne({ c, normalized }, `PMCID:${normalized}`, undefined);
819
1258
  };
820
1259
  const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
821
1260
  const [pmidResults, pmcidResults, doiResults] = await Promise.all([
@@ -831,35 +1270,36 @@ async function runEpmcStage(epmc, args) {
831
1270
  const pmcidOutcomes = new Map();
832
1271
  const doiOutcomes = new Map();
833
1272
  const sectionFilterMisses = [];
834
- for (const { c, outcome, article, sectionFilterMiss } of pmidResults) {
835
- pmidOutcomes.set(c.pmid, outcome);
836
- if (article) {
837
- articles.push(article);
838
- if (sectionFilterMiss)
839
- sectionFilterMisses.push(articleSectionMissId(article));
840
- }
1273
+ const truncatedArticles = [];
1274
+ let omittedSections = 0;
1275
+ const collectHit = (run) => {
1276
+ articles.push(run.article);
1277
+ if (run.sectionFilterMiss)
1278
+ sectionFilterMisses.push(articleDisplayId(run.article));
1279
+ if (run.truncation)
1280
+ truncatedArticles.push(run.truncation);
1281
+ omittedSections += run.omittedSections ?? 0;
1282
+ };
1283
+ for (const run of pmidResults) {
1284
+ pmidOutcomes.set(run.c.pmid, run.outcome);
1285
+ if (run.article)
1286
+ collectHit({ ...run, article: run.article });
841
1287
  else
842
- remainingPmid.push(c);
1288
+ remainingPmid.push(run.c);
843
1289
  }
844
- for (const { c: pair, outcome, article, sectionFilterMiss } of pmcidResults) {
845
- pmcidOutcomes.set(pair.normalized, outcome);
846
- if (article) {
847
- articles.push(article);
848
- if (sectionFilterMiss)
849
- sectionFilterMisses.push(articleSectionMissId(article));
850
- }
1290
+ for (const run of pmcidResults) {
1291
+ pmcidOutcomes.set(run.c.normalized, run.outcome);
1292
+ if (run.article)
1293
+ collectHit({ ...run, article: run.article });
851
1294
  else
852
- remainingPmcid.push(pair.c);
1295
+ remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
853
1296
  }
854
- for (const { c, outcome, article, sectionFilterMiss } of doiResults) {
855
- doiOutcomes.set(c.doi, outcome);
856
- if (article) {
857
- articles.push(article);
858
- if (sectionFilterMiss)
859
- sectionFilterMisses.push(articleSectionMissId(article));
860
- }
1297
+ for (const run of doiResults) {
1298
+ doiOutcomes.set(run.c.doi, run.outcome);
1299
+ if (run.article)
1300
+ collectHit({ ...run, article: run.article });
861
1301
  else
862
- remainingDoi.push(c);
1302
+ remainingDoi.push(run.c);
863
1303
  }
864
1304
  return {
865
1305
  articles,
@@ -870,6 +1310,8 @@ async function runEpmcStage(epmc, args) {
870
1310
  pmcidOutcomes,
871
1311
  doiOutcomes,
872
1312
  sectionFilterMisses,
1313
+ truncatedArticles,
1314
+ omittedSections,
873
1315
  };
874
1316
  }
875
1317
  /**
@@ -923,30 +1365,46 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
923
1365
  return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
924
1366
  }
925
1367
  const beforeFilter = parsePmcArticle(articleNode);
1368
+ if (isBodylessArticle(beforeFilter)) {
1369
+ return {
1370
+ kind: 'no-body',
1371
+ detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
1372
+ };
1373
+ }
926
1374
  const parsed = applyPmcFilters(beforeFilter, args.input);
927
1375
  const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
1376
+ const budgeted = applyPmcBudget(parsed, args.budget);
928
1377
  // `parsePmcArticle` always returns string fields (sometimes empty). Strip
929
1378
  // empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
930
1379
  // optional shape is respected — agents read `epmcId`/`epmcSource` for those.
931
- const { pmcId, pmcUrl, ...rest } = parsed;
1380
+ const { pmcId, pmcUrl, ...rest } = budgeted.article;
932
1381
  const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
933
1382
  const doi = rest.doi ?? hit.doi;
1383
+ const article = {
1384
+ source: 'pmc',
1385
+ viaSource: 'europepmc',
1386
+ ...rest,
1387
+ ...(pmcId && { pmcId, pmcUrl }),
1388
+ ...(pmid && {
1389
+ pmid,
1390
+ pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
1391
+ }),
1392
+ ...(doi && { doi }),
1393
+ epmcId: hit.id,
1394
+ epmcSource: hit.source,
1395
+ };
934
1396
  return {
935
1397
  kind: 'article',
936
1398
  sectionFilterMiss,
937
- article: {
938
- source: 'pmc',
939
- viaSource: 'europepmc',
940
- ...rest,
941
- ...(pmcId && { pmcId, pmcUrl }),
942
- ...(pmid && {
943
- pmid,
944
- pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
945
- }),
946
- ...(doi && { doi }),
947
- epmcId: hit.id,
948
- epmcSource: hit.source,
949
- },
1399
+ article,
1400
+ omittedSections: budgeted.omittedSections,
1401
+ ...(budgeted.truncation && {
1402
+ truncation: {
1403
+ id: articleDisplayId(article),
1404
+ source: 'pmc',
1405
+ ...budgeted.truncation,
1406
+ },
1407
+ }),
950
1408
  };
951
1409
  }
952
1410
  catch (error) {
@@ -987,12 +1445,28 @@ async function fetchPubmedDois(pmids, signal) {
987
1445
  return out;
988
1446
  }
989
1447
  /**
990
- * Resolve a DOI to an open-access article via Unpaywall. `pmid`, when set,
991
- * is stamped onto the resulting article so the pmid-input branch carries its
992
- * cross-reference through.
1448
+ * Resolve a DOI to an open-access article via Unpaywall. `pmcId` and `pmid`,
1449
+ * when set, are stamped onto the resulting article so the branch that requested
1450
+ * it carries its identifier through — Unpaywall itself only knows the DOI.
993
1451
  */
994
1452
  async function resolveUnpaywall(args, service, ctx) {
995
- const { pmid, doi } = args;
1453
+ const { pmcId, pmid, doi, budget } = args;
1454
+ const requestedIds = { ...(pmcId && { pmcId }), ...(pmid && { pmid }) };
1455
+ /** Budget the extracted body, then pair the article with its accounting. */
1456
+ const budgeted = (build, content) => {
1457
+ const capped = applyContentBudget(content, budget);
1458
+ const article = build(capped.content);
1459
+ return {
1460
+ article,
1461
+ ...(capped.truncation && {
1462
+ truncation: {
1463
+ id: articleDisplayId(article),
1464
+ source: 'unpaywall',
1465
+ ...capped.truncation,
1466
+ },
1467
+ }),
1468
+ };
1469
+ };
996
1470
  let resolution;
997
1471
  try {
998
1472
  resolution = await service.resolve(doi, ctx.signal);
@@ -1029,18 +1503,16 @@ async function resolveUnpaywall(args, service, ctx) {
1029
1503
  },
1030
1504
  };
1031
1505
  }
1032
- return {
1033
- article: buildUnpaywallArticle({
1034
- ...(pmid && { pmid }),
1035
- doi,
1036
- sourceUrl: content.fetchedUrl,
1037
- location: resolution.location,
1038
- contentFormat: 'html-markdown',
1039
- content: body,
1040
- title: extracted.title,
1041
- wordCount: extracted.wordCount,
1042
- }),
1043
- };
1506
+ return budgeted((text) => buildUnpaywallArticle({
1507
+ ...requestedIds,
1508
+ doi,
1509
+ sourceUrl: content.fetchedUrl,
1510
+ location: resolution.location,
1511
+ contentFormat: 'html-markdown',
1512
+ content: text,
1513
+ title: extracted.title,
1514
+ wordCount: extracted.wordCount,
1515
+ }), body);
1044
1516
  }
1045
1517
  const extracted = await pdfParser.extractText(content.body, { mergePages: true });
1046
1518
  const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
@@ -1049,17 +1521,15 @@ async function resolveUnpaywall(args, service, ctx) {
1049
1521
  unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
1050
1522
  };
1051
1523
  }
1052
- return {
1053
- article: buildUnpaywallArticle({
1054
- ...(pmid && { pmid }),
1055
- doi,
1056
- sourceUrl: content.fetchedUrl,
1057
- location: resolution.location,
1058
- contentFormat: 'pdf-text',
1059
- content: text,
1060
- totalPages: extracted.totalPages,
1061
- }),
1062
- };
1524
+ return budgeted((body) => buildUnpaywallArticle({
1525
+ ...requestedIds,
1526
+ doi,
1527
+ sourceUrl: content.fetchedUrl,
1528
+ location: resolution.location,
1529
+ contentFormat: 'pdf-text',
1530
+ content: body,
1531
+ totalPages: extracted.totalPages,
1532
+ }), text);
1063
1533
  }
1064
1534
  catch (error) {
1065
1535
  const detail = error instanceof Error ? error.message : String(error);
@@ -1073,6 +1543,7 @@ function buildUnpaywallArticle(args) {
1073
1543
  source: 'unpaywall',
1074
1544
  viaSource: 'unpaywall',
1075
1545
  contentFormat: args.contentFormat,
1546
+ ...(args.pmcId && { pmcId: args.pmcId }),
1076
1547
  ...(args.pmid && {
1077
1548
  pmid: args.pmid,
1078
1549
  pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
@@ -1103,6 +1574,8 @@ function epmcTierFromOutcome(outcome) {
1103
1574
  outcome: 'no-fulltext',
1104
1575
  ...(outcome.detail && { detail: outcome.detail }),
1105
1576
  };
1577
+ case 'no-body':
1578
+ return { tier: 'europepmc', outcome: 'no-body', detail: outcome.detail };
1106
1579
  case 'service-error':
1107
1580
  return { tier: 'europepmc', outcome: 'service-error', detail: outcome.detail };
1108
1581
  }
@@ -1116,6 +1589,7 @@ function epmcTierFromOutcome(outcome) {
1116
1589
  */
1117
1590
  function unpaywallReasonToTierOutcome(reason) {
1118
1591
  switch (reason) {
1592
+ case 'no-body':
1119
1593
  case 'no-doi':
1120
1594
  case 'no-oa':
1121
1595
  case 'fetch-failed':
@@ -1152,6 +1626,9 @@ function reasonFromChain(chain) {
1152
1626
  return 'not-found';
1153
1627
  case 'europepmc:no-fulltext':
1154
1628
  return 'no-epmc-fulltext';
1629
+ case 'pmc:no-body':
1630
+ case 'europepmc:no-body':
1631
+ return 'no-body';
1155
1632
  case 'unpaywall:no-doi':
1156
1633
  return 'no-doi';
1157
1634
  case 'unpaywall:no-oa':
@@ -1169,7 +1646,34 @@ function reasonFromChain(chain) {
1169
1646
  }
1170
1647
  }
1171
1648
  // ─── format() helpers ────────────────────────────────────────────────────────
1172
- function formatPmcArticle(a, lines) {
1649
+ /**
1650
+ * Render the response-level character accounting. Every field is rendered
1651
+ * unconditionally so `content[]` readers see the same budget detail
1652
+ * `structuredContent` readers get. Counts are printed raw — no thousands
1653
+ * separators — so the numbers stay greppable. (#81)
1654
+ */
1655
+ function formatTruncation(t, lines) {
1656
+ 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`);
1657
+ const budgets = [
1658
+ t.maxCharacters === undefined ? undefined : `maxCharacters ${t.maxCharacters}`,
1659
+ t.maxCharactersPerSection === undefined
1660
+ ? undefined
1661
+ : `maxCharactersPerSection ${t.maxCharactersPerSection}`,
1662
+ ].filter((b) => b !== undefined);
1663
+ if (budgets.length)
1664
+ lines.push(`Budget applied: ${budgets.join(', ')}`);
1665
+ for (const a of t.articles) {
1666
+ lines.push(`- ${a.id} (${a.source}): ${a.returnedCharacters} of ${a.originalCharacters} characters`);
1667
+ for (const s of a.sections ?? []) {
1668
+ lines.push(` - ${s.title ?? 'untitled section'} — ${s.returnedCharacters} of ${s.originalCharacters} characters (truncated: ${s.truncated})`);
1669
+ }
1670
+ }
1671
+ }
1672
+ /** Per-article inline marker so a reader of one article's body knows it is partial. */
1673
+ function truncationNote(t) {
1674
+ return `\n> Body shortened to fit the requested character budget — ${t.returnedCharacters} of ${t.originalCharacters} characters returned. See \`truncation\` for per-section counts.`;
1675
+ }
1676
+ function formatPmcArticle(a, lines, truncation) {
1173
1677
  lines.push(`### ${a.title ?? a.pmcId}`);
1174
1678
  const sourceLabel = a.viaSource === 'europepmc'
1175
1679
  ? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
@@ -1220,6 +1724,8 @@ function formatPmcArticle(a, lines) {
1220
1724
  lines.push(`**PubMed:** ${a.pubmedUrl}`);
1221
1725
  if (a.keywords?.length)
1222
1726
  lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
1727
+ if (truncation)
1728
+ lines.push(truncationNote(truncation));
1223
1729
  if (a.abstract)
1224
1730
  lines.push(`\n#### Abstract\n${a.abstract}`);
1225
1731
  for (const sec of a.sections) {
@@ -1244,13 +1750,16 @@ function formatPmcArticle(a, lines) {
1244
1750
  }
1245
1751
  }
1246
1752
  }
1247
- function formatUnpaywallArticle(a, lines) {
1248
- const heading = a.title ?? (a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`);
1753
+ function formatUnpaywallArticle(a, lines, truncation) {
1754
+ const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
1755
+ const heading = a.title ?? requestedId;
1249
1756
  const formatLabel = a.contentFormat === 'html-markdown'
1250
1757
  ? 'Unpaywall (HTML → Markdown, best-effort)'
1251
1758
  : 'Unpaywall (PDF → plain text)';
1252
1759
  lines.push(`### ${heading}`);
1253
1760
  lines.push(`**Source:** ${formatLabel}`);
1761
+ if (a.pmcId)
1762
+ lines.push(`**PMCID:** ${a.pmcId}`);
1254
1763
  if (a.pmid)
1255
1764
  lines.push(`**PMID:** ${a.pmid}`);
1256
1765
  lines.push(`**DOI:** ${a.doi}`);
@@ -1268,6 +1777,8 @@ function formatUnpaywallArticle(a, lines) {
1268
1777
  if (a.totalPages !== undefined)
1269
1778
  lines.push(`**Pages:** ${a.totalPages}`);
1270
1779
  lines.push(`\n> Section structure is not guaranteed for this source. Treat the content as best-effort raw text. OA location metadata courtesy of Unpaywall (https://unpaywall.org).`);
1780
+ if (truncation)
1781
+ lines.push(truncationNote(truncation));
1271
1782
  lines.push(`\n#### Full Text\n${a.content}`);
1272
1783
  }
1273
1784
  function formatPmcAuthor(au) {