@cyanheads/pubmed-mcp-server 2.9.10 → 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 (29) 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 +32 -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 +440 -79
  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 +28 -7
  21. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  22. package/dist/services/europe-pmc/europe-pmc-service.d.ts +21 -7
  23. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  24. package/dist/services/europe-pmc/europe-pmc-service.js +39 -6
  25. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  26. package/dist/services/europe-pmc/types.d.ts +9 -0
  27. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. 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
  }
@@ -76,10 +77,10 @@ function isSectionFilterMiss(before, after, sectionFilter) {
76
77
  function isBodylessArticle(before) {
77
78
  return before.sections.length === 0;
78
79
  }
79
- /** Pick the best human-readable identifier for an article whose section filter
80
- * missed, for the recovery notice. Treats empty strings as absent — EPMC-only
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
81
82
  * records carry an empty `pmcId`. */
82
- function articleSectionMissId(a) {
83
+ function articleDisplayId(a) {
83
84
  return [a.pmcId, a.pmid, a.doi, a.epmcId].find((v) => v && v.length > 0) ?? 'article';
84
85
  }
85
86
  /**
@@ -280,6 +281,234 @@ const UnavailableSchema = z
280
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.'),
281
282
  })
282
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
+ }
283
512
  // ─── Tool Definition ─────────────────────────────────────────────────────────
284
513
  /**
285
514
  * Compose the tool description for the fallback tiers enabled in this
@@ -367,6 +596,24 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
367
596
  .array(z.string())
368
597
  .optional()
369
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.'),
370
617
  })
371
618
  .refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
372
619
  message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
@@ -378,16 +625,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
378
625
  .array(UnavailableSchema)
379
626
  .optional()
380
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(),
381
629
  }),
382
- // Recovery guidance for two empty-body cases — a `sections` filter that removed
383
- // every body section (#80), and a record the chain could only retrieve as front
384
- // matter (#86). Agent-facing context surfaced via ctx.enrich.notice() to
385
- // structuredContent and content[]; absent when neither applies.
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.
386
635
  enrichment: {
387
636
  notice: z
388
637
  .string()
389
638
  .optional()
390
- .describe('Optional guidance for empty bodies. 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. Absent when neither case applies.'),
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.'),
391
640
  },
392
641
  async handler(input, ctx) {
393
642
  ctx.log.info('Executing pubmed_fetch_fulltext', {
@@ -418,6 +667,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
418
667
  // records are not full-text hits, so the chain continues past them; ids still
419
668
  // unrecovered at the end drive the metadata-only recovery notice (#86).
420
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
+ };
421
682
  const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
422
683
  // ── Branch routing → produce buckets the staged chain consumes ──────────
423
684
  let pmcIds = [];
@@ -564,9 +825,18 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
564
825
  }
565
826
  const after = applyPmcFilters(before, input);
566
827
  if (isSectionFilterMiss(before, after, input.sections)) {
567
- sectionFilterMisses.push(articleSectionMissId(after));
828
+ sectionFilterMisses.push(articleDisplayId(after));
829
+ }
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
+ });
568
838
  }
569
- parsed.push({ source: 'pmc', viaSource: 'pmc', ...after });
839
+ parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
570
840
  }
571
841
  pmcArticles = parsed;
572
842
  const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
@@ -614,6 +884,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
614
884
  pmcidFallbackCandidates,
615
885
  doiCandidates,
616
886
  input,
887
+ budget,
617
888
  ctx,
618
889
  })
619
890
  : {
@@ -625,8 +896,12 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
625
896
  pmcidOutcomes: new Map(),
626
897
  doiOutcomes: new Map(),
627
898
  sectionFilterMisses: [],
899
+ truncatedArticles: [],
900
+ omittedSections: 0,
628
901
  };
629
902
  pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
903
+ truncatedArticles.push(...epmcOutcomes.truncatedArticles);
904
+ omittedSections += epmcOutcomes.omittedSections;
630
905
  // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
631
906
  // recovered, so their ids are added to `recoveredIds` here.
632
907
  if (!epmc) {
@@ -719,7 +994,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
719
994
  return {
720
995
  pmcId,
721
996
  result: candidate.doi
722
- ? await resolveUnpaywall({ pmcId, doi: candidate.doi }, unpaywall, ctx)
997
+ ? await resolveUnpaywall({ pmcId, doi: candidate.doi, budget }, unpaywall, ctx)
723
998
  : { unavailable: { reason: 'no-doi' } },
724
999
  };
725
1000
  }));
@@ -727,6 +1002,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
727
1002
  const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
728
1003
  if ('article' in result) {
729
1004
  fallbackArticles.push(result.article);
1005
+ if (result.truncation)
1006
+ truncatedArticles.push(result.truncation);
730
1007
  recoveredIds.add(inputId);
731
1008
  }
732
1009
  else {
@@ -775,12 +1052,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
775
1052
  const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
776
1053
  candidate,
777
1054
  result: candidate.doi
778
- ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
1055
+ ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi, budget }, unpaywall, ctx)
779
1056
  : { unavailable: { reason: 'no-doi' } },
780
1057
  })));
781
1058
  for (const { candidate, result } of outcomes) {
782
1059
  if ('article' in result) {
783
1060
  fallbackArticles.push(result.article);
1061
+ if (result.truncation)
1062
+ truncatedArticles.push(result.truncation);
784
1063
  recoveredIds.add(candidate.pmid);
785
1064
  }
786
1065
  else {
@@ -809,11 +1088,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
809
1088
  // doesn't reject under normal operation.
810
1089
  const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
811
1090
  doi: c.doi,
812
- result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
1091
+ result: await resolveUnpaywall({ doi: c.doi, budget }, unpaywall, ctx),
813
1092
  })));
814
1093
  for (const { doi, result } of outcomes) {
815
1094
  if ('article' in result) {
816
1095
  fallbackArticles.push(result.article);
1096
+ if (result.truncation)
1097
+ truncatedArticles.push(result.truncation);
817
1098
  recoveredIds.add(doi);
818
1099
  }
819
1100
  else {
@@ -848,6 +1129,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
848
1129
  unpaywallHits: fallbackArticles.length,
849
1130
  unavailable: unavailable.length,
850
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;
851
1148
  // Only the last ctx.enrich.notice survives, so the applicable fragments are
852
1149
  // collected and emitted once.
853
1150
  const notices = [];
@@ -857,12 +1154,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
857
1154
  const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
858
1155
  if (unrecoveredBodyless.length > 0)
859
1156
  notices.push(buildBodylessNotice(unrecoveredBodyless));
1157
+ if (truncation)
1158
+ notices.push(buildTruncationNotice(truncation));
860
1159
  if (notices.length > 0)
861
1160
  ctx.enrich.notice(notices.join(' '));
862
1161
  return {
863
1162
  articles,
864
1163
  totalReturned: articles.length,
865
1164
  ...(unavailable.length > 0 && { unavailable }),
1165
+ ...(truncation && { truncation }),
866
1166
  };
867
1167
  },
868
1168
  format: (result) => {
@@ -884,12 +1184,16 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
884
1184
  if (result.totalReturned === 0) {
885
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\`.`);
886
1186
  }
1187
+ if (result.truncation)
1188
+ formatTruncation(result.truncation, lines);
1189
+ const truncationById = new Map(result.truncation?.articles.map((t) => [t.id, t]) ?? []);
887
1190
  for (const a of result.articles) {
888
1191
  lines.push('');
1192
+ const t = truncationById.get(articleDisplayId(a));
889
1193
  if (a.source === 'pmc')
890
- formatPmcArticle(a, lines);
1194
+ formatPmcArticle(a, lines, t);
891
1195
  else
892
- formatUnpaywallArticle(a, lines);
1196
+ formatUnpaywallArticle(a, lines, t);
893
1197
  }
894
1198
  return [{ type: 'text', text: lines.join('\n') }];
895
1199
  },
@@ -934,6 +1238,8 @@ async function runEpmcStage(epmc, args) {
934
1238
  outcome: { kind: 'hit' },
935
1239
  article: fetched.article,
936
1240
  sectionFilterMiss: fetched.sectionFilterMiss,
1241
+ omittedSections: fetched.omittedSections,
1242
+ ...(fetched.truncation && { truncation: fetched.truncation }),
937
1243
  };
938
1244
  };
939
1245
  /**
@@ -964,35 +1270,36 @@ async function runEpmcStage(epmc, args) {
964
1270
  const pmcidOutcomes = new Map();
965
1271
  const doiOutcomes = new Map();
966
1272
  const sectionFilterMisses = [];
967
- for (const { c, outcome, article, sectionFilterMiss } of pmidResults) {
968
- pmidOutcomes.set(c.pmid, outcome);
969
- if (article) {
970
- articles.push(article);
971
- if (sectionFilterMiss)
972
- sectionFilterMisses.push(articleSectionMissId(article));
973
- }
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 });
974
1287
  else
975
- remainingPmid.push(c);
1288
+ remainingPmid.push(run.c);
976
1289
  }
977
- for (const { c: pair, outcome, article, sectionFilterMiss, doi } of pmcidResults) {
978
- pmcidOutcomes.set(pair.normalized, outcome);
979
- if (article) {
980
- articles.push(article);
981
- if (sectionFilterMiss)
982
- sectionFilterMisses.push(articleSectionMissId(article));
983
- }
1290
+ for (const run of pmcidResults) {
1291
+ pmcidOutcomes.set(run.c.normalized, run.outcome);
1292
+ if (run.article)
1293
+ collectHit({ ...run, article: run.article });
984
1294
  else
985
- remainingPmcid.push(doi && !pair.c.doi ? { ...pair.c, doi } : pair.c);
1295
+ remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
986
1296
  }
987
- for (const { c, outcome, article, sectionFilterMiss } of doiResults) {
988
- doiOutcomes.set(c.doi, outcome);
989
- if (article) {
990
- articles.push(article);
991
- if (sectionFilterMiss)
992
- sectionFilterMisses.push(articleSectionMissId(article));
993
- }
1297
+ for (const run of doiResults) {
1298
+ doiOutcomes.set(run.c.doi, run.outcome);
1299
+ if (run.article)
1300
+ collectHit({ ...run, article: run.article });
994
1301
  else
995
- remainingDoi.push(c);
1302
+ remainingDoi.push(run.c);
996
1303
  }
997
1304
  return {
998
1305
  articles,
@@ -1003,6 +1310,8 @@ async function runEpmcStage(epmc, args) {
1003
1310
  pmcidOutcomes,
1004
1311
  doiOutcomes,
1005
1312
  sectionFilterMisses,
1313
+ truncatedArticles,
1314
+ omittedSections,
1006
1315
  };
1007
1316
  }
1008
1317
  /**
@@ -1064,28 +1373,38 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
1064
1373
  }
1065
1374
  const parsed = applyPmcFilters(beforeFilter, args.input);
1066
1375
  const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
1376
+ const budgeted = applyPmcBudget(parsed, args.budget);
1067
1377
  // `parsePmcArticle` always returns string fields (sometimes empty). Strip
1068
1378
  // empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
1069
1379
  // optional shape is respected — agents read `epmcId`/`epmcSource` for those.
1070
- const { pmcId, pmcUrl, ...rest } = parsed;
1380
+ const { pmcId, pmcUrl, ...rest } = budgeted.article;
1071
1381
  const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
1072
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
+ };
1073
1396
  return {
1074
1397
  kind: 'article',
1075
1398
  sectionFilterMiss,
1076
- article: {
1077
- source: 'pmc',
1078
- viaSource: 'europepmc',
1079
- ...rest,
1080
- ...(pmcId && { pmcId, pmcUrl }),
1081
- ...(pmid && {
1082
- pmid,
1083
- pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
1084
- }),
1085
- ...(doi && { doi }),
1086
- epmcId: hit.id,
1087
- epmcSource: hit.source,
1088
- },
1399
+ article,
1400
+ omittedSections: budgeted.omittedSections,
1401
+ ...(budgeted.truncation && {
1402
+ truncation: {
1403
+ id: articleDisplayId(article),
1404
+ source: 'pmc',
1405
+ ...budgeted.truncation,
1406
+ },
1407
+ }),
1089
1408
  };
1090
1409
  }
1091
1410
  catch (error) {
@@ -1131,8 +1450,23 @@ async function fetchPubmedDois(pmids, signal) {
1131
1450
  * it carries its identifier through — Unpaywall itself only knows the DOI.
1132
1451
  */
1133
1452
  async function resolveUnpaywall(args, service, ctx) {
1134
- const { pmcId, pmid, doi } = args;
1453
+ const { pmcId, pmid, doi, budget } = args;
1135
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
+ };
1136
1470
  let resolution;
1137
1471
  try {
1138
1472
  resolution = await service.resolve(doi, ctx.signal);
@@ -1169,18 +1503,16 @@ async function resolveUnpaywall(args, service, ctx) {
1169
1503
  },
1170
1504
  };
1171
1505
  }
1172
- return {
1173
- article: buildUnpaywallArticle({
1174
- ...requestedIds,
1175
- doi,
1176
- sourceUrl: content.fetchedUrl,
1177
- location: resolution.location,
1178
- contentFormat: 'html-markdown',
1179
- content: body,
1180
- title: extracted.title,
1181
- wordCount: extracted.wordCount,
1182
- }),
1183
- };
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);
1184
1516
  }
1185
1517
  const extracted = await pdfParser.extractText(content.body, { mergePages: true });
1186
1518
  const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
@@ -1189,17 +1521,15 @@ async function resolveUnpaywall(args, service, ctx) {
1189
1521
  unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
1190
1522
  };
1191
1523
  }
1192
- return {
1193
- article: buildUnpaywallArticle({
1194
- ...requestedIds,
1195
- doi,
1196
- sourceUrl: content.fetchedUrl,
1197
- location: resolution.location,
1198
- contentFormat: 'pdf-text',
1199
- content: text,
1200
- totalPages: extracted.totalPages,
1201
- }),
1202
- };
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);
1203
1533
  }
1204
1534
  catch (error) {
1205
1535
  const detail = error instanceof Error ? error.message : String(error);
@@ -1316,7 +1646,34 @@ function reasonFromChain(chain) {
1316
1646
  }
1317
1647
  }
1318
1648
  // ─── format() helpers ────────────────────────────────────────────────────────
1319
- 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) {
1320
1677
  lines.push(`### ${a.title ?? a.pmcId}`);
1321
1678
  const sourceLabel = a.viaSource === 'europepmc'
1322
1679
  ? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
@@ -1367,6 +1724,8 @@ function formatPmcArticle(a, lines) {
1367
1724
  lines.push(`**PubMed:** ${a.pubmedUrl}`);
1368
1725
  if (a.keywords?.length)
1369
1726
  lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
1727
+ if (truncation)
1728
+ lines.push(truncationNote(truncation));
1370
1729
  if (a.abstract)
1371
1730
  lines.push(`\n#### Abstract\n${a.abstract}`);
1372
1731
  for (const sec of a.sections) {
@@ -1391,7 +1750,7 @@ function formatPmcArticle(a, lines) {
1391
1750
  }
1392
1751
  }
1393
1752
  }
1394
- function formatUnpaywallArticle(a, lines) {
1753
+ function formatUnpaywallArticle(a, lines, truncation) {
1395
1754
  const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
1396
1755
  const heading = a.title ?? requestedId;
1397
1756
  const formatLabel = a.contentFormat === 'html-markdown'
@@ -1418,6 +1777,8 @@ function formatUnpaywallArticle(a, lines) {
1418
1777
  if (a.totalPages !== undefined)
1419
1778
  lines.push(`**Pages:** ${a.totalPages}`);
1420
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));
1421
1782
  lines.push(`\n#### Full Text\n${a.content}`);
1422
1783
  }
1423
1784
  function formatPmcAuthor(au) {