@cyanheads/pubmed-mcp-server 2.6.11 → 2.7.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 (54) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +36 -14
  3. package/dist/config/server-config.d.ts +6 -0
  4. package/dist/config/server-config.d.ts.map +1 -1
  5. package/dist/config/server-config.js +52 -0
  6. package/dist/config/server-config.js.map +1 -1
  7. package/dist/index.js +19 -12
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +71 -23
  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 +671 -141
  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 +0 -1
  14. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/find-related.tool.js +21 -41
  16. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +87 -0
  18. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -0
  19. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +217 -0
  20. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -0
  21. package/dist/services/error-contracts.d.ts +18 -1
  22. package/dist/services/error-contracts.d.ts.map +1 -1
  23. package/dist/services/error-contracts.js +21 -4
  24. package/dist/services/error-contracts.js.map +1 -1
  25. package/dist/services/europe-pmc/api-client.d.ts +47 -0
  26. package/dist/services/europe-pmc/api-client.d.ts.map +1 -0
  27. package/dist/services/europe-pmc/api-client.js +123 -0
  28. package/dist/services/europe-pmc/api-client.js.map +1 -0
  29. package/dist/services/europe-pmc/europe-pmc-service.d.ts +71 -0
  30. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -0
  31. package/dist/services/europe-pmc/europe-pmc-service.js +243 -0
  32. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -0
  33. package/dist/services/europe-pmc/request-queue.d.ts +33 -0
  34. package/dist/services/europe-pmc/request-queue.d.ts.map +1 -0
  35. package/dist/services/europe-pmc/request-queue.js +107 -0
  36. package/dist/services/europe-pmc/request-queue.js.map +1 -0
  37. package/dist/services/europe-pmc/types.d.ts +123 -0
  38. package/dist/services/europe-pmc/types.d.ts.map +1 -0
  39. package/dist/services/europe-pmc/types.js +16 -0
  40. package/dist/services/europe-pmc/types.js.map +1 -0
  41. package/dist/services/ncbi/ncbi-service.d.ts +6 -7
  42. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  43. package/dist/services/ncbi/ncbi-service.js +11 -11
  44. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  45. package/dist/services/ncbi/request-queue.d.ts +26 -11
  46. package/dist/services/ncbi/request-queue.d.ts.map +1 -1
  47. package/dist/services/ncbi/request-queue.js +94 -52
  48. package/dist/services/ncbi/request-queue.js.map +1 -1
  49. package/dist/services/ncbi/response-handler.js +1 -1
  50. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  51. package/dist/services/unpaywall/unpaywall-service.js +14 -13
  52. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  53. package/package.json +7 -6
  54. package/server.json +4 -4
@@ -1,30 +1,58 @@
1
1
  /**
2
- * @fileoverview Full-text fetch tool. Primary source is PubMed Central (NCBI
3
- * EFetch, `db=pmc`). When a PMID has no PMC copy but does have a DOI,
4
- * transparently falls back to Unpaywall to retrieve a legally-deposited
5
- * open-access copy (HTML or PDF). Output uses a discriminated union on
6
- * `source` so callers can reason about structural reliability per article.
2
+ * @fileoverview Full-text fetch tool. Resolves full-text articles through a
3
+ * three-stage chain: NCBI PMC EFetch Europe PMC `fullTextXML` Unpaywall.
4
+ * Accepts three mutually-exclusive input shapes:
5
+ *
6
+ * - `pmcids` fetch directly by PMC ID. Articles not in PMC fall through to
7
+ * EPMC by PMC ID, then to Unpaywall when the DOI is available.
8
+ * - `pmids` — resolve PMID → PMCID via PMC ID Converter, then run the chain.
9
+ * - `dois` — skip PMC EFetch (no PMCID); resolve via EPMC search-by-DOI →
10
+ * fullTextXML, then Unpaywall. Covers EPMC-only OA and preprints with no
11
+ * PubMed presence.
12
+ *
13
+ * Output uses a discriminated union on `source` (`pmc` | `unpaywall`) with an
14
+ * extra `viaSource` discriminator that records which layer produced the
15
+ * content. EPMC's JATS reuses the `pmc` schema shape because it's the same
16
+ * DTD; `viaSource: 'europepmc'` distinguishes it from PMC EFetch output.
17
+ *
7
18
  * @module src/mcp-server/tools/definitions/fetch-fulltext.tool
8
19
  */
9
20
  import { tool, z } from '@cyanheads/mcp-ts-core';
10
- import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
11
21
  import { htmlExtractor, pdfParser } from '@cyanheads/mcp-ts-core/utils';
12
- import { NCBI_SERVICE_ERRORS, UNPAYWALL_SERVICE_ERRORS } from '../../../services/error-contracts.js';
22
+ import { EUROPEPMC_SERVICE_ERRORS, NCBI_SERVICE_ERRORS, UNPAYWALL_SERVICE_ERRORS, } from '../../../services/error-contracts.js';
23
+ import { getEuropePmcService, } from '../../../services/europe-pmc/europe-pmc-service.js';
13
24
  import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
14
25
  import { extractDoi, extractPmid } from '../../../services/ncbi/parsing/article-parser.js';
15
26
  import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-parser.js';
16
27
  import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
17
28
  import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
18
- import { getUnpaywallService } from '../../../services/unpaywall/unpaywall-service.js';
29
+ import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
19
30
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
20
31
  import { pmidStringSchema } from './_schemas.js';
21
32
  function normalizePmcId(id) {
22
33
  return id.replace(/^PMC/i, '');
23
34
  }
35
+ function withPmcPrefix(id) {
36
+ return id.startsWith('PMC') ? id : `PMC${id}`;
37
+ }
24
38
  function filterSections(sections, sectionFilter) {
25
39
  const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
26
40
  return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
27
41
  }
42
+ function applyPmcFilters(article, filters) {
43
+ let out = article;
44
+ if (filters.sections?.length) {
45
+ out = { ...out, sections: filterSections(out.sections, filters.sections) };
46
+ }
47
+ if (filters.maxSections !== undefined) {
48
+ out = { ...out, sections: out.sections.slice(0, filters.maxSections) };
49
+ }
50
+ if (!filters.includeReferences) {
51
+ const { references: _, ...rest } = out;
52
+ out = rest;
53
+ }
54
+ return out;
55
+ }
28
56
  // ─── Schemas ─────────────────────────────────────────────────────────────────
29
57
  const SubsectionSchema = z
30
58
  .object({
@@ -73,9 +101,17 @@ const PublicationDateSchema = z
73
101
  .describe('Publication date');
74
102
  const PmcArticleSchema = z
75
103
  .object({
76
- source: z.literal('pmc').describe('Content came from PubMed Central as structured JATS XML'),
77
- pmcId: z.string().describe('PMC ID'),
78
- pmcUrl: z.string().describe('PMC URL'),
104
+ source: z
105
+ .literal('pmc')
106
+ .describe('Structured JATS — same DTD whether sourced from NCBI PMC or Europe PMC'),
107
+ viaSource: z
108
+ .enum(['pmc', 'europepmc'])
109
+ .describe('Which layer produced the JATS: `pmc` for NCBI PMC EFetch (db=pmc), `europepmc` for Europe PMC `fullTextXML`. Both paths return the same JATS shape; the discriminator records origin for observability and license attribution.'),
110
+ pmcId: z
111
+ .string()
112
+ .optional()
113
+ .describe('PMC ID — present for NCBI PMC records and Europe PMC entries that have a PMC counterpart. Absent for EPMC-only records like preprints; use `epmcId` in that case.'),
114
+ pmcUrl: z.string().optional().describe('PMC URL — derived from `pmcId` when present'),
79
115
  pmid: z.string().optional().describe('PubMed ID'),
80
116
  pubmedUrl: z.string().optional().describe('PubMed URL'),
81
117
  doi: z.string().optional().describe('DOI'),
@@ -89,18 +125,32 @@ const PmcArticleSchema = z
89
125
  publicationDate: PublicationDateSchema.optional(),
90
126
  sections: z.array(SectionSchema).describe('Article body sections'),
91
127
  references: z.array(ReferenceSchema).optional().describe('Reference list'),
128
+ epmcId: z
129
+ .string()
130
+ .optional()
131
+ .describe('Europe PMC record id — present when `viaSource` is `europepmc`'),
132
+ epmcSource: z
133
+ .string()
134
+ .optional()
135
+ .describe('Europe PMC source code when `viaSource` is `europepmc`. Common values: `MED` (PubMed-derived), `PMC` (PMC counterpart), `PPR` (preprint), `PAT` (patent), `AGR` (Agricola), plus less common codes (`CTX`, `CBA`, `ETH`, `HIR`). Treat as opaque — EPMC may introduce new codes.'),
92
136
  })
93
- .describe('Structured PMC full-text article reliable section/reference structure');
137
+ .describe('Structured JATS full-text article. `viaSource` records whether the JATS came from NCBI PMC or Europe PMC.');
94
138
  const UnpaywallArticleSchema = z
95
139
  .object({
96
140
  source: z
97
141
  .literal('unpaywall')
98
142
  .describe('Content fetched from an open-access copy indexed by Unpaywall. Best-effort — structural fidelity depends on `contentFormat`.'),
143
+ viaSource: z
144
+ .literal('unpaywall')
145
+ .describe('Layer that produced this article. Constant `unpaywall` for this branch.'),
99
146
  contentFormat: z
100
147
  .enum(['html-markdown', 'pdf-text'])
101
148
  .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.'),
102
- pmid: z.string().describe('PubMed ID the article was resolved from'),
103
- pubmedUrl: z.string().describe('PubMed URL'),
149
+ pmid: z
150
+ .string()
151
+ .optional()
152
+ .describe('PubMed ID when input was `pmids`; absent for `dois` input'),
153
+ pubmedUrl: z.string().optional().describe('PubMed URL — present when `pmid` is set'),
104
154
  doi: z.string().describe('DOI used to locate the open-access copy'),
105
155
  sourceUrl: z.string().describe('URL the content was fetched from'),
106
156
  title: z.string().optional().describe('Detected article title when present'),
@@ -126,39 +176,60 @@ const UnpaywallArticleSchema = z
126
176
  .describe('Best-effort full text from an open-access copy');
127
177
  const ArticleSchema = z
128
178
  .discriminatedUnion('source', [PmcArticleSchema, UnpaywallArticleSchema])
129
- .describe('Full-text article; shape depends on `source` (pmc = structured, unpaywall = best-effort)');
179
+ .describe('Full-text article; shape depends on `source` (pmc = structured JATS, unpaywall = best-effort)');
130
180
  const UnavailableReasonSchema = z
131
181
  .enum([
182
+ 'not-found',
132
183
  'no-pmc-fallback-disabled',
184
+ 'no-epmc-fulltext',
133
185
  'no-doi',
134
186
  'no-oa',
135
187
  'fetch-failed',
136
188
  'parse-failed',
137
189
  'service-error',
138
190
  ])
139
- .describe(`Why the PMID has no full text. no-pmc-fallback-disabled: not in PMC and UNPAYWALL_EMAIL is unset so the Unpaywall fallback is off. no-doi: not in PMC and the ID Converter returned no DOI to try Unpaywall with. no-oa: DOI exists but Unpaywall has no open-access copy indexed. fetch-failed: OA location found but the content could not be downloaded. parse-failed: content was downloaded but text extraction produced nothing usable. service-error: Unpaywall or an upstream host returned a server error.`);
191
+ .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).');
192
+ const TierOutcomeSchema = z
193
+ .enum([
194
+ 'not-attempted',
195
+ 'miss',
196
+ 'no-fulltext',
197
+ 'no-doi',
198
+ 'no-oa',
199
+ 'fetch-failed',
200
+ 'parse-failed',
201
+ 'service-error',
202
+ ])
203
+ .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.');
204
+ const TriedTierSchema = z
205
+ .object({
206
+ tier: z.enum(['pmc', 'europepmc', 'unpaywall']).describe('Which tier in the resolution chain'),
207
+ outcome: TierOutcomeSchema,
208
+ detail: z.string().optional().describe('Tier-specific context when available'),
209
+ })
210
+ .describe('One tier the resolution chain attempted, with its outcome');
140
211
  const UnavailableSchema = z
141
212
  .object({
142
- pmid: z.string().describe('PMID full text could not be returned for'),
213
+ id: z
214
+ .string()
215
+ .describe('Identifier the chain could not resolve — PMID, PMCID, or DOI per `idType`'),
216
+ idType: z.enum(['pmid', 'pmcid', 'doi']).describe('Which input branch the id came from'),
143
217
  reason: UnavailableReasonSchema,
144
- detail: z.string().optional().describe('Additional context when available'),
218
+ triedTiers: z
219
+ .array(TriedTierSchema)
220
+ .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.'),
145
221
  })
146
- .describe('One PMID that could not be returned with an explanation of why');
222
+ .describe('One identifier that could not be returned, with the full chain it traversed');
147
223
  // ─── Tool Definition ─────────────────────────────────────────────────────────
148
224
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
149
- description: 'Fetch full-text articles from PubMed Central with structured sections and references. When a PMID has no PMC copy, transparently falls back to publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text. Provide exactly one of `pmcids` (PMC IDs directly) or `pmids` (PubMed IDs, auto-resolved) not both, not neither.',
225
+ description: 'Fetch full-text articles from PubMed Central with structured sections and references. When PMC misses, transparently falls back to Europe PMC `fullTextXML` (structured JATS for records with a PMC counterpart), then to Unpaywall — publisher-hosted or institutional open-access copies as HTML-as-Markdown or PDF-as-text. Provide exactly one of `pmcids` (PMC IDs directly), `pmids` (PubMed IDs, auto-resolved), or `dois` (preprints and EPMC-only OA records that lack PMID/PMCID).',
150
226
  annotations: { readOnlyHint: true, openWorldHint: true },
151
227
  _meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL]),
152
228
  sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/fetch-fulltext.tool.ts',
153
229
  errors: [
154
230
  ...NCBI_SERVICE_ERRORS,
155
231
  ...UNPAYWALL_SERVICE_ERRORS,
156
- {
157
- reason: 'invalid_pmc_efetch_response',
158
- code: JsonRpcErrorCode.SerializationError,
159
- when: 'PMC EFetch returned a payload missing the pmc-articleset wrapper.',
160
- recovery: 'Retry once; if it persists, NCBI returned malformed data — try fewer PMC IDs at once.',
161
- },
232
+ ...EUROPEPMC_SERVICE_ERRORS,
162
233
  ],
163
234
  input: z
164
235
  .object({
@@ -167,13 +238,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
167
238
  .min(1)
168
239
  .max(10)
169
240
  .optional()
170
- .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids` or `pmids`.'),
241
+ .describe('PMC IDs to fetch (e.g. ["PMC9575052"]). Provide exactly one of `pmcids`, `pmids`, or `dois`.'),
171
242
  pmids: z
172
243
  .array(pmidStringSchema)
173
244
  .min(1)
174
245
  .max(10)
175
246
  .optional()
176
- .describe('PubMed IDs. Provide exactly one of `pmcids` or `pmids`. Articles in PMC are returned as structured JATS; articles not in PMC are retrieved from Unpaywall when UNPAYWALL_EMAIL is set and a DOI is available.'),
247
+ .describe('PubMed IDs. Provide exactly one of `pmcids`, `pmids`, or `dois`. Articles in PMC are returned as structured JATS; articles not in PMC fall through to Europe PMC (when EPMC has a `fullTextXML`), then to Unpaywall when `UNPAYWALL_EMAIL` is set and a DOI is available.'),
248
+ dois: z
249
+ .array(z.string().min(3))
250
+ .min(1)
251
+ .max(10)
252
+ .optional()
253
+ .describe('DOIs to resolve (e.g. ["10.21203/rs.3.rs-9010375/v1"]). Provide exactly one of `pmcids`, `pmids`, or `dois`. Covers preprints and EPMC-only OA records that lack PMID/PMCID. Chain: Europe PMC search-by-DOI → fullTextXML → Unpaywall.'),
177
254
  includeReferences: z
178
255
  .boolean()
179
256
  .default(false)
@@ -190,8 +267,8 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
190
267
  .optional()
191
268
  .describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
192
269
  })
193
- .refine((v) => (v.pmcids === undefined) !== (v.pmids === undefined), {
194
- message: 'Provide exactly one of `pmcids` or `pmids` (not both, not neither).',
270
+ .refine((v) => [v.pmcids, v.pmids, v.dois].filter((b) => b !== undefined).length === 1, {
271
+ message: 'Provide exactly one of `pmcids`, `pmids`, or `dois` (not zero, not more).',
195
272
  }),
196
273
  output: z.object({
197
274
  articles: z.array(ArticleSchema).describe('Full-text articles'),
@@ -199,22 +276,37 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
199
276
  unavailable: z
200
277
  .array(UnavailableSchema)
201
278
  .optional()
202
- .describe('Per-PMID explanations for any requested PMIDs with no returnable full text'),
203
- unavailablePmcIds: z
204
- .array(z.string())
205
- .optional()
206
- .describe('PMC IDs that returned no data, whether requested directly via `pmcids` or resolved from `pmids` via the PMC ID Converter'),
279
+ .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
207
280
  }),
208
281
  async handler(input, ctx) {
209
282
  ctx.log.info('Executing pubmed_fetch_fulltext', {
210
283
  hasPmcids: !!input.pmcids,
211
284
  hasPmids: !!input.pmids,
212
- idCount: (input.pmcids ?? input.pmids)?.length,
285
+ hasDois: !!input.dois,
286
+ idCount: (input.pmcids ?? input.pmids ?? input.dois)?.length,
213
287
  });
214
- // ── PMID path: resolve to PMC + collect unresolved PMIDs for Unpaywall fallback ──
288
+ // ── Chain tracking ──────────────────────────────────────────────────────
289
+ // Per-input-id tier history (the `triedTiers` array on unavailable entries).
290
+ // Keys: pmid for `pmids` input, prefixed PMCID for `pmcids` input, doi for
291
+ // `dois` input. `recoveredIds` collects ids the chain produced an article
292
+ // for, so we can skip them when building `unavailable[]`.
293
+ const chainByInput = new Map();
294
+ const recoveredIds = new Set();
295
+ // PMCIDs the converter resolved from a pmid → PMID, for back-mapping after
296
+ // the PMC and EPMC stages.
297
+ const pmcidToPmid = new Map();
298
+ // DOI hints captured during pmids→pmcid routing so PMC-misses on the pmids
299
+ // branch can still reach Unpaywall without re-fetching from PubMed metadata.
300
+ const pmidContext = new Map();
301
+ const idType = input.pmids ? 'pmid' : input.pmcids ? 'pmcid' : 'doi';
302
+ // ── Branch routing → produce buckets the staged chain consumes ──────────
215
303
  let pmcIds = [];
216
- let fallbackCandidates = [];
304
+ let pmidFallbackCandidates = [];
305
+ let pmcidFallbackCandidates = [];
306
+ let doiCandidates = [];
217
307
  if (input.pmids) {
308
+ for (const id of input.pmids)
309
+ chainByInput.set(id, []);
218
310
  const records = await getNcbiService().idConvert(input.pmids, 'pmid', ctx.signal ? { signal: ctx.signal } : undefined);
219
311
  const seen = new Set();
220
312
  for (const r of records) {
@@ -223,111 +315,295 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
223
315
  const pmid = String(r.pmid);
224
316
  seen.add(pmid);
225
317
  if (r.pmcid) {
226
- pmcIds.push(normalizePmcId(String(r.pmcid)));
318
+ const normalized = normalizePmcId(String(r.pmcid));
319
+ pmcIds.push(normalized);
320
+ pmcidToPmid.set(withPmcPrefix(normalized), pmid);
321
+ pmidContext.set(pmid, { pmid, ...(r.doi && { doi: r.doi }) });
227
322
  }
228
323
  else {
229
- fallbackCandidates.push({ pmid, ...(r.doi && { doi: r.doi }) });
324
+ chainByInput.get(pmid)?.push({
325
+ tier: 'pmc',
326
+ outcome: 'not-attempted',
327
+ detail: 'PMID has no PMC counterpart',
328
+ });
329
+ pmidFallbackCandidates.push({ pmid, ...(r.doi && { doi: r.doi }) });
230
330
  }
231
331
  }
232
- // Any requested PMIDs the converter didn't return at all: treat as fallback candidates.
233
332
  for (const requested of input.pmids) {
234
- if (!seen.has(requested))
235
- fallbackCandidates.push({ pmid: requested });
333
+ if (!seen.has(requested)) {
334
+ chainByInput.get(requested)?.push({
335
+ tier: 'pmc',
336
+ outcome: 'not-attempted',
337
+ detail: 'ID Converter returned no record for this PMID',
338
+ });
339
+ pmidFallbackCandidates.push({ pmid: requested });
340
+ }
236
341
  }
237
342
  }
238
- else {
239
- pmcIds = (input.pmcids ?? []).map(normalizePmcId);
343
+ else if (input.pmcids) {
344
+ for (const id of input.pmcids)
345
+ chainByInput.set(withPmcPrefix(normalizePmcId(id)), []);
346
+ pmcIds = input.pmcids.map(normalizePmcId);
347
+ }
348
+ else if (input.dois) {
349
+ for (const doi of input.dois) {
350
+ chainByInput.set(doi, [
351
+ { tier: 'pmc', outcome: 'not-attempted', detail: 'DOI input bypasses PMC EFetch' },
352
+ ]);
353
+ }
354
+ doiCandidates = input.dois.map((doi) => ({ doi }));
240
355
  }
241
- // ── PMC fetch ───────────────────────────────────────────────────────────
356
+ // Route PMC-missed prefixed PMCIDs into the fallback buckets so EPMC and
357
+ // (for pmids) Unpaywall still get a chance. For pmids input we look up the
358
+ // captured DOI hint via `pmidContext` to avoid an extra PubMed eFetch when
359
+ // available; the converter often returns the DOI alongside a PMCID match.
360
+ const routePmcMissesToFallback = (missingPrefixed) => {
361
+ if (missingPrefixed.length === 0)
362
+ return;
363
+ if (input.pmcids) {
364
+ pmcidFallbackCandidates = missingPrefixed.map((pmcid) => ({ pmcid }));
365
+ }
366
+ else if (input.pmids) {
367
+ for (const prefixed of missingPrefixed) {
368
+ const pmid = pmcidToPmid.get(prefixed);
369
+ if (pmid)
370
+ pmidFallbackCandidates.push(pmidContext.get(pmid) ?? { pmid });
371
+ }
372
+ }
373
+ };
374
+ // ── Stage 1: PMC EFetch ─────────────────────────────────────────────────
375
+ // Wrapped so transient NCBI failures fall through to EPMC/Unpaywall rather
376
+ // than sinking the whole batch — the chain's contract is graceful fallback.
242
377
  let pmcArticles = [];
243
- let unavailablePmcIds;
244
378
  if (pmcIds.length > 0) {
245
- const xmlData = await getNcbiService().eFetch({ db: 'pmc', id: pmcIds.join(','), retmode: 'xml' }, {
246
- retmode: 'xml',
247
- useOrderedParser: true,
248
- usePost: pmcIds.length > 5,
249
- signal: ctx.signal,
250
- });
251
- const articleSet = findOne(xmlData, 'pmc-articleset');
252
- if (!articleSet)
253
- throw ctx.fail('invalid_pmc_efetch_response', 'Invalid PMC EFetch response: missing pmc-articleset', {
254
- requestedPmcIdCount: pmcIds.length,
255
- ...ctx.recoveryFor('invalid_pmc_efetch_response'),
379
+ try {
380
+ const xmlData = await getNcbiService().eFetch({ db: 'pmc', id: pmcIds.join(','), retmode: 'xml' }, {
381
+ retmode: 'xml',
382
+ useOrderedParser: true,
383
+ usePost: pmcIds.length > 5,
384
+ signal: ctx.signal,
385
+ });
386
+ const articleSet = findOne(xmlData, 'pmc-articleset');
387
+ if (!articleSet) {
388
+ throw new Error('PMC EFetch response missing pmc-articleset wrapper');
389
+ }
390
+ const parsed = findAll(articleSet, 'article')
391
+ .map(parsePmcArticle)
392
+ .map((a) => applyPmcFilters(a, input));
393
+ pmcArticles = parsed.map((a) => ({
394
+ source: 'pmc',
395
+ viaSource: 'pmc',
396
+ ...a,
397
+ }));
398
+ const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
399
+ for (const prefixed of returnedPmcIds) {
400
+ recoveredIds.add(pmcidToPmid.get(prefixed) ?? prefixed);
401
+ }
402
+ const missing = pmcIds
403
+ .map((id) => withPmcPrefix(id))
404
+ .filter((id) => !returnedPmcIds.has(id));
405
+ for (const prefixed of missing) {
406
+ const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
407
+ chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'miss' });
408
+ }
409
+ routePmcMissesToFallback(missing);
410
+ }
411
+ catch (error) {
412
+ const detail = error instanceof Error ? error.message : String(error);
413
+ ctx.log.warning('PMC EFetch failed; chain continues with next layer', {
414
+ pmcIdCount: pmcIds.length,
415
+ error: detail,
256
416
  });
257
- let parsed = findAll(articleSet, 'article').map(parsePmcArticle);
258
- if (input.sections?.length) {
259
- const sectionFilter = input.sections;
260
- parsed = parsed.map((a) => ({ ...a, sections: filterSections(a.sections, sectionFilter) }));
417
+ const allPrefixed = pmcIds.map(withPmcPrefix);
418
+ for (const prefixed of allPrefixed) {
419
+ const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
420
+ chainByInput.get(inputId)?.push({ tier: 'pmc', outcome: 'service-error', detail });
421
+ }
422
+ routePmcMissesToFallback(allPrefixed);
423
+ }
424
+ }
425
+ // ── Stage 2: Europe PMC fullTextXML ─────────────────────────────────────
426
+ const epmc = getEuropePmcService();
427
+ const epmcOutcomes = epmc
428
+ ? await runEpmcStage(epmc, {
429
+ pmidFallbackCandidates,
430
+ pmcidFallbackCandidates,
431
+ doiCandidates,
432
+ input,
433
+ ctx,
434
+ })
435
+ : {
436
+ articles: [],
437
+ remainingPmid: pmidFallbackCandidates,
438
+ remainingPmcid: pmcidFallbackCandidates,
439
+ remainingDoi: doiCandidates,
440
+ pmidOutcomes: new Map(),
441
+ pmcidOutcomes: new Map(),
442
+ doiOutcomes: new Map(),
443
+ };
444
+ pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
445
+ // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
446
+ // recovered, so their ids are added to `recoveredIds` here.
447
+ if (!epmc) {
448
+ const epmcDisabledEntry = {
449
+ tier: 'europepmc',
450
+ outcome: 'not-attempted',
451
+ detail: 'EUROPEPMC_ENABLED=false',
452
+ };
453
+ for (const c of pmidFallbackCandidates)
454
+ chainByInput.get(c.pmid)?.push(epmcDisabledEntry);
455
+ for (const c of pmcidFallbackCandidates) {
456
+ const prefixed = withPmcPrefix(c.pmcid);
457
+ chainByInput.get(pmcidToPmid.get(prefixed) ?? prefixed)?.push(epmcDisabledEntry);
458
+ }
459
+ for (const c of doiCandidates)
460
+ chainByInput.get(c.doi)?.push(epmcDisabledEntry);
461
+ }
462
+ else {
463
+ for (const [pmid, outcome] of epmcOutcomes.pmidOutcomes) {
464
+ if (outcome.kind === 'hit') {
465
+ recoveredIds.add(pmid);
466
+ continue;
467
+ }
468
+ chainByInput.get(pmid)?.push(epmcTierFromOutcome(outcome));
261
469
  }
262
- if (input.maxSections !== undefined) {
263
- parsed = parsed.map((a) => ({ ...a, sections: a.sections.slice(0, input.maxSections) }));
470
+ for (const [prefixed, outcome] of epmcOutcomes.pmcidOutcomes) {
471
+ const inputId = pmcidToPmid.get(prefixed) ?? prefixed;
472
+ if (outcome.kind === 'hit') {
473
+ recoveredIds.add(inputId);
474
+ continue;
475
+ }
476
+ chainByInput.get(inputId)?.push(epmcTierFromOutcome(outcome));
264
477
  }
265
- if (!input.includeReferences) {
266
- parsed = parsed.map(({ references: _, ...rest }) => rest);
478
+ for (const [doi, outcome] of epmcOutcomes.doiOutcomes) {
479
+ if (outcome.kind === 'hit') {
480
+ recoveredIds.add(doi);
481
+ continue;
482
+ }
483
+ chainByInput.get(doi)?.push(epmcTierFromOutcome(outcome));
267
484
  }
268
- pmcArticles = parsed.map((a) => ({ source: 'pmc', ...a }));
269
- const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId));
270
- const missing = pmcIds.map((id) => `PMC${id}`).filter((id) => !returnedPmcIds.has(id));
271
- if (missing.length > 0)
272
- unavailablePmcIds = missing;
273
485
  }
274
- // ── Unpaywall fallback for PMIDs not in PMC ─────────────────────────────
486
+ pmidFallbackCandidates = epmcOutcomes.remainingPmid;
487
+ pmcidFallbackCandidates = epmcOutcomes.remainingPmcid;
488
+ doiCandidates = epmcOutcomes.remainingDoi;
489
+ // ── Stage 3: Unpaywall fallback ─────────────────────────────────────────
275
490
  const unpaywall = getUnpaywallService();
276
- const unavailable = [];
277
491
  const fallbackArticles = [];
278
- if (fallbackCandidates.length > 0) {
492
+ // PMC misses on `pmcids` input don't get an Unpaywall attempt — the current
493
+ // implementation doesn't resolve PMCID → DOI for that branch.
494
+ for (const c of pmcidFallbackCandidates) {
495
+ const prefixed = withPmcPrefix(c.pmcid);
496
+ chainByInput.get(pmcidToPmid.get(prefixed) ?? prefixed)?.push({
497
+ tier: 'unpaywall',
498
+ outcome: 'not-attempted',
499
+ detail: 'pmcids input does not resolve a DOI for Unpaywall',
500
+ });
501
+ }
502
+ if (pmidFallbackCandidates.length > 0) {
503
+ // The PMC ID Converter only returns DOIs for articles it has in PMC, so
504
+ // candidates here are missing DOIs by default. Pull them from PubMed
505
+ // metadata (db=pubmed) before dispatching to Unpaywall.
506
+ const needDoi = pmidFallbackCandidates.filter((c) => !c.doi).map((c) => c.pmid);
507
+ if (needDoi.length > 0) {
508
+ try {
509
+ const doiMap = await fetchPubmedDois(needDoi, ctx.signal);
510
+ pmidFallbackCandidates = pmidFallbackCandidates.map((c) => {
511
+ if (c.doi)
512
+ return c;
513
+ const doi = doiMap.get(c.pmid);
514
+ return doi ? { ...c, doi } : c;
515
+ });
516
+ }
517
+ catch (error) {
518
+ ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
519
+ error: error instanceof Error ? error.message : String(error),
520
+ pmidCount: needDoi.length,
521
+ });
522
+ }
523
+ }
279
524
  if (!unpaywall) {
280
- for (const c of fallbackCandidates) {
281
- unavailable.push({
282
- pmid: c.pmid,
283
- reason: 'no-pmc-fallback-disabled',
284
- detail: 'Article not in PMC and UNPAYWALL_EMAIL is not set',
525
+ for (const c of pmidFallbackCandidates) {
526
+ chainByInput.get(c.pmid)?.push({
527
+ tier: 'unpaywall',
528
+ outcome: 'not-attempted',
529
+ detail: 'UNPAYWALL_EMAIL is not set',
285
530
  });
286
531
  }
287
532
  }
288
533
  else {
289
- // The PMC ID Converter only returns DOIs for articles it has in PMC, so
290
- // candidates here are missing DOIs by default. Pull them from PubMed
291
- // metadata (db=pubmed) before dispatching to Unpaywall.
292
- const needDoi = fallbackCandidates.filter((c) => !c.doi).map((c) => c.pmid);
293
- if (needDoi.length > 0) {
294
- try {
295
- const doiMap = await fetchPubmedDois(needDoi, ctx.signal);
296
- fallbackCandidates = fallbackCandidates.map((c) => {
297
- if (c.doi)
298
- return c;
299
- const doi = doiMap.get(c.pmid);
300
- return doi ? { ...c, doi } : c;
301
- });
534
+ const outcomes = await Promise.all(pmidFallbackCandidates.map(async (candidate) => ({
535
+ candidate,
536
+ result: candidate.doi
537
+ ? await resolveUnpaywall({ pmid: candidate.pmid, doi: candidate.doi }, unpaywall, ctx)
538
+ : { unavailable: { reason: 'no-doi' } },
539
+ })));
540
+ for (const { candidate, result } of outcomes) {
541
+ if ('article' in result) {
542
+ fallbackArticles.push(result.article);
543
+ recoveredIds.add(candidate.pmid);
302
544
  }
303
- catch (error) {
304
- ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
305
- error: error instanceof Error ? error.message : String(error),
306
- pmidCount: needDoi.length,
545
+ else {
546
+ const u = result.unavailable;
547
+ chainByInput.get(candidate.pmid)?.push({
548
+ tier: 'unpaywall',
549
+ outcome: unpaywallReasonToTierOutcome(u.reason),
550
+ ...(u.detail && { detail: u.detail }),
307
551
  });
308
552
  }
309
553
  }
310
- const outcomes = await Promise.allSettled(fallbackCandidates.map((c) => resolveViaUnpaywall(c, ctx)));
311
- for (const outcome of outcomes) {
312
- if (outcome.status === 'fulfilled') {
313
- if ('article' in outcome.value)
314
- fallbackArticles.push(outcome.value.article);
315
- else
316
- unavailable.push(outcome.value.unavailable);
554
+ }
555
+ }
556
+ if (doiCandidates.length > 0) {
557
+ if (!unpaywall) {
558
+ for (const c of doiCandidates) {
559
+ chainByInput.get(c.doi)?.push({
560
+ tier: 'unpaywall',
561
+ outcome: 'not-attempted',
562
+ detail: 'UNPAYWALL_EMAIL is not set',
563
+ });
564
+ }
565
+ }
566
+ else {
567
+ // `resolveUnpaywall` catches its own failures so this Promise.all
568
+ // doesn't reject under normal operation.
569
+ const outcomes = await Promise.all(doiCandidates.map(async (c) => ({
570
+ doi: c.doi,
571
+ result: await resolveUnpaywall({ doi: c.doi }, unpaywall, ctx),
572
+ })));
573
+ for (const { doi, result } of outcomes) {
574
+ if ('article' in result) {
575
+ fallbackArticles.push(result.article);
576
+ recoveredIds.add(doi);
317
577
  }
318
578
  else {
319
- ctx.log.warning('Unpaywall fallback crashed unexpectedly', {
320
- error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
579
+ const u = result.unavailable;
580
+ chainByInput.get(doi)?.push({
581
+ tier: 'unpaywall',
582
+ outcome: unpaywallReasonToTierOutcome(u.reason),
583
+ ...(u.detail && { detail: u.detail }),
321
584
  });
322
585
  }
323
586
  }
324
587
  }
325
588
  }
589
+ // ── Assemble unavailable[] from chains ──────────────────────────────────
590
+ const unavailable = [];
591
+ for (const [id, chain] of chainByInput) {
592
+ if (recoveredIds.has(id))
593
+ continue;
594
+ unavailable.push({
595
+ id,
596
+ idType,
597
+ reason: reasonFromChain(chain),
598
+ triedTiers: chain,
599
+ });
600
+ }
326
601
  const articles = [...pmcArticles, ...fallbackArticles];
327
602
  ctx.log.info('pubmed_fetch_fulltext completed', {
328
- requested: (input.pmids ?? input.pmcids)?.length ?? 0,
603
+ requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
329
604
  returned: articles.length,
330
- pmcHits: pmcArticles.length,
605
+ pmcHits: pmcArticles.filter((a) => a.viaSource === 'pmc').length,
606
+ epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
331
607
  unpaywallHits: fallbackArticles.length,
332
608
  unavailable: unavailable.length,
333
609
  });
@@ -335,22 +611,23 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
335
611
  articles,
336
612
  totalReturned: articles.length,
337
613
  ...(unavailable.length > 0 && { unavailable }),
338
- ...(unavailablePmcIds && { unavailablePmcIds }),
339
614
  };
340
615
  },
341
616
  format: (result) => {
342
617
  const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
343
618
  if (result.unavailable?.length) {
344
- lines.push(`\n**Unavailable PMIDs (${result.unavailable.length}):**`);
619
+ lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
345
620
  for (const u of result.unavailable) {
346
- lines.push(`- ${u.pmid} ${u.reason}${u.detail ? `: ${u.detail}` : ''}`);
621
+ lines.push(`- [${u.idType}] ${u.id} ${u.reason}`);
622
+ const chain = u.triedTiers
623
+ .map((t) => `${t.tier}:${t.outcome}${t.detail ? ` (${t.detail})` : ''}`)
624
+ .join(' → ');
625
+ if (chain)
626
+ lines.push(` chain: ${chain}`);
347
627
  }
348
628
  }
349
- if (result.unavailablePmcIds?.length) {
350
- lines.push(`**Unavailable PMC IDs:** ${result.unavailablePmcIds.join(', ')}`);
351
- }
352
629
  if (result.totalReturned === 0) {
353
- lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC (or recoverable via Unpaywall) to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
630
+ 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\`.`);
354
631
  }
355
632
  for (const a of result.articles) {
356
633
  lines.push('');
@@ -362,6 +639,170 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
362
639
  return [{ type: 'text', text: lines.join('\n') }];
363
640
  },
364
641
  });
642
+ /**
643
+ * Run the Europe PMC step against everything that fell through PMC EFetch
644
+ * plus any direct DOI input. Each candidate goes through search-by-best-id →
645
+ * fullTextXML. Hits become `source: 'pmc'` articles with `viaSource: 'europepmc'`;
646
+ * misses flow through to the Unpaywall stage unchanged.
647
+ *
648
+ * Candidates run in parallel — the EPMC request queue caps concurrency so this
649
+ * stays polite without serializing. Errors are caught and logged inside the
650
+ * helpers; a transient EPMC failure must not block the downstream Unpaywall
651
+ * fallback.
652
+ */
653
+ async function runEpmcStage(epmc, args) {
654
+ const runOne = async (c, query, contextPmid) => {
655
+ const search = await searchEpmcSafe(epmc, query, args.ctx);
656
+ if (search.kind === 'error') {
657
+ return { c, outcome: { kind: 'service-error', detail: search.detail } };
658
+ }
659
+ if (search.kind === 'miss')
660
+ return { c, outcome: { kind: 'miss' } };
661
+ const fetched = await fetchEpmcArticle(epmc, search.hit, args, contextPmid);
662
+ if (fetched.kind === 'error') {
663
+ return { c, outcome: { kind: 'service-error', detail: fetched.detail } };
664
+ }
665
+ if (fetched.kind === 'no-fulltext') {
666
+ return {
667
+ c,
668
+ outcome: { kind: 'no-fulltext', ...(fetched.detail && { detail: fetched.detail }) },
669
+ };
670
+ }
671
+ return { c, outcome: { kind: 'hit' }, article: fetched.article };
672
+ };
673
+ const fetchForPmid = (c) => runOne(c, `EXT_ID:"${c.pmid}" AND SRC:MED`, c.pmid);
674
+ const fetchForPmcid = (c) => {
675
+ const normalized = withPmcPrefix(c.pmcid);
676
+ return runOne({ c, normalized }, `PMCID:"${normalized}" AND SRC:PMC`, undefined);
677
+ };
678
+ const fetchForDoi = (c) => runOne(c, `DOI:"${c.doi}"`, undefined);
679
+ const [pmidResults, pmcidResults, doiResults] = await Promise.all([
680
+ Promise.all(args.pmidFallbackCandidates.map(fetchForPmid)),
681
+ Promise.all(args.pmcidFallbackCandidates.map(fetchForPmcid)),
682
+ Promise.all(args.doiCandidates.map(fetchForDoi)),
683
+ ]);
684
+ const articles = [];
685
+ const remainingPmid = [];
686
+ const remainingPmcid = [];
687
+ const remainingDoi = [];
688
+ const pmidOutcomes = new Map();
689
+ const pmcidOutcomes = new Map();
690
+ const doiOutcomes = new Map();
691
+ for (const { c, outcome, article } of pmidResults) {
692
+ pmidOutcomes.set(c.pmid, outcome);
693
+ if (article)
694
+ articles.push(article);
695
+ else
696
+ remainingPmid.push(c);
697
+ }
698
+ for (const { c: pair, outcome, article } of pmcidResults) {
699
+ pmcidOutcomes.set(pair.normalized, outcome);
700
+ if (article)
701
+ articles.push(article);
702
+ else
703
+ remainingPmcid.push(pair.c);
704
+ }
705
+ for (const { c, outcome, article } of doiResults) {
706
+ doiOutcomes.set(c.doi, outcome);
707
+ if (article)
708
+ articles.push(article);
709
+ else
710
+ remainingDoi.push(c);
711
+ }
712
+ return {
713
+ articles,
714
+ remainingPmid,
715
+ remainingPmcid,
716
+ remainingDoi,
717
+ pmidOutcomes,
718
+ pmcidOutcomes,
719
+ doiOutcomes,
720
+ };
721
+ }
722
+ /**
723
+ * Single-hit Europe PMC search with discriminated outcomes so the chain can
724
+ * record `miss` vs `service-error` separately. Errors are logged and swallowed
725
+ * so transient EPMC failures fall through to the next stage instead of
726
+ * aborting the chain.
727
+ */
728
+ async function searchEpmcSafe(epmc, query, ctx) {
729
+ try {
730
+ const result = await epmc.search({
731
+ query,
732
+ resultType: 'core',
733
+ pageSize: 1,
734
+ ...(ctx.signal && { signal: ctx.signal }),
735
+ });
736
+ return result.hits[0] ? { kind: 'hit', hit: result.hits[0] } : { kind: 'miss' };
737
+ }
738
+ catch (error) {
739
+ const detail = error instanceof Error ? error.message : String(error);
740
+ ctx.log.warning('Europe PMC search failed; chain continues with next layer', {
741
+ query,
742
+ error: detail,
743
+ });
744
+ return { kind: 'error', detail };
745
+ }
746
+ }
747
+ /**
748
+ * Fetch and parse the JATS for an EPMC hit. Returns a discriminated outcome so
749
+ * the chain can record `no-fulltext` (record exists but EPMC publishes no JATS)
750
+ * separately from `service-error` (transient failure). Preprints/patents and
751
+ * MED-only records without a PMC counterpart short-circuit to `no-fulltext`
752
+ * since EPMC's fullTextXML endpoint is PMC-keyed.
753
+ */
754
+ async function fetchEpmcArticle(epmc, hit, args, contextPmid) {
755
+ // EPMC's fullTextXML endpoint is PMC-keyed (URL: `/{PMC<digits>}/fullTextXML`).
756
+ // For PMC-source hits, `hit.id` already is the PMC ID; for MED hits, `hit.pmcid`
757
+ // carries the counterpart when one exists. Preprints (PPR) and patents (PAT)
758
+ // have no PMC ID, so fullTextXML is never available.
759
+ const pmcLookupId = hit.pmcid ?? (hit.source === 'PMC' ? hit.id : undefined);
760
+ if (!pmcLookupId) {
761
+ return { kind: 'no-fulltext', detail: `EPMC source ${hit.source} has no PMC counterpart` };
762
+ }
763
+ try {
764
+ const result = await epmc.fullTextXml(pmcLookupId, hit.source, args.ctx.signal ?? undefined);
765
+ if (result.kind === 'not-available') {
766
+ return { kind: 'no-fulltext', detail: 'EPMC fullTextXML not available for this record' };
767
+ }
768
+ const articleNode = epmc.parseFullTextXml(result.xml);
769
+ if (!articleNode) {
770
+ return { kind: 'no-fulltext', detail: 'EPMC fullTextXML payload had no <article> element' };
771
+ }
772
+ const parsed = applyPmcFilters(parsePmcArticle(articleNode), args.input);
773
+ // `parsePmcArticle` always returns string fields (sometimes empty). Strip
774
+ // empty `pmcId`/`pmcUrl` for EPMC-only records (preprints) so the schema's
775
+ // optional shape is respected — agents read `epmcId`/`epmcSource` for those.
776
+ const { pmcId, pmcUrl, ...rest } = parsed;
777
+ const pmid = rest.pmid ?? hit.pmid ?? contextPmid;
778
+ const doi = rest.doi ?? hit.doi;
779
+ return {
780
+ kind: 'article',
781
+ article: {
782
+ source: 'pmc',
783
+ viaSource: 'europepmc',
784
+ ...rest,
785
+ ...(pmcId && { pmcId, pmcUrl }),
786
+ ...(pmid && {
787
+ pmid,
788
+ pubmedUrl: rest.pubmedUrl ?? `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
789
+ }),
790
+ ...(doi && { doi }),
791
+ epmcId: hit.id,
792
+ epmcSource: hit.source,
793
+ },
794
+ };
795
+ }
796
+ catch (error) {
797
+ const detail = error instanceof Error ? error.message : String(error);
798
+ args.ctx.log.warning('Europe PMC fullTextXML failed; chain continues with next layer', {
799
+ epmcId: hit.id,
800
+ source: hit.source,
801
+ error: detail,
802
+ });
803
+ return { kind: 'error', detail };
804
+ }
805
+ }
365
806
  /**
366
807
  * Batch-fetch DOIs from PubMed metadata for PMIDs that lack one after the PMC
367
808
  * ID Converter roundtrip. The Converter only returns DOIs for articles already
@@ -389,23 +830,24 @@ async function fetchPubmedDois(pmids, signal) {
389
830
  }
390
831
  return out;
391
832
  }
392
- async function resolveViaUnpaywall(candidate, ctx) {
393
- const { pmid, doi } = candidate;
394
- const service = getUnpaywallService();
395
- if (!doi)
396
- return { unavailable: { pmid, reason: 'no-doi' } };
397
- if (!service)
398
- return { unavailable: { pmid, reason: 'no-pmc-fallback-disabled' } };
833
+ /**
834
+ * Resolve a DOI to an open-access article via Unpaywall. `pmid`, when set,
835
+ * is stamped onto the resulting article so the pmid-input branch carries its
836
+ * cross-reference through.
837
+ */
838
+ async function resolveUnpaywall(args, service, ctx) {
839
+ const { pmid, doi } = args;
399
840
  let resolution;
400
841
  try {
401
842
  resolution = await service.resolve(doi, ctx.signal);
402
843
  }
403
844
  catch (error) {
404
845
  const detail = error instanceof Error ? error.message : String(error);
405
- return { unavailable: { pmid, reason: 'service-error', detail } };
846
+ ctx.log.warning('Unpaywall DOI resolve failed', { doi, error: detail });
847
+ return { unavailable: { reason: 'service-error', detail } };
406
848
  }
407
849
  if (resolution.kind === 'no-oa') {
408
- return { unavailable: { pmid, reason: 'no-oa', detail: resolution.reason } };
850
+ return { unavailable: { reason: 'no-oa', detail: resolution.reason } };
409
851
  }
410
852
  let content;
411
853
  try {
@@ -413,7 +855,8 @@ async function resolveViaUnpaywall(candidate, ctx) {
413
855
  }
414
856
  catch (error) {
415
857
  const detail = error instanceof Error ? error.message : String(error);
416
- return { unavailable: { pmid, reason: 'fetch-failed', detail } };
858
+ ctx.log.warning('Unpaywall content fetch failed', { doi, error: detail });
859
+ return { unavailable: { reason: 'fetch-failed', detail } };
417
860
  }
418
861
  try {
419
862
  if (content.kind === 'html') {
@@ -425,7 +868,6 @@ async function resolveViaUnpaywall(candidate, ctx) {
425
868
  if (!body) {
426
869
  return {
427
870
  unavailable: {
428
- pmid,
429
871
  reason: 'parse-failed',
430
872
  detail: 'HTML extraction produced empty content',
431
873
  },
@@ -433,7 +875,7 @@ async function resolveViaUnpaywall(candidate, ctx) {
433
875
  }
434
876
  return {
435
877
  article: buildUnpaywallArticle({
436
- pmid,
878
+ ...(pmid && { pmid }),
437
879
  doi,
438
880
  sourceUrl: content.fetchedUrl,
439
881
  location: resolution.location,
@@ -448,16 +890,12 @@ async function resolveViaUnpaywall(candidate, ctx) {
448
890
  const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
449
891
  if (!text) {
450
892
  return {
451
- unavailable: {
452
- pmid,
453
- reason: 'parse-failed',
454
- detail: 'PDF extraction produced empty text',
455
- },
893
+ unavailable: { reason: 'parse-failed', detail: 'PDF extraction produced empty text' },
456
894
  };
457
895
  }
458
896
  return {
459
897
  article: buildUnpaywallArticle({
460
- pmid,
898
+ ...(pmid && { pmid }),
461
899
  doi,
462
900
  sourceUrl: content.fetchedUrl,
463
901
  location: resolution.location,
@@ -470,16 +908,19 @@ async function resolveViaUnpaywall(candidate, ctx) {
470
908
  catch (error) {
471
909
  const detail = error instanceof Error ? error.message : String(error);
472
910
  ctx.log.warning('Unpaywall content extraction failed', { pmid, doi, detail });
473
- return { unavailable: { pmid, reason: 'parse-failed', detail } };
911
+ return { unavailable: { reason: 'parse-failed', detail } };
474
912
  }
475
913
  }
476
914
  function buildUnpaywallArticle(args) {
477
915
  const { location } = args;
478
916
  return {
479
917
  source: 'unpaywall',
918
+ viaSource: 'unpaywall',
480
919
  contentFormat: args.contentFormat,
481
- pmid: args.pmid,
482
- pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
920
+ ...(args.pmid && {
921
+ pmid: args.pmid,
922
+ pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
923
+ }),
483
924
  doi: args.doi,
484
925
  sourceUrl: args.sourceUrl,
485
926
  content: args.content,
@@ -491,10 +932,93 @@ function buildUnpaywallArticle(args) {
491
932
  ...(location.version && { version: location.version }),
492
933
  };
493
934
  }
935
+ /**
936
+ * Convert an EPMC stage outcome into the `triedTiers` entry stored on
937
+ * `chainByInput`. `hit` is filtered before calling — the chain only records
938
+ * failure outcomes since recovered ids never appear in `unavailable[]`.
939
+ */
940
+ function epmcTierFromOutcome(outcome) {
941
+ switch (outcome.kind) {
942
+ case 'miss':
943
+ return { tier: 'europepmc', outcome: 'miss' };
944
+ case 'no-fulltext':
945
+ return {
946
+ tier: 'europepmc',
947
+ outcome: 'no-fulltext',
948
+ ...(outcome.detail && { detail: outcome.detail }),
949
+ };
950
+ case 'service-error':
951
+ return { tier: 'europepmc', outcome: 'service-error', detail: outcome.detail };
952
+ }
953
+ }
954
+ /**
955
+ * Map an Unpaywall-resolver `UnavailableReason` to its `TierOutcome`
956
+ * counterpart. The two enums overlap on the values the Unpaywall path can
957
+ * actually emit (`no-doi`, `no-oa`, `fetch-failed`, `parse-failed`,
958
+ * `service-error`). Defensive branches cover values the resolver returns under
959
+ * dead-code safety checks but never in normal flow.
960
+ */
961
+ function unpaywallReasonToTierOutcome(reason) {
962
+ switch (reason) {
963
+ case 'no-doi':
964
+ case 'no-oa':
965
+ case 'fetch-failed':
966
+ case 'parse-failed':
967
+ case 'service-error':
968
+ return reason;
969
+ case 'no-pmc-fallback-disabled':
970
+ return 'not-attempted';
971
+ case 'no-epmc-fulltext':
972
+ return 'no-fulltext';
973
+ case 'not-found':
974
+ return 'miss';
975
+ }
976
+ }
977
+ /**
978
+ * Derive the terminal `reason` shown on the unavailable entry from its chain.
979
+ * Skips `not-attempted` entries when summarizing — those record config state,
980
+ * not content state, so they make a misleading `reason` when an earlier tier
981
+ * produced a real signal (`pmc:miss`, `unpaywall:no-oa`, etc.). Only when every
982
+ * tier was skipped does `reason` fall back to `no-pmc-fallback-disabled`.
983
+ */
984
+ function reasonFromChain(chain) {
985
+ let lastSignal;
986
+ for (const t of chain) {
987
+ if (t.outcome !== 'not-attempted')
988
+ lastSignal = t;
989
+ }
990
+ if (!lastSignal)
991
+ return 'no-pmc-fallback-disabled';
992
+ const key = `${lastSignal.tier}:${lastSignal.outcome}`;
993
+ switch (key) {
994
+ case 'pmc:miss':
995
+ case 'europepmc:miss':
996
+ return 'not-found';
997
+ case 'europepmc:no-fulltext':
998
+ return 'no-epmc-fulltext';
999
+ case 'unpaywall:no-doi':
1000
+ return 'no-doi';
1001
+ case 'unpaywall:no-oa':
1002
+ return 'no-oa';
1003
+ case 'unpaywall:fetch-failed':
1004
+ return 'fetch-failed';
1005
+ case 'unpaywall:parse-failed':
1006
+ return 'parse-failed';
1007
+ case 'pmc:service-error':
1008
+ case 'unpaywall:service-error':
1009
+ case 'europepmc:service-error':
1010
+ return 'service-error';
1011
+ default:
1012
+ return 'not-found';
1013
+ }
1014
+ }
494
1015
  // ─── format() helpers ────────────────────────────────────────────────────────
495
1016
  function formatPmcArticle(a, lines) {
496
1017
  lines.push(`### ${a.title ?? a.pmcId}`);
497
- lines.push(`**Source:** PMC (structured JATS)`);
1018
+ const sourceLabel = a.viaSource === 'europepmc'
1019
+ ? `Europe PMC (structured JATS${a.epmcSource ? `, source: ${a.epmcSource}` : ''})`
1020
+ : 'PMC (structured JATS)';
1021
+ lines.push(`**Source:** ${sourceLabel}`);
498
1022
  if (a.authors?.length) {
499
1023
  lines.push(`\n**Authors (${a.authors.length}):**`);
500
1024
  for (const au of a.authors)
@@ -526,12 +1050,16 @@ function formatPmcArticle(a, lines) {
526
1050
  if (dateParts.length)
527
1051
  lines.push(`**Published:** ${dateParts.join('-')}`);
528
1052
  }
529
- lines.push(`**PMCID:** ${a.pmcId}`);
1053
+ if (a.pmcId)
1054
+ lines.push(`**PMCID:** ${a.pmcId}`);
1055
+ if (a.epmcId)
1056
+ lines.push(`**EPMC ID:** ${a.epmcId}${a.epmcSource ? ` (${a.epmcSource})` : ''}`);
530
1057
  if (a.pmid)
531
1058
  lines.push(`**PMID:** ${a.pmid}`);
532
1059
  if (a.doi)
533
1060
  lines.push(`**DOI:** ${a.doi}`);
534
- lines.push(`**PMC:** ${a.pmcUrl}`);
1061
+ if (a.pmcUrl)
1062
+ lines.push(`**PMC:** ${a.pmcUrl}`);
535
1063
  if (a.pubmedUrl)
536
1064
  lines.push(`**PubMed:** ${a.pubmedUrl}`);
537
1065
  if (a.keywords?.length)
@@ -561,15 +1089,17 @@ function formatPmcArticle(a, lines) {
561
1089
  }
562
1090
  }
563
1091
  function formatUnpaywallArticle(a, lines) {
564
- const heading = a.title ?? `PMID ${a.pmid}`;
1092
+ const heading = a.title ?? (a.pmid ? `PMID ${a.pmid}` : `DOI ${a.doi}`);
565
1093
  const formatLabel = a.contentFormat === 'html-markdown'
566
1094
  ? 'Unpaywall (HTML → Markdown, best-effort)'
567
1095
  : 'Unpaywall (PDF → plain text)';
568
1096
  lines.push(`### ${heading}`);
569
1097
  lines.push(`**Source:** ${formatLabel}`);
570
- lines.push(`**PMID:** ${a.pmid}`);
1098
+ if (a.pmid)
1099
+ lines.push(`**PMID:** ${a.pmid}`);
571
1100
  lines.push(`**DOI:** ${a.doi}`);
572
- lines.push(`**PubMed:** ${a.pubmedUrl}`);
1101
+ if (a.pubmedUrl)
1102
+ lines.push(`**PubMed:** ${a.pubmedUrl}`);
573
1103
  lines.push(`**OA Copy:** ${a.sourceUrl}`);
574
1104
  if (a.license)
575
1105
  lines.push(`**License:** ${a.license}`);