@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.
- package/AGENTS.md +30 -8
- package/CLAUDE.md +30 -8
- package/Dockerfile +13 -7
- package/README.md +1 -1
- package/dist/mcp-server/prompts/definitions/research-plan.prompt.d.ts +1 -1
- package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +17 -17
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +18 -18
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +43 -40
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +206 -56
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +33 -33
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +17 -17
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +16 -16
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +9 -12
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +20 -16
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +73 -22
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +18 -18
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +22 -22
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +15 -15
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +33 -33
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts +14 -1
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +22 -25
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/request-queue.d.ts.map +1 -1
- package/dist/services/europe-pmc/request-queue.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +2 -2
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.js +3 -0
- package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/xml-helpers.js.map +1 -1
- package/dist/services/ncbi/request-queue.d.ts.map +1 -1
- package/dist/services/ncbi/request-queue.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/types.d.ts +6 -0
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts +15 -1
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +22 -24
- package/dist/services/openalex/api-client.js.map +1 -1
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +1 -0
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +24 -17
- 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
|
|
350
|
-
//
|
|
351
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
}
|
|
522
|
-
pmcArticles = parsed
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`,
|
|
991
|
-
*
|
|
992
|
-
*
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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}`);
|