@cyanheads/pubmed-mcp-server 2.9.8 → 2.9.10

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 (70) hide show
  1. package/AGENTS.md +30 -8
  2. package/CLAUDE.md +30 -8
  3. package/Dockerfile +13 -7
  4. package/README.md +1 -1
  5. package/dist/mcp-server/prompts/definitions/research-plan.prompt.d.ts +1 -1
  6. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +17 -17
  7. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +18 -18
  8. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +43 -40
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +206 -56
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +33 -33
  14. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +17 -17
  15. package/dist/mcp-server/tools/definitions/format-citations.tool.js +1 -1
  16. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +16 -16
  18. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +9 -12
  20. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +20 -16
  22. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +73 -22
  24. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +18 -18
  26. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +1 -1
  28. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +22 -22
  30. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +15 -15
  31. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/spell-check.tool.js +2 -2
  33. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  34. package/dist/services/error-contracts.d.ts +33 -33
  35. package/dist/services/error-contracts.d.ts.map +1 -1
  36. package/dist/services/europe-pmc/api-client.d.ts +14 -1
  37. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  38. package/dist/services/europe-pmc/api-client.js +22 -25
  39. package/dist/services/europe-pmc/api-client.js.map +1 -1
  40. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  41. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  42. package/dist/services/europe-pmc/request-queue.d.ts.map +1 -1
  43. package/dist/services/europe-pmc/request-queue.js.map +1 -1
  44. package/dist/services/europe-pmc/types.d.ts +2 -2
  45. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  46. package/dist/services/ncbi/api-client.d.ts.map +1 -1
  47. package/dist/services/ncbi/api-client.js.map +1 -1
  48. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  49. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  50. package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
  51. package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
  52. package/dist/services/ncbi/parsing/esummary-parser.js +3 -0
  53. package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
  54. package/dist/services/ncbi/parsing/xml-helpers.js.map +1 -1
  55. package/dist/services/ncbi/request-queue.d.ts.map +1 -1
  56. package/dist/services/ncbi/request-queue.js.map +1 -1
  57. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  58. package/dist/services/ncbi/types.d.ts +6 -0
  59. package/dist/services/ncbi/types.d.ts.map +1 -1
  60. package/dist/services/openalex/api-client.d.ts +15 -1
  61. package/dist/services/openalex/api-client.d.ts.map +1 -1
  62. package/dist/services/openalex/api-client.js +22 -24
  63. package/dist/services/openalex/api-client.js.map +1 -1
  64. package/dist/services/openalex/openalex-service.d.ts.map +1 -1
  65. package/dist/services/openalex/openalex-service.js.map +1 -1
  66. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  67. package/dist/services/unpaywall/unpaywall-service.js +1 -0
  68. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  69. package/package.json +24 -17
  70. package/server.json +3 -3
@@ -64,6 +64,18 @@ function applyPmcFilters(article, filters) {
64
64
  function isSectionFilterMiss(before, after, sectionFilter) {
65
65
  return (Boolean(sectionFilter?.length) && before.sections.length > 0 && after.sections.length === 0);
66
66
  }
67
+ /**
68
+ * True when the upstream JATS carried no body sections at all — front matter and
69
+ * abstract only. Publishers that block full-text XML download still return an
70
+ * `<article>` with a populated `<front>`, so the parsed article looks like a hit
71
+ * while carrying nothing to read. Distinct from {@link isSectionFilterMiss},
72
+ * which needs a non-empty pre-filter body: the two never overlap. Evaluated
73
+ * against the *pre-filter* article so a `sections` filter can't be mistaken for
74
+ * an upstream absence. (#86)
75
+ */
76
+ function isBodylessArticle(before) {
77
+ return before.sections.length === 0;
78
+ }
67
79
  /** Pick the best human-readable identifier for an article whose section filter
68
80
  * missed, for the recovery notice. Treats empty strings as absent — EPMC-only
69
81
  * records carry an empty `pmcId`. */
@@ -80,6 +92,21 @@ function buildSectionFilterMissNotice(affectedIds, sectionFilter) {
80
92
  const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
81
93
  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
94
  }
95
+ /**
96
+ * Compose the recovery notice for identifiers whose only retrievable record was
97
+ * metadata-only — PMC or Europe PMC returned front matter with no body, and no
98
+ * later tier recovered a full-text copy. Points at the tool that still serves
99
+ * the abstract so the metadata isn't simply lost.
100
+ *
101
+ * States what the chain observed rather than asserting the article has no body:
102
+ * a later tier may well have located an open-access copy and failed to download
103
+ * it (`unpaywall:fetch-failed`), so the per-tier outcomes are the honest answer
104
+ * and the notice defers to them. (#86)
105
+ */
106
+ function buildBodylessNotice(affectedIds) {
107
+ const subject = affectedIds.length === 1 ? `article ${affectedIds[0]}` : `articles ${affectedIds.join(', ')}`;
108
+ 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.`;
109
+ }
83
110
  // ─── Schemas ─────────────────────────────────────────────────────────────────
84
111
  const SubsectionSchema = z
85
112
  .object({
@@ -173,10 +200,14 @@ const UnpaywallArticleSchema = z
173
200
  contentFormat: z
174
201
  .enum(['html-markdown', 'pdf-text'])
175
202
  .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.'),
203
+ pmcId: z
204
+ .string()
205
+ .optional()
206
+ .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
207
  pmid: z
177
208
  .string()
178
209
  .optional()
179
- .describe('PubMed ID when input was `pmids`; absent for `dois` input'),
210
+ .describe('PubMed ID when input was `pmids`; absent for `pmcids` and `dois` input'),
180
211
  pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
181
212
  doi: z.string().describe('DOI used to locate the open-access copy'),
182
213
  sourceUrl: z.string().describe('URL the content was fetched from'),
@@ -209,25 +240,27 @@ const UnavailableReasonSchema = z
209
240
  'not-found',
210
241
  'no-pmc-fallback-disabled',
211
242
  'no-epmc-fulltext',
243
+ 'no-body',
212
244
  'no-doi',
213
245
  'no-oa',
214
246
  'fetch-failed',
215
247
  'parse-failed',
216
248
  'service-error',
217
249
  ])
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).');
250
+ .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
251
  const TierOutcomeSchema = z
220
252
  .enum([
221
253
  'not-attempted',
222
254
  'miss',
223
255
  'no-fulltext',
256
+ 'no-body',
224
257
  'no-doi',
225
258
  'no-oa',
226
259
  'fetch-failed',
227
260
  'parse-failed',
228
261
  'service-error',
229
262
  ])
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.');
263
+ .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
264
  const TriedTierSchema = z
232
265
  .object({
233
266
  tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
@@ -306,7 +339,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
306
339
  .min(1)
307
340
  .max(10)
308
341
  .optional()
309
- .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`.'),
342
+ .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
343
  pmids: z
311
344
  .array(pmidStringSchema)
312
345
  .min(1)
@@ -346,14 +379,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
346
379
  .optional()
347
380
  .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
348
381
  }),
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)
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.
352
386
  enrichment: {
353
387
  notice: z
354
388
  .string()
355
389
  .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.'),
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.'),
357
391
  },
358
392
  async handler(input, ctx) {
359
393
  ctx.log.info('Executing pubmed_fetch_fulltext', {
@@ -380,6 +414,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
380
414
  // section — collected across the PMC and EPMC stages to drive one recovery
381
415
  // notice via ctx.enrich.notice (#80).
382
416
  const sectionFilterMisses = [];
417
+ // Input ids whose PMC or EPMC record carried no body sections at all. Those
418
+ // records are not full-text hits, so the chain continues past them; ids still
419
+ // unrecovered at the end drive the metadata-only recovery notice (#86).
420
+ const bodylessInputIds = new Set();
383
421
  const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
384
422
  // ── Branch routing → produce buckets the staged chain consumes ──────────
385
423
  let pmcIds = [];
@@ -511,19 +549,26 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
511
549
  if (!articleSet) {
512
550
  throw new Error('PMC EFetch response missing pmc-articleset wrapper');
513
551
  }
514
- const parsed = findAll(articleSet, 'article').map((node) => {
552
+ // A parsed article with no body sections is front matter only — PMC
553
+ // returns one whenever the publisher blocks full-text XML download. It
554
+ // is not a hit: it never enters `articles[]`, and its id is routed to
555
+ // the remaining tiers like any other PMC miss. (#86)
556
+ const bodylessPmcIds = new Set();
557
+ const parsed = [];
558
+ for (const node of findAll(articleSet, 'article')) {
515
559
  const before = parsePmcArticle(node);
560
+ if (isBodylessArticle(before)) {
561
+ if (before.pmcId)
562
+ bodylessPmcIds.add(before.pmcId);
563
+ continue;
564
+ }
516
565
  const after = applyPmcFilters(before, input);
517
566
  if (isSectionFilterMiss(before, after, input.sections)) {
518
567
  sectionFilterMisses.push(articleSectionMissId(after));
519
568
  }
520
- return after;
521
- });
522
- pmcArticles = parsed.map((a) => ({
523
- source: 'pmc',
524
- viaSource: 'pmc',
525
- ...a,
526
- }));
569
+ parsed.push({ source: 'pmc', viaSource: 'pmc', ...after });
570
+ }
571
+ pmcArticles = parsed;
527
572
  const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
528
573
  for (const prefixed of returnedPmcIds) {
529
574
  recoveredIds.add(pmcidToInputId.get(prefixed) ?? prefixed);
@@ -533,7 +578,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
533
578
  .filter((id) => !returnedPmcIds.has(id));
534
579
  for (const prefixed of missing) {
535
580
  const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
536
- chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
581
+ if (bodylessPmcIds.has(prefixed)) {
582
+ bodylessInputIds.add(inputId);
583
+ chainByInput.get(inputId)?.push({
584
+ tier: 'pmc',
585
+ outcome: 'no-body',
586
+ detail: 'PMC returned front matter and abstract only, with no body sections',
587
+ });
588
+ }
589
+ else {
590
+ chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
591
+ }
537
592
  }
538
593
  routePmcMissesToFallback(missing);
539
594
  }
@@ -590,28 +645,22 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
590
645
  chainByInput.get(c.doi)?.push(epmcDisabledEntry);
591
646
  }
592
647
  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;
648
+ const foldEpmcOutcome = (inputId, outcome) => {
602
649
  if (outcome.kind === 'hit') {
603
650
  recoveredIds.add(inputId);
604
- continue;
651
+ return;
605
652
  }
653
+ if (outcome.kind === 'no-body')
654
+ bodylessInputIds.add(inputId);
606
655
  chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
656
+ };
657
+ for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes)
658
+ foldEpmcOutcome(pmid, outcome);
659
+ for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
660
+ foldEpmcOutcome(pmcidToInputId.get(prefixed) ?? prefixed, outcome);
607
661
  }
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
- }
662
+ for (const [doi, outcome] of epmcOutcomes.doiOutcomes)
663
+ foldEpmcOutcome(doi, outcome);
615
664
  }
616
665
  pmidFallbackCandidates = epmcOutcomes.remainingPmid;
617
666
  pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
@@ -620,15 +669,76 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
620
669
  // ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
621
670
  const unpaywall = getUnpaywallService();
622
671
  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
- });
672
+ // `pmcids` input reaches Unpaywall on the DOI the chain already holds: the
673
+ // EPMC stage searches by PMCID and its hit carries one, captured on non-hit
674
+ // outcomes too. PMCIDs EPMC never resolved fall back to the PMC ID
675
+ // Converter, which returns DOIs for PMC-indexed records. (#88)
676
+ if (pmcidFallbackCandidates.length > 0) {
677
+ if (!unpaywall) {
678
+ for (const c of pmcidFallbackCandidates) {
679
+ const prefixed = withPmcPrefix(c.pmcid);
680
+ chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
681
+ tier: 'unpaywall',
682
+ outcome: 'not-attempted',
683
+ detail: 'UNPAYWALL_EMAIL is not set',
684
+ });
685
+ }
686
+ }
687
+ else {
688
+ const needDoi = pmcidFallbackCandidates
689
+ .filter((c) => !c.doi)
690
+ .map((c) => withPmcPrefix(c.pmcid));
691
+ if (needDoi.length > 0) {
692
+ try {
693
+ const records = await getNcbiService().idConvert(needDoi, 'pmcid', ctx.signal ? { signal: ctx.signal } : undefined);
694
+ const doiByPmcid = new Map();
695
+ for (const r of records) {
696
+ if (r.pmcid && r.doi) {
697
+ doiByPmcid.set(withPmcPrefix(normalizePmcId(String(r.pmcid))), String(r.doi));
698
+ }
699
+ }
700
+ pmcidFallbackCandidates = pmcidFallbackCandidates.map((c) => {
701
+ if (c.doi)
702
+ return c;
703
+ const doi = doiByPmcid.get(withPmcPrefix(c.pmcid));
704
+ return doi ? { ...c, doi } : c;
705
+ });
706
+ }
707
+ catch (error) {
708
+ ctx.log.warning('Failed to resolve PMCID → DOI for the Unpaywall fallback', {
709
+ error: error instanceof Error ? error.message : String(error),
710
+ pmcidCount: needDoi.length,
711
+ });
712
+ }
713
+ }
714
+ const outcomes = await Promise.all(pmcidFallbackCandidates.map(async (candidate) => {
715
+ // The prefixed PMCID is the id `unavailable[]` keys on, so stamping
716
+ // it on the article makes a partially-recovered batch report its
717
+ // successes and its failures under the same identifier. (#92)
718
+ const pmcId = withPmcPrefix(candidate.pmcid);
719
+ return {
720
+ pmcId,
721
+ result: candidate.doi
722
+ ? await resolveUnpaywall({ pmcId, doi: candidate.doi }, unpaywall, ctx)
723
+ : { unavailable: { reason: 'no-doi' } },
724
+ };
725
+ }));
726
+ for (const { pmcId, result } of outcomes) {
727
+ const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
728
+ if ('article' in result) {
729
+ fallbackArticles.push(result.article);
730
+ recoveredIds.add(inputId);
731
+ }
732
+ else {
733
+ const u = result.unavailable;
734
+ chainByInput.get(inputId)?.push({
735
+ tier: 'unpaywall',
736
+ outcome: unpaywallReasonToTierOutcome(u.reason),
737
+ ...(u.detail && { detail: u.detail }),
738
+ });
739
+ }
740
+ }
741
+ }
632
742
  }
633
743
  if (pmidFallbackCandidates.length > 0) {
634
744
  // The PMC ID Converter only returns DOIs for articles it has in PMC, so
@@ -738,9 +848,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
738
848
  unpaywallHits: fallbackArticles.length,
739
849
  unavailable: unavailable.length,
740
850
  });
851
+ // Only the last ctx.enrich.notice survives, so the applicable fragments are
852
+ // collected and emitted once.
853
+ const notices = [];
741
854
  if (input.sections?.length && sectionFilterMisses.length > 0) {
742
- ctx.enrich.notice(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
855
+ notices.push(buildSectionFilterMissNotice(sectionFilterMisses, input.sections));
743
856
  }
857
+ const unrecoveredBodyless = [...bodylessInputIds].filter((id) => !recoveredIds.has(id));
858
+ if (unrecoveredBodyless.length > 0)
859
+ notices.push(buildBodylessNotice(unrecoveredBodyless));
860
+ if (notices.length > 0)
861
+ ctx.enrich.notice(notices.join(' '));
744
862
  return {
745
863
  articles,
746
864
  totalReturned: articles.length,
@@ -795,27 +913,42 @@ async function runEpmcStage(epmc, args) {
795
913
  }
796
914
  if (search.kind === 'miss')
797
915
  return { c, outcome: { kind: 'miss' } };
916
+ const doi = search.hit.doi ? { doi: search.hit.doi } : {};
798
917
  const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
799
918
  if (fetched.kind === 'error') {
800
- return { c, outcome: { kind: 'service-error', detail: fetched.detail } };
919
+ return { c, ...doi, outcome: { kind: 'service-error', detail: fetched.detail } };
801
920
  }
802
921
  if (fetched.kind === 'no-fulltext') {
803
922
  return {
804
923
  c,
924
+ ...doi,
805
925
  outcome: { kind: 'no-fulltext', ...(fetched.detail && { detail: fetched.detail }) },
806
926
  };
807
927
  }
928
+ if (fetched.kind === 'no-body') {
929
+ return { c, ...doi, outcome: { kind: 'no-body', detail: fetched.detail } };
930
+ }
808
931
  return {
809
932
  c,
933
+ ...doi,
810
934
  outcome: { kind: 'hit' },
811
935
  article: fetched.article,
812
936
  sectionFilterMiss: fetched.sectionFilterMiss,
813
937
  };
814
938
  };
815
- const fetchForPmid = (c) => runOne(c, `EXT_ID:"${c.pmid}" AND SRC:MED`, c.pmid);
939
+ /**
940
+ * Query shapes are load-bearing and not interchangeable with their quoted
941
+ * variants. Europe PMC matches zero records for `EXT_ID:"<pmid>" AND SRC:MED`
942
+ * and `PMCID:"PMC<digits>"` — the quotes only survive as long as no `AND SRC:`
943
+ * clause follows. `SRC:PMC` is likewise wrong for a PMCID lookup: EPMC's
944
+ * canonical record for a PMC-indexed article has `source: MED` and carries the
945
+ * PMCID as a field, so the filter excludes the very record being sought. DOIs
946
+ * keep their quotes — they carry slashes and dots that need them. (#85)
947
+ */
948
+ const fetchForPmid = (c) => runOne(c, `EXT_ID:${c.pmid} AND SRC:MED`, c.pmid);
816
949
  const fetchForPmcid = (c) => {
817
950
  const normalized = withPmcPrefix(c.pmcid);
818
- return runOne({ c, normalized }, `PMCID:"${normalized}" AND SRC:PMC`, undefined);
951
+ return runOne({ c, normalized }, `PMCID:${normalized}`, undefined);
819
952
  };
820
953
  const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
821
954
  const [pmidResults, pmcidResults, doiResults] = await Promise.all([
@@ -841,7 +974,7 @@ async function runEpmcStage(epmc, args) {
841
974
  else
842
975
  remainingPmid.push(c);
843
976
  }
844
- for (const { c: pair, outcome, article, sectionFilterMiss } of pmcidResults) {
977
+ for (const { c: pair, outcome, article, sectionFilterMiss, doi } of pmcidResults) {
845
978
  pmcidOutcomes.set(pair.normalized, outcome);
846
979
  if (article) {
847
980
  articles.push(article);
@@ -849,7 +982,7 @@ async function runEpmcStage(epmc, args) {
849
982
  sectionFilterMisses.push(articleSectionMissId(article));
850
983
  }
851
984
  else
852
- remainingPmcid.push(pair.c);
985
+ remainingPmcid.push(doi && !pair.c.doi ? { ...pair.c, doi } : pair.c);
853
986
  }
854
987
  for (const { c, outcome, article, sectionFilterMiss } of doiResults) {
855
988
  doiOutcomes.set(c.doi, outcome);
@@ -923,6 +1056,12 @@ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
923
1056
  return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
924
1057
  }
925
1058
  const beforeFilter = parsePmcArticle(articleNode);
1059
+ if (isBodylessArticle(beforeFilter)) {
1060
+ return {
1061
+ kind: 'no-body',
1062
+ detail: 'EPMC fullTextXML carried front matter and abstract only, with no body sections',
1063
+ };
1064
+ }
926
1065
  const parsed = applyPmcFilters(beforeFilter, args.input);
927
1066
  const sectionFilterMiss = isSectionFilterMiss(beforeFilter, parsed, args.input.sections);
928
1067
  // `parsePmcArticle` always returns string fields (sometimes empty). Strip
@@ -987,12 +1126,13 @@ async function fetchPubmedDois(pmids, signal) {
987
1126
  return out;
988
1127
  }
989
1128
  /**
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.
1129
+ * Resolve a DOI to an open-access article via Unpaywall. `pmcId` and `pmid`,
1130
+ * when set, are stamped onto the resulting article so the branch that requested
1131
+ * it carries its identifier through — Unpaywall itself only knows the DOI.
993
1132
  */
994
1133
  async function resolveUnpaywall(args, service, ctx) {
995
- const { pmid, doi } = args;
1134
+ const { pmcId, pmid, doi } = args;
1135
+ const requestedIds = { ...(pmcId && { pmcId }), ...(pmid && { pmid }) };
996
1136
  let resolution;
997
1137
  try {
998
1138
  resolution = await service.resolve(doi, ctx.signal);
@@ -1031,7 +1171,7 @@ async function resolveUnpaywall(args, service, ctx) {
1031
1171
  }
1032
1172
  return {
1033
1173
  article: buildUnpaywallArticle({
1034
- ...(pmid && { pmid }),
1174
+ ...requestedIds,
1035
1175
  doi,
1036
1176
  sourceUrl: content.fetchedUrl,
1037
1177
  location: resolution.location,
@@ -1051,7 +1191,7 @@ async function resolveUnpaywall(args, service, ctx) {
1051
1191
  }
1052
1192
  return {
1053
1193
  article: buildUnpaywallArticle({
1054
- ...(pmid && { pmid }),
1194
+ ...requestedIds,
1055
1195
  doi,
1056
1196
  sourceUrl: content.fetchedUrl,
1057
1197
  location: resolution.location,
@@ -1073,6 +1213,7 @@ function buildUnpaywallArticle(args) {
1073
1213
  source: 'unpaywall',
1074
1214
  viaSource: 'unpaywall',
1075
1215
  contentFormat: args.contentFormat,
1216
+ ...(args.pmcId && { pmcId: args.pmcId }),
1076
1217
  ...(args.pmid && {
1077
1218
  pmid: args.pmid,
1078
1219
  pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
@@ -1103,6 +1244,8 @@ function epmcTierFromOutcome(outcome) {
1103
1244
  outcome: 'no-fulltext',
1104
1245
  ...(outcome.detail && { detail: outcome.detail }),
1105
1246
  };
1247
+ case 'no-body':
1248
+ return { tier: 'europepmc', outcome: 'no-body', detail: outcome.detail };
1106
1249
  case 'service-error':
1107
1250
  return { tier: 'europepmc', outcome: 'service-error', detail: outcome.detail };
1108
1251
  }
@@ -1116,6 +1259,7 @@ function epmcTierFromOutcome(outcome) {
1116
1259
  */
1117
1260
  function unpaywallReasonToTierOutcome(reason) {
1118
1261
  switch (reason) {
1262
+ case 'no-body':
1119
1263
  case 'no-doi':
1120
1264
  case 'no-oa':
1121
1265
  case 'fetch-failed':
@@ -1152,6 +1296,9 @@ function reasonFromChain(chain) {
1152
1296
  return 'not-found';
1153
1297
  case 'europepmc:no-fulltext':
1154
1298
  return 'no-epmc-fulltext';
1299
+ case 'pmc:no-body':
1300
+ case 'europepmc:no-body':
1301
+ return 'no-body';
1155
1302
  case 'unpaywall:no-doi':
1156
1303
  return 'no-doi';
1157
1304
  case 'unpaywall:no-oa':
@@ -1245,12 +1392,15 @@ function formatPmcArticle(a, lines) {
1245
1392
  }
1246
1393
  }
1247
1394
  function formatUnpaywallArticle(a, lines) {
1248
- const heading = a.title ?? (a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`);
1395
+ const requestedId = a.pmcId ? `PMCID ${a.pmcId}` : a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`;
1396
+ const heading = a.title ?? requestedId;
1249
1397
  const formatLabel = a.contentFormat === 'html-markdown'
1250
1398
  ? 'Unpaywall (HTML → Markdown, best-effort)'
1251
1399
  : 'Unpaywall (PDF → plain text)';
1252
1400
  lines.push(`### ${heading}`);
1253
1401
  lines.push(`**Source:** ${formatLabel}`);
1402
+ if (a.pmcId)
1403
+ lines.push(`**PMCID:** ${a.pmcId}`);
1254
1404
  if (a.pmid)
1255
1405
  lines.push(`**PMID:** ${a.pmid}`);
1256
1406
  lines.push(`**DOI:** ${a.doi}`);