@cyanheads/pubmed-mcp-server 2.6.12 → 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 (41) hide show
  1. package/README.md +33 -12
  2. package/dist/config/server-config.d.ts +5 -0
  3. package/dist/config/server-config.d.ts.map +1 -1
  4. package/dist/config/server-config.js +45 -0
  5. package/dist/config/server-config.js.map +1 -1
  6. package/dist/index.js +19 -12
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +71 -23
  9. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +671 -141
  11. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +87 -0
  13. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -0
  14. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +217 -0
  15. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -0
  16. package/dist/services/error-contracts.d.ts +18 -1
  17. package/dist/services/error-contracts.d.ts.map +1 -1
  18. package/dist/services/error-contracts.js +21 -4
  19. package/dist/services/error-contracts.js.map +1 -1
  20. package/dist/services/europe-pmc/api-client.d.ts +47 -0
  21. package/dist/services/europe-pmc/api-client.d.ts.map +1 -0
  22. package/dist/services/europe-pmc/api-client.js +123 -0
  23. package/dist/services/europe-pmc/api-client.js.map +1 -0
  24. package/dist/services/europe-pmc/europe-pmc-service.d.ts +71 -0
  25. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -0
  26. package/dist/services/europe-pmc/europe-pmc-service.js +243 -0
  27. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -0
  28. package/dist/services/europe-pmc/request-queue.d.ts +33 -0
  29. package/dist/services/europe-pmc/request-queue.d.ts.map +1 -0
  30. package/dist/services/europe-pmc/request-queue.js +107 -0
  31. package/dist/services/europe-pmc/request-queue.js.map +1 -0
  32. package/dist/services/europe-pmc/types.d.ts +123 -0
  33. package/dist/services/europe-pmc/types.d.ts.map +1 -0
  34. package/dist/services/europe-pmc/types.js +16 -0
  35. package/dist/services/europe-pmc/types.js.map +1 -0
  36. package/dist/services/ncbi/response-handler.js +1 -1
  37. package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
  38. package/dist/services/unpaywall/unpaywall-service.js +14 -13
  39. package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
  40. package/package.json +5 -4
  41. package/server.json +4 -4
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs,
3
+ * injects the optional contact email, and exposes single-attempt search and
4
+ * fullTextXML calls. Retry logic lives in `EuropePmcService`.
5
+ * @module src/services/europe-pmc/api-client
6
+ */
7
+ import { McpError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
8
+ import { fetchWithTimeout, httpErrorFromResponse, logger, requestContextService, } from '@cyanheads/mcp-ts-core/utils';
9
+ import { recoveryFor } from '../../services/error-contracts.js';
10
+ import { EUROPEPMC_API_BASE } from './types.js';
11
+ const USER_AGENT = 'pubmed-mcp-server (+https://github.com/cyanheads/pubmed-mcp-server)';
12
+ /** Low-level HTTP client for Europe PMC. Single-attempt — retries upstream. */
13
+ export class EuropePmcApiClient {
14
+ config;
15
+ constructor(config) {
16
+ this.config = config;
17
+ }
18
+ /**
19
+ * Execute a search. Returns the raw JSON response body as a string so
20
+ * `EuropePmcService` can parse and surface SerializationError consistently
21
+ * when the body is malformed.
22
+ */
23
+ async search(params) {
24
+ const url = this.buildSearchUrl(params);
25
+ const ctx = requestContextService.createRequestContext({
26
+ operation: 'EuropePmcSearch',
27
+ query: params.query,
28
+ });
29
+ let response;
30
+ try {
31
+ response = await fetchWithTimeout(url, this.config.timeoutMs, ctx, {
32
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
33
+ ...(params.signal && { signal: params.signal }),
34
+ });
35
+ }
36
+ catch (error) {
37
+ if (error instanceof McpError)
38
+ throw error;
39
+ const msg = error instanceof Error ? error.message : String(error);
40
+ throw serviceUnavailable(`Europe PMC search request failed: ${msg}`, { reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') }, { cause: error });
41
+ }
42
+ if (!response.ok) {
43
+ throw await httpErrorFromResponse(response, {
44
+ service: 'Europe PMC',
45
+ data: { url, reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') },
46
+ });
47
+ }
48
+ return response.text();
49
+ }
50
+ /**
51
+ * Fetch the JATS full-text XML for an EPMC record by its internal id.
52
+ * Returns `{ kind: 'not-available' }` for 404 — EPMC has the record but
53
+ * doesn't publish a full-text XML for it (very common for preprints).
54
+ */
55
+ async fullTextXml(epmcId, signal) {
56
+ const url = `${EUROPEPMC_API_BASE}/${encodeURIComponent(epmcId)}/fullTextXML`;
57
+ const ctx = requestContextService.createRequestContext({
58
+ operation: 'EuropePmcFullTextXml',
59
+ epmcId,
60
+ });
61
+ let response;
62
+ try {
63
+ response = await fetchWithTimeout(url, this.config.timeoutMs, ctx, {
64
+ headers: {
65
+ Accept: 'application/xml, text/xml, */*;q=0.5',
66
+ 'User-Agent': USER_AGENT,
67
+ },
68
+ ...(signal && { signal }),
69
+ });
70
+ }
71
+ catch (error) {
72
+ if (error instanceof McpError)
73
+ throw error;
74
+ const msg = error instanceof Error ? error.message : String(error);
75
+ throw serviceUnavailable(`Europe PMC fullTextXML request failed: ${msg}`, { reason: 'europepmc_unreachable', epmcId, ...recoveryFor('europepmc_unreachable') }, { cause: error });
76
+ }
77
+ if (response.status === 404) {
78
+ return { kind: 'not-available', reason: 'EPMC has no fullTextXML for this record' };
79
+ }
80
+ if (!response.ok) {
81
+ throw await httpErrorFromResponse(response, {
82
+ service: 'Europe PMC fullTextXML',
83
+ data: { epmcId, reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') },
84
+ });
85
+ }
86
+ const xml = await response.text();
87
+ if (!xml.trim()) {
88
+ logger.debug('Europe PMC returned an empty fullTextXML body.', requestContextService.createRequestContext({
89
+ operation: 'EuropePmcFullTextXmlEmpty',
90
+ epmcId,
91
+ }));
92
+ return { kind: 'not-available', reason: 'EPMC returned an empty fullTextXML body' };
93
+ }
94
+ return { kind: 'found', xml };
95
+ }
96
+ buildSearchUrl(params) {
97
+ const finalParams = {
98
+ query: this.buildQueryString(params),
99
+ format: 'json',
100
+ resultType: params.resultType ?? 'core',
101
+ pageSize: String(params.pageSize ?? 25),
102
+ cursorMark: params.cursorMark ?? '*',
103
+ };
104
+ if (params.sort)
105
+ finalParams.sort = params.sort;
106
+ if (this.config.email)
107
+ finalParams.email = this.config.email;
108
+ return `${EUROPEPMC_API_BASE}/search?${new URLSearchParams(finalParams).toString()}`;
109
+ }
110
+ /**
111
+ * Combine the caller's query with an optional source filter. EPMC's query
112
+ * syntax supports `SRC:"X"` field tokens — we OR-join the requested sources
113
+ * into a parenthesized clause and AND it with the user's query.
114
+ */
115
+ buildQueryString(params) {
116
+ const base = params.query.trim();
117
+ if (!params.sources || params.sources.length === 0)
118
+ return base;
119
+ const sourceClause = params.sources.map((s) => `SRC:"${s}"`).join(' OR ');
120
+ return `(${base}) AND (${sourceClause})`;
121
+ }
122
+ }
123
+ //# sourceMappingURL=api-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-client.js","sourceRoot":"","sources":["../../../src/services/europe-pmc/api-client.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAC7E,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,MAAM,EACN,qBAAqB,GACtB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAA8B,MAAM,YAAY,CAAC;AAE5E,MAAM,UAAU,GAAG,qEAAqE,CAAC;AAezF,+EAA+E;AAC/E,MAAM,OAAO,kBAAkB;IACA;IAA7B,YAA6B,MAAgC;QAAhC,WAAM,GAAN,MAAM,CAA0B;IAAG,CAAC;IAEjE;;;;OAIG;IACH,KAAK,CAAC,MAAM,CAAC,MAA6B;QACxC,MAAM,GAAG,GAAG,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;QACxC,MAAM,GAAG,GAAG,qBAAqB,CAAC,oBAAoB,CAAC;YACrD,SAAS,EAAE,iBAAiB;YAC5B,KAAK,EAAE,MAAM,CAAC,KAAK;SACpB,CAAC,CAAC;QAEH,IAAI,QAAkB,CAAC;QACvB,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,GAAG,EAAE;gBACjE,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE,YAAY,EAAE,UAAU,EAAE;gBACjE,GAAG,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aAChD,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,IAAI,KAAK,YAAY,QAAQ;gBAAE,MAAM,KAAK,CAAC;YAC3C,MAAM,GAAG,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACnE,MAAM,kBAAkB,CACtB,qCAAqC,GAAG,EAAE,EAC1C,EAAE,MAAM,EAAE,uBAAuB,EAAE,GAAG,WAAW,CAAC,uBAAuB,CAAC,EAAE,EAC5E,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAC;QACJ,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,MAAM,qBAAqB,CAAC,QAAQ,EAAE;gBAC1C,OAAO,EAAE,YAAY;gBACrB,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,uBAAuB,EAAE,GAAG,WAAW,CAAC,uBAAuB,CAAC,EAAE;aACxF,CAAC,CAAC;QACL,CAAC;QAED,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAC;IACzB,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAoB;QACpD,MAAM,GAAG,GAAG,GAAG,kBAAkB,IAAI,kBAAkB,CAAC,MAAM,CAAC,cAAc,CAAC;QAC9E,MAAM,GAAG,GAAG,qBAAqB,CAAC,oBAAoB,CAAC;YACrD,SAAS,EAAE,sBAAsB;YACjC,MAAM;SACP,CAAC,CAAC;QAEH,IAAI,QAAkB,CAAC;QACvB,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,GAAG,EAAE;gBACjE,OAAO,EAAE;oBACP,MAAM,EAAE,sCAAsC;oBAC9C,YAAY,EAAE,UAAU;iBACzB;gBACD,GAAG,CAAC,MAAM,IAAI,EAAE,MAAM,EAAE,CAAC;aAC1B,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,IAAI,KAAK,YAAY,QAAQ;gBAAE,MAAM,KAAK,CAAC;YAC3C,MAAM,GAAG,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACnE,MAAM,kBAAkB,CACtB,0CAA0C,GAAG,EAAE,EAC/C,EAAE,MAAM,EAAE,uBAAuB,EAAE,MAAM,EAAE,GAAG,WAAW,CAAC,uBAAuB,CAAC,EAAE,EACpF,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAC;QACJ,CAAC;QAED,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YAC5B,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,yCAAyC,EAAE,CAAC;QACtF,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,MAAM,qBAAqB,CAAC,QAAQ,EAAE;gBAC1C,OAAO,EAAE,wBAAwB;gBACjC,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,uBAAuB,EAAE,GAAG,WAAW,CAAC,uBAAuB,CAAC,EAAE;aAC3F,CAAC,CAAC;QACL,CAAC;QAED,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAClC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;YAChB,MAAM,CAAC,KAAK,CACV,gDAAgD,EAChD,qBAAqB,CAAC,oBAAoB,CAAC;gBACzC,SAAS,EAAE,2BAA2B;gBACtC,MAAM;aACP,CAAC,CACH,CAAC;YACF,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,yCAAyC,EAAE,CAAC;QACtF,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC;IAChC,CAAC;IAEO,cAAc,CAAC,MAA6B;QAClD,MAAM,WAAW,GAA2B;YAC1C,KAAK,EAAE,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC;YACpC,MAAM,EAAE,MAAM;YACd,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,MAAM;YACvC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;YACvC,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,GAAG;SACrC,CAAC;QACF,IAAI,MAAM,CAAC,IAAI;YAAE,WAAW,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;QAChD,IAAI,IAAI,CAAC,MAAM,CAAC,KAAK;YAAE,WAAW,CAAC,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC;QAE7D,OAAO,GAAG,kBAAkB,WAAW,IAAI,eAAe,CAAC,WAAW,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;IACvF,CAAC;IAED;;;;OAIG;IACK,gBAAgB,CAAC,MAA6B;QACpD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACjC,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAChE,MAAM,YAAY,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1E,OAAO,IAAI,IAAI,UAAU,YAAY,GAAG,CAAC;IAC3C,CAAC;CACF"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @fileoverview Europe PMC service. Wraps the EPMC REST API with rate-limiting,
3
+ * retries, and JATS XML parsing. Two methods: `search()` for keyword discovery
4
+ * across the EPMC corpus (MED/PMC/PPR/PAT/AGR) and `fullTextXml()` for fetching
5
+ * a record's full-text JATS. The XML parser matches NCBI's ordered config so
6
+ * `parsePmcArticle` consumes the result without modification.
7
+ *
8
+ * Optional service: only constructed when `EUROPEPMC_ENABLED=true` (the
9
+ * default). `getEuropePmcService()` returns `undefined` when disabled so
10
+ * callers can skip the chain step gracefully.
11
+ *
12
+ * @module src/services/europe-pmc/europe-pmc-service
13
+ */
14
+ import type { JatsNode } from '../../services/ncbi/parsing/pmc-xml-helpers.js';
15
+ import { EuropePmcApiClient } from './api-client.js';
16
+ import { EuropePmcRequestQueue } from './request-queue.js';
17
+ import type { EuropePmcFullTextResult, EuropePmcSearchParams, EuropePmcSearchResult, EuropePmcSource } from './types.js';
18
+ /**
19
+ * Facade over the Europe PMC REST API. Two methods:
20
+ * - `search()` — keyword search across MED/PMC/PPR/PAT/AGR.
21
+ * - `fullTextXml()` — JATS full text for an EPMC record.
22
+ *
23
+ * Both honor `ctx.signal` for cancellation and retry transient failures with
24
+ * capped exponential backoff plus jitter.
25
+ */
26
+ export declare class EuropePmcService {
27
+ private readonly client;
28
+ private readonly queue;
29
+ private readonly maxRetries;
30
+ private readonly orderedXmlParser;
31
+ constructor(client: EuropePmcApiClient, queue: EuropePmcRequestQueue, maxRetries: number);
32
+ /**
33
+ * Search Europe PMC. Cursor-based pagination — pass `cursorMark: '*'` (or
34
+ * omit) for the first page; pass the returned `nextCursorMark` for the next.
35
+ */
36
+ search(params: EuropePmcSearchParams): Promise<EuropePmcSearchResult>;
37
+ /**
38
+ * Fetch the JATS full text for an EPMC record. Returns:
39
+ * - `{ kind: 'found', xml, epmcId, source }` — JATS XML string usable
40
+ * directly by tool callers that hold their own parser, or via
41
+ * `parseFullTextXml()` for the parsed tree.
42
+ * - `{ kind: 'not-available', reason }` — EPMC has the record but
43
+ * publishes no fullTextXML (404 or empty body).
44
+ */
45
+ fullTextXml(epmcId: string, source: EuropePmcSource, signal?: AbortSignal): Promise<EuropePmcFullTextResult>;
46
+ /**
47
+ * Parse a JATS XML string into the ordered node tree consumed by
48
+ * `parsePmcArticle`. Returns the `<article>` JatsNode, or `undefined` when
49
+ * the body doesn't contain an article element (malformed / empty).
50
+ *
51
+ * Throws `SerializationError` only for fundamentally invalid XML; an
52
+ * article-free but well-formed body returns `undefined` so callers can
53
+ * surface a `no-epmc-fulltext` outcome without a hard failure.
54
+ */
55
+ parseFullTextXml(xml: string): JatsNode | undefined;
56
+ /**
57
+ * Retry wrapper for transient errors. Mirrors NCBI's `withRetry` minus the
58
+ * service-level deadline — EPMC requests are cheaper individually and the
59
+ * caller (typically `ctx.signal`) bounds the total chain.
60
+ */
61
+ private withRetry;
62
+ }
63
+ /**
64
+ * Initialize the Europe PMC service when enabled. Safe to call regardless of
65
+ * config — `EUROPEPMC_ENABLED=false` leaves the service unset so callers see
66
+ * `undefined` and skip the chain step.
67
+ */
68
+ export declare function initEuropePmcService(): void;
69
+ /** Returns the initialized service, or `undefined` when EPMC is disabled. */
70
+ export declare function getEuropePmcService(): EuropePmcService | undefined;
71
+ //# sourceMappingURL=europe-pmc-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"europe-pmc-service.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/europe-pmc-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAgB,MAAM,4CAA4C,CAAC;AAEzF,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EACV,uBAAuB,EAEvB,qBAAqB,EAErB,qBAAqB,EACrB,eAAe,EAChB,MAAM,YAAY,CAAC;AA2BpB;;;;;;;GAOG;AACH,qBAAa,gBAAgB;IAIzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAL7B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAY;gBAG1B,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,qBAAqB,EAC5B,UAAU,EAAE,MAAM;IAmBrC;;;OAGG;IACG,MAAM,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IA0C3E;;;;;;;OAOG;IACG,WAAW,CACf,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,uBAAuB,CAAC;IAkBnC;;;;;;;;OAQG;IACH,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS;IAgCnD;;;;OAIG;YACW,SAAS;CAsDxB;AAMD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAuB3C;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,SAAS,CAElE"}
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @fileoverview Europe PMC service. Wraps the EPMC REST API with rate-limiting,
3
+ * retries, and JATS XML parsing. Two methods: `search()` for keyword discovery
4
+ * across the EPMC corpus (MED/PMC/PPR/PAT/AGR) and `fullTextXml()` for fetching
5
+ * a record's full-text JATS. The XML parser matches NCBI's ordered config so
6
+ * `parsePmcArticle` consumes the result without modification.
7
+ *
8
+ * Optional service: only constructed when `EUROPEPMC_ENABLED=true` (the
9
+ * default). `getEuropePmcService()` returns `undefined` when disabled so
10
+ * callers can skip the chain step gracefully.
11
+ *
12
+ * @module src/services/europe-pmc/europe-pmc-service
13
+ */
14
+ import { internalError, JsonRpcErrorCode, McpError, serializationError, } from '@cyanheads/mcp-ts-core/errors';
15
+ import { logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
16
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
17
+ import { getServerConfig } from '../../config/server-config.js';
18
+ import { recoveryFor } from '../../services/error-contracts.js';
19
+ import { ensureArray } from '../../services/ncbi/parsing/xml-helpers.js';
20
+ import { EuropePmcApiClient } from './api-client.js';
21
+ import { EuropePmcRequestQueue } from './request-queue.js';
22
+ /** Retryable transient codes — same set NCBI uses. */
23
+ const RETRYABLE_CODES = new Set([
24
+ JsonRpcErrorCode.ServiceUnavailable,
25
+ JsonRpcErrorCode.Timeout,
26
+ JsonRpcErrorCode.RateLimited,
27
+ ]);
28
+ const MAX_BACKOFF_MS = 30_000;
29
+ function abortableSleep(ms, signal) {
30
+ if (!signal)
31
+ return new Promise((r) => setTimeout(r, ms));
32
+ if (signal.aborted)
33
+ return Promise.reject(signal.reason);
34
+ return new Promise((resolve, reject) => {
35
+ const onAbort = () => {
36
+ clearTimeout(timer);
37
+ reject(signal.reason);
38
+ };
39
+ const timer = setTimeout(() => {
40
+ signal.removeEventListener('abort', onAbort);
41
+ resolve();
42
+ }, ms);
43
+ signal.addEventListener('abort', onAbort, { once: true });
44
+ });
45
+ }
46
+ /**
47
+ * Facade over the Europe PMC REST API. Two methods:
48
+ * - `search()` — keyword search across MED/PMC/PPR/PAT/AGR.
49
+ * - `fullTextXml()` — JATS full text for an EPMC record.
50
+ *
51
+ * Both honor `ctx.signal` for cancellation and retry transient failures with
52
+ * capped exponential backoff plus jitter.
53
+ */
54
+ export class EuropePmcService {
55
+ client;
56
+ queue;
57
+ maxRetries;
58
+ orderedXmlParser;
59
+ constructor(client, queue, maxRetries) {
60
+ this.client = client;
61
+ this.queue = queue;
62
+ this.maxRetries = maxRetries;
63
+ /**
64
+ * EPMC's fullTextXML is JATS Z39.96 — same DTD PMC uses — so the parser
65
+ * config mirrors `NcbiResponseHandler.orderedXmlParser`. `preserveOrder`
66
+ * keeps inline mixed content readable; `trimValues: false` retains spaces
67
+ * between text and inline children.
68
+ */
69
+ this.orderedXmlParser = new XMLParser({
70
+ preserveOrder: true,
71
+ ignoreAttributes: false,
72
+ attributeNamePrefix: '@_',
73
+ parseTagValue: true,
74
+ trimValues: false,
75
+ processEntities: true,
76
+ htmlEntities: true,
77
+ });
78
+ }
79
+ /**
80
+ * Search Europe PMC. Cursor-based pagination — pass `cursorMark: '*'` (or
81
+ * omit) for the first page; pass the returned `nextCursorMark` for the next.
82
+ */
83
+ async search(params) {
84
+ const text = await this.queue.enqueue(() => this.withRetry(() => this.client.search(params), 'search', params.signal), 'search', params.signal);
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(text);
88
+ }
89
+ catch (error) {
90
+ throw serializationError('Failed to parse Europe PMC search JSON response.', {
91
+ reason: 'europepmc_invalid_response',
92
+ responseSnippet: text.substring(0, 200),
93
+ ...recoveryFor('europepmc_invalid_response'),
94
+ }, { cause: error });
95
+ }
96
+ const hits = ensureArray(parsed.resultList?.result);
97
+ const echoed = parsed.request?.queryString ?? params.query;
98
+ // EPMC echoes back the input cursor mark on the final page, so absence of
99
+ // an explicit "next" or equality with the request's cursor marks the end.
100
+ const cursorMark = parsed.request?.cursorMark ?? params.cursorMark ?? '*';
101
+ const nextCursor = parsed.nextCursorMark && parsed.nextCursorMark !== cursorMark
102
+ ? parsed.nextCursorMark
103
+ : undefined;
104
+ return {
105
+ hits,
106
+ hitCount: parsed.hitCount ?? hits.length,
107
+ ...(nextCursor && { nextCursorMark: nextCursor }),
108
+ cursorMark,
109
+ query: echoed,
110
+ };
111
+ }
112
+ /**
113
+ * Fetch the JATS full text for an EPMC record. Returns:
114
+ * - `{ kind: 'found', xml, epmcId, source }` — JATS XML string usable
115
+ * directly by tool callers that hold their own parser, or via
116
+ * `parseFullTextXml()` for the parsed tree.
117
+ * - `{ kind: 'not-available', reason }` — EPMC has the record but
118
+ * publishes no fullTextXML (404 or empty body).
119
+ */
120
+ async fullTextXml(epmcId, source, signal) {
121
+ const outcome = await this.queue.enqueue(() => this.withRetry(() => this.client.fullTextXml(epmcId, signal), `fullTextXml(${epmcId})`, signal), `fullTextXml(${epmcId})`, signal);
122
+ if (outcome.kind === 'not-available') {
123
+ return { kind: 'not-available', reason: outcome.reason };
124
+ }
125
+ return { kind: 'found', xml: outcome.xml, epmcId, source };
126
+ }
127
+ /**
128
+ * Parse a JATS XML string into the ordered node tree consumed by
129
+ * `parsePmcArticle`. Returns the `<article>` JatsNode, or `undefined` when
130
+ * the body doesn't contain an article element (malformed / empty).
131
+ *
132
+ * Throws `SerializationError` only for fundamentally invalid XML; an
133
+ * article-free but well-formed body returns `undefined` so callers can
134
+ * surface a `no-epmc-fulltext` outcome without a hard failure.
135
+ */
136
+ parseFullTextXml(xml) {
137
+ const validationResult = XMLValidator.validate(xml.replace(/<!DOCTYPE[^>]*>/gi, ''));
138
+ if (validationResult !== true) {
139
+ throw serializationError('Received invalid XML from Europe PMC.', {
140
+ reason: 'europepmc_invalid_response',
141
+ responseSnippet: xml.substring(0, 200),
142
+ ...recoveryFor('europepmc_invalid_response'),
143
+ });
144
+ }
145
+ let parsed;
146
+ try {
147
+ parsed = this.orderedXmlParser.parse(xml);
148
+ }
149
+ catch (error) {
150
+ const parserError = error instanceof Error ? error.message : String(error);
151
+ throw serializationError(`Failed to parse Europe PMC fullTextXML response: ${parserError}`, {
152
+ reason: 'europepmc_invalid_response',
153
+ parserError,
154
+ responseSnippet: xml.substring(0, 200),
155
+ ...recoveryFor('europepmc_invalid_response'),
156
+ }, { cause: error });
157
+ }
158
+ if (!Array.isArray(parsed))
159
+ return;
160
+ const nodes = parsed;
161
+ return nodes.find((n) => 'article' in n);
162
+ }
163
+ /**
164
+ * Retry wrapper for transient errors. Mirrors NCBI's `withRetry` minus the
165
+ * service-level deadline — EPMC requests are cheaper individually and the
166
+ * caller (typically `ctx.signal`) bounds the total chain.
167
+ */
168
+ async withRetry(execute, label, signal) {
169
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
170
+ if (signal?.aborted)
171
+ throw signal.reason;
172
+ try {
173
+ return await execute();
174
+ }
175
+ catch (error) {
176
+ if (signal?.aborted)
177
+ throw signal.reason;
178
+ if (!(error instanceof McpError))
179
+ throw error;
180
+ if (!RETRYABLE_CODES.has(error.code))
181
+ throw error;
182
+ if (attempt < this.maxRetries) {
183
+ const baseDelay = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS);
184
+ const jitter = baseDelay * (0.75 + 0.5 * Math.random());
185
+ const retryDelay = Math.round(jitter);
186
+ logger.warning(`Europe PMC ${label} failed. Retrying (${attempt + 1}/${this.maxRetries}) in ${retryDelay}ms.`, requestContextService.createRequestContext({
187
+ operation: 'EuropePmcRetry',
188
+ label,
189
+ attempt: attempt + 1,
190
+ retryDelay,
191
+ }));
192
+ await abortableSleep(retryDelay, signal);
193
+ continue;
194
+ }
195
+ const attempts = this.maxRetries + 1;
196
+ const msg = error instanceof Error ? error.message : String(error);
197
+ throw new McpError(error.code, `${msg} (failed after ${attempts} attempts)`, {
198
+ reason: 'europepmc_unreachable',
199
+ label,
200
+ attempts,
201
+ ...recoveryFor('europepmc_unreachable'),
202
+ }, { cause: error });
203
+ }
204
+ }
205
+ throw internalError('Europe PMC request failed after all retries.', {
206
+ reason: 'europepmc_unreachable',
207
+ label,
208
+ ...recoveryFor('europepmc_unreachable'),
209
+ });
210
+ }
211
+ }
212
+ // ─── Init / Accessor ────────────────────────────────────────────────────────
213
+ let _service;
214
+ /**
215
+ * Initialize the Europe PMC service when enabled. Safe to call regardless of
216
+ * config — `EUROPEPMC_ENABLED=false` leaves the service unset so callers see
217
+ * `undefined` and skip the chain step.
218
+ */
219
+ export function initEuropePmcService() {
220
+ const config = getServerConfig();
221
+ if (!config.europepmcEnabled) {
222
+ logger.info('Europe PMC service disabled (EUROPEPMC_ENABLED=false).');
223
+ return;
224
+ }
225
+ const client = new EuropePmcApiClient({
226
+ timeoutMs: config.europepmcTimeoutMs,
227
+ ...(config.europepmcEmail && { email: config.europepmcEmail }),
228
+ });
229
+ const queue = new EuropePmcRequestQueue(config.europepmcRequestDelayMs);
230
+ _service = new EuropePmcService(client, queue, config.europepmcMaxRetries);
231
+ logger.info('Europe PMC service initialized.', requestContextService.createRequestContext({
232
+ operation: 'EuropePmcInit',
233
+ requestDelayMs: config.europepmcRequestDelayMs,
234
+ maxRetries: config.europepmcMaxRetries,
235
+ timeoutMs: config.europepmcTimeoutMs,
236
+ hasEmail: !!config.europepmcEmail,
237
+ }));
238
+ }
239
+ /** Returns the initialized service, or `undefined` when EPMC is disabled. */
240
+ export function getEuropePmcService() {
241
+ return _service;
242
+ }
243
+ //# sourceMappingURL=europe-pmc-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"europe-pmc-service.js","sourceRoot":"","sources":["../../../src/services/europe-pmc/europe-pmc-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @fileoverview Rate-limited request scheduler for Europe PMC calls. Caps
3
+ * concurrent in-flight requests and enforces a minimum start-gap between
4
+ * dispatches to stay polite with EBI's infrastructure. Independent rate
5
+ * domain from NCBI's queue — Europe PMC runs on a different host with its
6
+ * own limits.
7
+ * @module src/services/europe-pmc/request-queue
8
+ */
9
+ /**
10
+ * Schedules Europe PMC requests against two independent ceilings:
11
+ *
12
+ * - **Throughput** (`minStartGapMs`): minimum delay between two consecutive
13
+ * dispatch times. Defaults to 200ms to be polite with EBI.
14
+ * - **Concurrency** (`maxConcurrent`): maximum simultaneous in-flight
15
+ * requests. Decouples concurrency from rate so slow upstream responses
16
+ * don't block new dispatches.
17
+ *
18
+ * Enqueue accepts an optional `AbortSignal` so callers can bound their total
19
+ * time inside the scheduler — when the signal fires, a still-waiting task
20
+ * rejects immediately instead of sitting behind a saturated worker.
21
+ */
22
+ export declare class EuropePmcRequestQueue {
23
+ private readonly waiters;
24
+ private readonly minStartGapMs;
25
+ private readonly maxConcurrent;
26
+ private inFlight;
27
+ private lastStartTime;
28
+ private nextDispatchTimer;
29
+ constructor(minStartGapMs: number, maxConcurrent?: number);
30
+ enqueue<T>(task: () => Promise<T>, label: string, signal?: AbortSignal): Promise<T>;
31
+ private tryDispatch;
32
+ }
33
+ //# sourceMappingURL=request-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-queue.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/request-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAeH;;;;;;;;;;;;GAYG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,iBAAiB,CAA4C;gBAEzD,aAAa,EAAE,MAAM,EAAE,aAAa,GAAE,MAA+B;IAKjF,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,CAAC,CAAC;IA+BnF,OAAO,CAAC,WAAW;CAoDpB"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @fileoverview Rate-limited request scheduler for Europe PMC calls. Caps
3
+ * concurrent in-flight requests and enforces a minimum start-gap between
4
+ * dispatches to stay polite with EBI's infrastructure. Independent rate
5
+ * domain from NCBI's queue — Europe PMC runs on a different host with its
6
+ * own limits.
7
+ * @module src/services/europe-pmc/request-queue
8
+ */
9
+ import { logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
10
+ const DEFAULT_MAX_CONCURRENT = 4;
11
+ /**
12
+ * Schedules Europe PMC requests against two independent ceilings:
13
+ *
14
+ * - **Throughput** (`minStartGapMs`): minimum delay between two consecutive
15
+ * dispatch times. Defaults to 200ms to be polite with EBI.
16
+ * - **Concurrency** (`maxConcurrent`): maximum simultaneous in-flight
17
+ * requests. Decouples concurrency from rate so slow upstream responses
18
+ * don't block new dispatches.
19
+ *
20
+ * Enqueue accepts an optional `AbortSignal` so callers can bound their total
21
+ * time inside the scheduler — when the signal fires, a still-waiting task
22
+ * rejects immediately instead of sitting behind a saturated worker.
23
+ */
24
+ export class EuropePmcRequestQueue {
25
+ waiters = [];
26
+ minStartGapMs;
27
+ maxConcurrent;
28
+ inFlight = 0;
29
+ lastStartTime = 0;
30
+ nextDispatchTimer;
31
+ constructor(minStartGapMs, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
32
+ this.minStartGapMs = minStartGapMs;
33
+ this.maxConcurrent = maxConcurrent;
34
+ }
35
+ enqueue(task, label, signal) {
36
+ return new Promise((resolve, reject) => {
37
+ if (signal?.aborted) {
38
+ reject(signal.reason);
39
+ return;
40
+ }
41
+ const waiter = {
42
+ resolve,
43
+ reject,
44
+ task,
45
+ label,
46
+ ...(signal && { signal }),
47
+ };
48
+ if (signal) {
49
+ const onAbort = () => {
50
+ const idx = this.waiters.indexOf(waiter);
51
+ if (idx === -1)
52
+ return;
53
+ this.waiters.splice(idx, 1);
54
+ reject(signal.reason);
55
+ };
56
+ waiter.onAbort = onAbort;
57
+ signal.addEventListener('abort', onAbort, { once: true });
58
+ }
59
+ this.waiters.push(waiter);
60
+ this.tryDispatch();
61
+ });
62
+ }
63
+ tryDispatch() {
64
+ if (this.nextDispatchTimer !== undefined)
65
+ return;
66
+ while (this.inFlight < this.maxConcurrent && this.waiters.length > 0) {
67
+ const now = Date.now();
68
+ const gap = this.minStartGapMs - (now - this.lastStartTime);
69
+ if (gap > 0) {
70
+ this.nextDispatchTimer = setTimeout(() => {
71
+ this.nextDispatchTimer = undefined;
72
+ this.tryDispatch();
73
+ }, gap);
74
+ return;
75
+ }
76
+ const waiter = this.waiters.shift();
77
+ if (!waiter)
78
+ return;
79
+ if (waiter.signal && waiter.onAbort) {
80
+ waiter.signal.removeEventListener('abort', waiter.onAbort);
81
+ }
82
+ this.lastStartTime = Date.now();
83
+ this.inFlight += 1;
84
+ logger.debug(`Executing Europe PMC request via queue: ${waiter.label}`, requestContextService.createRequestContext({
85
+ operation: 'EuropePmcQueueDispatch',
86
+ label: waiter.label,
87
+ inFlight: this.inFlight,
88
+ queueDepth: this.waiters.length,
89
+ }));
90
+ Promise.resolve()
91
+ .then(() => waiter.task())
92
+ .then(waiter.resolve, (err) => {
93
+ logger.error('Error processing Europe PMC request from queue.', requestContextService.createRequestContext({
94
+ operation: 'EuropePmcQueueProcess',
95
+ label: waiter.label,
96
+ errorMessage: err instanceof Error ? err.message : String(err),
97
+ }));
98
+ waiter.reject(err);
99
+ })
100
+ .finally(() => {
101
+ this.inFlight -= 1;
102
+ this.tryDispatch();
103
+ });
104
+ }
105
+ }
106
+ }
107
+ //# sourceMappingURL=request-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-queue.js","sourceRoot":"","sources":["../../../src/services/europe-pmc/request-queue.ts"],"names":[],"mappings":"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"}