@cyanheads/pubmed-mcp-server 2.10.14 → 2.10.16
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 +4 -4
- package/CLAUDE.md +4 -4
- package/README.md +24 -15
- package/changelog/2.10.x/2.10.14.md +1 -0
- package/changelog/2.10.x/2.10.15.md +28 -0
- package/changelog/2.10.x/2.10.16.md +26 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts +11 -1
- package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_schemas.js +13 -1
- package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +10 -2
- package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/convert-ids.tool.js +41 -9
- package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +10 -3
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +12 -4
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +16 -5
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +35 -12
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +7 -63
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +42 -25
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +10 -3
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +12 -4
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +18 -4
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +62 -41
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +10 -5
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +3 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -3
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +11 -5
- 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 +20 -11
- 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 +16 -5
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +71 -11
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +12 -6
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +4 -3
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +32 -15
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +32 -15
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts +24 -9
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +47 -18
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +22 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +102 -43
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +18 -24
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +111 -120
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/request-queue.d.ts +22 -30
- package/dist/services/ncbi/request-queue.d.ts.map +1 -1
- package/dist/services/ncbi/request-queue.js +29 -128
- package/dist/services/ncbi/request-queue.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +14 -1
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +65 -9
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/package.json +6 -6
- package/server.json +3 -3
|
@@ -12,22 +12,28 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Tool definitions import the contract arrays directly and spread them into
|
|
14
14
|
* their `errors: [...]` declarations to surface the failure modes to the LLM.
|
|
15
|
+
* Every service-array entry carries `thrownBy: 'service'` so the linter's
|
|
16
|
+
* `error-contract-unthrown` check skips it while still checking the handler's
|
|
17
|
+
* own reasons. Spread a service array only into a tool whose handler lets that
|
|
18
|
+
* service's errors propagate — a tool that catches them all never produces
|
|
19
|
+
* those reasons.
|
|
15
20
|
*
|
|
16
21
|
* @module src/services/error-contracts
|
|
17
22
|
*/
|
|
18
23
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
19
24
|
/**
|
|
20
|
-
* Failure modes the NCBI service layer can surface. Tools
|
|
21
|
-
* `getNcbiService()`
|
|
22
|
-
* declared contract matches what actually reaches the wire.
|
|
25
|
+
* Failure modes the NCBI service layer can surface. Tools whose handler lets
|
|
26
|
+
* `getNcbiService()` failures propagate spread these into their own `errors[]`
|
|
27
|
+
* so the declared contract matches what actually reaches the wire.
|
|
23
28
|
*/
|
|
24
29
|
export const NCBI_SERVICE_ERRORS = [
|
|
25
30
|
{
|
|
26
31
|
reason: 'queue_full',
|
|
27
32
|
code: JsonRpcErrorCode.RateLimited,
|
|
28
|
-
when: '
|
|
29
|
-
recovery: '
|
|
33
|
+
when: 'The local NCBI request queue shed the call — the queue is full, or the call cannot start before its total deadline (for example behind the cooldown that follows an NCBI 429).',
|
|
34
|
+
recovery: 'Wait the number of seconds in `retryAfter`, then retry; the NCBI request queue is saturated or cooling down after a rate limit.',
|
|
30
35
|
retryable: true,
|
|
36
|
+
thrownBy: 'service',
|
|
31
37
|
},
|
|
32
38
|
{
|
|
33
39
|
reason: 'ncbi_unreachable',
|
|
@@ -35,6 +41,7 @@ export const NCBI_SERVICE_ERRORS = [
|
|
|
35
41
|
when: 'NCBI E-utilities is unreachable after all retry attempts.',
|
|
36
42
|
recovery: 'Retry after a brief delay; NCBI was unreachable across all retry attempts.',
|
|
37
43
|
retryable: true,
|
|
44
|
+
thrownBy: 'service',
|
|
38
45
|
},
|
|
39
46
|
{
|
|
40
47
|
reason: 'ncbi_deadline_exceeded',
|
|
@@ -42,6 +49,7 @@ export const NCBI_SERVICE_ERRORS = [
|
|
|
42
49
|
when: 'Total request deadline expired before NCBI returned a response.',
|
|
43
50
|
recovery: 'Reduce batch size or retry; NCBI may be under temporary load.',
|
|
44
51
|
retryable: true,
|
|
52
|
+
thrownBy: 'service',
|
|
45
53
|
},
|
|
46
54
|
{
|
|
47
55
|
reason: 'ncbi_invalid_response',
|
|
@@ -49,6 +57,7 @@ export const NCBI_SERVICE_ERRORS = [
|
|
|
49
57
|
when: 'NCBI returned a body that could not be parsed (invalid XML/JSON).',
|
|
50
58
|
recovery: 'Retry the request; NCBI returned a malformed response that could not be parsed.',
|
|
51
59
|
retryable: true,
|
|
60
|
+
thrownBy: 'service',
|
|
52
61
|
},
|
|
53
62
|
{
|
|
54
63
|
reason: 'ncbi_resource_not_found',
|
|
@@ -56,6 +65,7 @@ export const NCBI_SERVICE_ERRORS = [
|
|
|
56
65
|
when: 'NCBI returned a structured "not found" error for the requested ID(s).',
|
|
57
66
|
recovery: 'Verify the ID exists in PubMed; the resource was not found in NCBI and retrying will not help.',
|
|
58
67
|
retryable: false,
|
|
68
|
+
thrownBy: 'service',
|
|
59
69
|
},
|
|
60
70
|
];
|
|
61
71
|
/**
|
|
@@ -73,8 +83,8 @@ export const NCBI_QUERY_INPUT_ERRORS = [
|
|
|
73
83
|
{
|
|
74
84
|
reason: 'blank_query',
|
|
75
85
|
code: JsonRpcErrorCode.ValidationError,
|
|
76
|
-
when: 'The query holds no search term once whitespace, and
|
|
77
|
-
recovery: 'Supply a nonblank search term; NCBI cannot search a blank term and retrying the same input will not help.',
|
|
86
|
+
when: 'The query holds no search term once whitespace, and anything the tool strips before searching, are removed — so NCBI would receive a blank term. pubmed_search_articles strips markup, bracketed field tags, and parentheses, so a bare field tag such as `[pdat]` or empty parentheses `()` count as blank there.',
|
|
87
|
+
recovery: 'Supply a nonblank search term — a bare field tag or empty parentheses carry none; NCBI cannot search a blank term and retrying the same input will not help.',
|
|
78
88
|
retryable: false,
|
|
79
89
|
},
|
|
80
90
|
];
|
|
@@ -109,12 +119,13 @@ export const UNPAYWALL_SERVICE_ERRORS = [
|
|
|
109
119
|
when: 'Unpaywall was unreachable when resolving a DOI or fetching content.',
|
|
110
120
|
recovery: 'Retry after a brief delay; Unpaywall was unreachable. The PMC source remains the primary path.',
|
|
111
121
|
retryable: true,
|
|
122
|
+
thrownBy: 'service',
|
|
112
123
|
},
|
|
113
124
|
];
|
|
114
125
|
/**
|
|
115
|
-
* Failure modes the OpenAlex service layer can surface. Tools
|
|
116
|
-
* `getOpenAlexService()` / `getOpenAlexServiceOptional()`
|
|
117
|
-
* these into their `errors[]`.
|
|
126
|
+
* Failure modes the OpenAlex service layer can surface. Tools whose handler
|
|
127
|
+
* lets `getOpenAlexService()` / `getOpenAlexServiceOptional()` failures
|
|
128
|
+
* propagate spread these into their `errors[]`.
|
|
118
129
|
*/
|
|
119
130
|
export const OPENALEX_SERVICE_ERRORS = [
|
|
120
131
|
{
|
|
@@ -123,6 +134,7 @@ export const OPENALEX_SERVICE_ERRORS = [
|
|
|
123
134
|
when: 'OpenAlex was unreachable after all retry attempts.',
|
|
124
135
|
recovery: 'Retry after a brief delay; OpenAlex was unreachable. NCBI and Europe PMC remain available.',
|
|
125
136
|
retryable: true,
|
|
137
|
+
thrownBy: 'service',
|
|
126
138
|
},
|
|
127
139
|
{
|
|
128
140
|
reason: 'openalex_invalid_response',
|
|
@@ -130,19 +142,22 @@ export const OPENALEX_SERVICE_ERRORS = [
|
|
|
130
142
|
when: 'OpenAlex returned a body that could not be parsed (invalid JSON).',
|
|
131
143
|
recovery: 'Retry the request; OpenAlex returned a malformed response that could not be parsed.',
|
|
132
144
|
retryable: true,
|
|
145
|
+
thrownBy: 'service',
|
|
133
146
|
},
|
|
134
147
|
];
|
|
135
148
|
/**
|
|
136
|
-
* Failure modes the Europe PMC service layer can surface. Tools
|
|
137
|
-
* `getEuropePmcService()`
|
|
149
|
+
* Failure modes the Europe PMC service layer can surface. Tools whose handler
|
|
150
|
+
* lets `getEuropePmcService()` failures propagate spread these into their
|
|
151
|
+
* `errors[]`.
|
|
138
152
|
*/
|
|
139
153
|
export const EUROPEPMC_SERVICE_ERRORS = [
|
|
140
154
|
{
|
|
141
155
|
reason: 'europepmc_unreachable',
|
|
142
156
|
code: JsonRpcErrorCode.ServiceUnavailable,
|
|
143
|
-
when: 'Europe PMC
|
|
157
|
+
when: 'Europe PMC failed on every retry attempt — unreachable, an HTTP 404 or 5xx other than a 504 timeout from its search endpoint, or an empty response with no results.',
|
|
144
158
|
recovery: 'Retry after a brief delay; Europe PMC was unreachable. NCBI PMC and Unpaywall remain available.',
|
|
145
159
|
retryable: true,
|
|
160
|
+
thrownBy: 'service',
|
|
146
161
|
},
|
|
147
162
|
{
|
|
148
163
|
reason: 'europepmc_invalid_response',
|
|
@@ -150,13 +165,15 @@ export const EUROPEPMC_SERVICE_ERRORS = [
|
|
|
150
165
|
when: 'Europe PMC returned a body that could not be parsed (invalid JSON or XML).',
|
|
151
166
|
recovery: 'Retry the request; Europe PMC returned a malformed response that could not be parsed.',
|
|
152
167
|
retryable: true,
|
|
168
|
+
thrownBy: 'service',
|
|
153
169
|
},
|
|
154
170
|
{
|
|
155
171
|
reason: 'europepmc_invalid_input',
|
|
156
172
|
code: JsonRpcErrorCode.ValidationError,
|
|
157
|
-
when: 'Europe PMC rejected the request input
|
|
158
|
-
recovery: 'Adjust the input —
|
|
173
|
+
when: 'Europe PMC rejected the request input — an error message such as an empty query, an empty response to a sort with an undocumented field or no asc/desc direction, or an empty response to a pagination cursor on every attempt.',
|
|
174
|
+
recovery: 'Adjust the input — the query, the sort, or the cursorMark — before retrying; the same input will be rejected again.',
|
|
159
175
|
retryable: false,
|
|
176
|
+
thrownBy: 'service',
|
|
160
177
|
},
|
|
161
178
|
];
|
|
162
179
|
const REASON_TO_RECOVERY = new Map([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-contracts.js","sourceRoot":"","sources":["../../src/services/error-contracts.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"error-contracts.js","sourceRoot":"","sources":["../../src/services/error-contracts.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs and
|
|
3
|
+
* the source-filtered search query (`buildSearchQuery`, shared with the service
|
|
4
|
+
* so it can report the query it sent), injects the optional contact email, and
|
|
5
|
+
* exposes single-attempt search, fullTextXML, and citation-link calls.
|
|
6
|
+
* Classifies each endpoint's HTTP failures — a failed search as
|
|
7
|
+
* `europepmc_unreachable`, a missing fullTextXML as `not-available` — while
|
|
8
|
+
* retry logic lives in `EuropePmcService`.
|
|
5
9
|
* @module src/services/europe-pmc/api-client
|
|
6
10
|
*/
|
|
7
11
|
import { type EuropePmcSearchParams } from './types.js';
|
|
@@ -36,6 +40,16 @@ export declare class EuropePmcApiClient {
|
|
|
36
40
|
* Execute a search. Returns the raw JSON response body as a string so
|
|
37
41
|
* `EuropePmcService` can parse and surface SerializationError consistently
|
|
38
42
|
* when the body is malformed.
|
|
43
|
+
*
|
|
44
|
+
* A failed `/search` is an outage, never "no match" — a genuine zero-hit
|
|
45
|
+
* query is HTTP 200 with `hitCount: 0`. A 404 is reclassified from its
|
|
46
|
+
* status-mapped `NotFound` to `ServiceUnavailable` so the service retries it.
|
|
47
|
+
* A retryable 5xx keeps its code, so a 504 stays `Timeout`. Only a failure
|
|
48
|
+
* that ends up `ServiceUnavailable` carries `europepmc_unreachable` and its
|
|
49
|
+
* recovery hint, the one code that reason is declared for. An upstream
|
|
50
|
+
* `retryAfter` is kept. A 429 and every other 4xx pass through unchanged, as
|
|
51
|
+
* does a 501, whose `data.retryable: false` keeps it out of the retry loop.
|
|
52
|
+
* (#152)
|
|
39
53
|
*/
|
|
40
54
|
search(params: EuropePmcSearchParams): Promise<string>;
|
|
41
55
|
/**
|
|
@@ -64,11 +78,12 @@ export declare class EuropePmcApiClient {
|
|
|
64
78
|
/** Helper: fetch a links (citations/references) JSON URL and return the body. */
|
|
65
79
|
private fetchLinksJson;
|
|
66
80
|
private buildSearchUrl;
|
|
67
|
-
/**
|
|
68
|
-
* Combine the caller's query with an optional source filter. EPMC's query
|
|
69
|
-
* syntax supports `SRC:"X"` field tokens — we OR-join the requested sources
|
|
70
|
-
* into a parenthesized clause and AND it with the user's query.
|
|
71
|
-
*/
|
|
72
|
-
private buildQueryString;
|
|
73
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The query string a search sends: the caller's query combined with an
|
|
84
|
+
* optional source filter. EPMC's query syntax supports `SRC:"X"` field tokens —
|
|
85
|
+
* the requested sources are OR-joined into a parenthesized clause and ANDed
|
|
86
|
+
* with the caller's query.
|
|
87
|
+
*/
|
|
88
|
+
export declare function buildSearchQuery(params: Pick<EuropePmcSearchParams, 'query' | 'sources'>): string;
|
|
74
89
|
//# sourceMappingURL=api-client.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/api-client.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/api-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,EAAsB,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAI5E,MAAM,WAAW,wBAAwB;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,MAAM,4BAA4B,GACpC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C;;;;;;;;GAQG;AACH,qBAAa,kBAAkB;IACjB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAnC,YAA6B,MAAM,EAAE,wBAAwB,EAAI;IAEjE;;;;;;;;;;;;;;OAcG;IACG,MAAM,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,CA4C3D;IAED;;;;;;;;;OASG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,4BAA4B,CAAC,CA4C7F;IAED;;;;OAIG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7F;IAED;;;;OAIG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAG9F;IAED,iFAAiF;YACnE,cAAc;IA8B5B,OAAO,CAAC,cAAc;CAavB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,qBAAqB,EAAE,OAAO,GAAG,SAAS,CAAC,GAAG,MAAM,CAKjG"}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs and
|
|
3
|
+
* the source-filtered search query (`buildSearchQuery`, shared with the service
|
|
4
|
+
* so it can report the query it sent), injects the optional contact email, and
|
|
5
|
+
* exposes single-attempt search, fullTextXML, and citation-link calls.
|
|
6
|
+
* Classifies each endpoint's HTTP failures — a failed search as
|
|
7
|
+
* `europepmc_unreachable`, a missing fullTextXML as `not-available` — while
|
|
8
|
+
* retry logic lives in `EuropePmcService`.
|
|
5
9
|
* @module src/services/europe-pmc/api-client
|
|
6
10
|
*/
|
|
7
11
|
import { JsonRpcErrorCode, McpError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
@@ -27,6 +31,16 @@ export class EuropePmcApiClient {
|
|
|
27
31
|
* Execute a search. Returns the raw JSON response body as a string so
|
|
28
32
|
* `EuropePmcService` can parse and surface SerializationError consistently
|
|
29
33
|
* when the body is malformed.
|
|
34
|
+
*
|
|
35
|
+
* A failed `/search` is an outage, never "no match" — a genuine zero-hit
|
|
36
|
+
* query is HTTP 200 with `hitCount: 0`. A 404 is reclassified from its
|
|
37
|
+
* status-mapped `NotFound` to `ServiceUnavailable` so the service retries it.
|
|
38
|
+
* A retryable 5xx keeps its code, so a 504 stays `Timeout`. Only a failure
|
|
39
|
+
* that ends up `ServiceUnavailable` carries `europepmc_unreachable` and its
|
|
40
|
+
* recovery hint, the one code that reason is declared for. An upstream
|
|
41
|
+
* `retryAfter` is kept. A 429 and every other 4xx pass through unchanged, as
|
|
42
|
+
* does a 501, whose `data.retryable: false` keeps it out of the retry loop.
|
|
43
|
+
* (#152)
|
|
30
44
|
*/
|
|
31
45
|
async search(params) {
|
|
32
46
|
const url = this.buildSearchUrl(params);
|
|
@@ -42,8 +56,22 @@ export class EuropePmcApiClient {
|
|
|
42
56
|
});
|
|
43
57
|
}
|
|
44
58
|
catch (error) {
|
|
45
|
-
if (error instanceof McpError)
|
|
46
|
-
|
|
59
|
+
if (error instanceof McpError) {
|
|
60
|
+
const status = error.data?.status;
|
|
61
|
+
const isOutage = status === 404 ||
|
|
62
|
+
(typeof status === 'number' && status >= 500 && error.data?.retryable !== false);
|
|
63
|
+
if (!isOutage)
|
|
64
|
+
throw error;
|
|
65
|
+
const code = status === 404 ? JsonRpcErrorCode.ServiceUnavailable : error.code;
|
|
66
|
+
throw new McpError(code, `Europe PMC search request failed: ${error.message}`, {
|
|
67
|
+
...(code === JsonRpcErrorCode.ServiceUnavailable && {
|
|
68
|
+
reason: 'europepmc_unreachable',
|
|
69
|
+
...recoveryFor('europepmc_unreachable'),
|
|
70
|
+
}),
|
|
71
|
+
status,
|
|
72
|
+
...(error.data?.retryAfter !== undefined && { retryAfter: error.data.retryAfter }),
|
|
73
|
+
}, { cause: error });
|
|
74
|
+
}
|
|
47
75
|
const msg = error instanceof Error ? error.message : String(error);
|
|
48
76
|
throw serviceUnavailable(`Europe PMC search request failed: ${msg}`, { reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') }, { cause: error });
|
|
49
77
|
}
|
|
@@ -137,7 +165,7 @@ export class EuropePmcApiClient {
|
|
|
137
165
|
}
|
|
138
166
|
buildSearchUrl(params) {
|
|
139
167
|
const finalParams = {
|
|
140
|
-
query:
|
|
168
|
+
query: buildSearchQuery(params),
|
|
141
169
|
format: 'json',
|
|
142
170
|
resultType: params.resultType ?? 'core',
|
|
143
171
|
pageSize: String(params.pageSize ?? 25),
|
|
@@ -149,17 +177,18 @@ export class EuropePmcApiClient {
|
|
|
149
177
|
finalParams.email = this.config.email;
|
|
150
178
|
return `${EUROPEPMC_API_BASE}/search?${new URLSearchParams(finalParams).toString()}`;
|
|
151
179
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The query string a search sends: the caller's query combined with an
|
|
183
|
+
* optional source filter. EPMC's query syntax supports `SRC:"X"` field tokens —
|
|
184
|
+
* the requested sources are OR-joined into a parenthesized clause and ANDed
|
|
185
|
+
* with the caller's query.
|
|
186
|
+
*/
|
|
187
|
+
export function buildSearchQuery(params) {
|
|
188
|
+
const base = params.query.trim();
|
|
189
|
+
if (!params.sources || params.sources.length === 0)
|
|
190
|
+
return base;
|
|
191
|
+
const sourceClause = params.sources.map((s) => `SRC:"${s}"`).join(' OR ');
|
|
192
|
+
return `(${base}) AND (${sourceClause})`;
|
|
164
193
|
}
|
|
165
194
|
//# sourceMappingURL=api-client.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../../../src/services/europe-pmc/api-client.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../../../src/services/europe-pmc/api-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* from the same `ORDERED_XML_PARSER_OPTIONS` NCBI's ordered parser uses, so
|
|
8
8
|
* `parsePmcArticle` consumes the result without modification.
|
|
9
9
|
*
|
|
10
|
+
* A search's retry boundary covers fetch and response classification, so
|
|
11
|
+
* Europe PMC's intermittent empty `{ version }` envelope is retried like an
|
|
12
|
+
* HTTP outage; only a sort or cursor that explains it is reported as bad input.
|
|
13
|
+
* A search reports the effective query Europe PMC echoes back, or — with no
|
|
14
|
+
* echo — the source-filtered query it sent.
|
|
15
|
+
*
|
|
10
16
|
* Optional service: only constructed when `EUROPEPMC_ENABLED=true` (the
|
|
11
17
|
* default). `getEuropePmcService()` returns `undefined` when disabled so
|
|
12
18
|
* callers can skip the chain step gracefully.
|
|
@@ -36,8 +42,18 @@ export declare class EuropePmcService {
|
|
|
36
42
|
/**
|
|
37
43
|
* Search Europe PMC. Cursor-based pagination — pass `cursorMark: '*'` (or
|
|
38
44
|
* omit) for the first page; pass the returned `nextCursorMark` for the next.
|
|
45
|
+
*
|
|
46
|
+
* The retry boundary covers the fetch and the response classification
|
|
47
|
+
* together, so an empty envelope retries like any other transient failure
|
|
48
|
+
* rather than surfacing after the loop has already returned. (#159)
|
|
39
49
|
*/
|
|
40
50
|
search(params: EuropePmcSearchParams): Promise<EuropePmcSearchResult>;
|
|
51
|
+
/**
|
|
52
|
+
* One search attempt: fetch, parse, and classify the body. `isLastAttempt`
|
|
53
|
+
* lets an empty envelope that has persisted through the whole retry budget
|
|
54
|
+
* be attributed to a caller-supplied cursor.
|
|
55
|
+
*/
|
|
56
|
+
private searchOnce;
|
|
41
57
|
/**
|
|
42
58
|
* Look up specific records by `source` + EPMC id. One search request covers
|
|
43
59
|
* the whole batch: each ref becomes a `recordLookupQuery` clause, OR-joined
|
|
@@ -103,7 +119,12 @@ export declare class EuropePmcService {
|
|
|
103
119
|
/**
|
|
104
120
|
* Retry wrapper for transient errors. Mirrors NCBI's `withRetry` minus the
|
|
105
121
|
* service-level deadline — EPMC requests are cheaper individually and the
|
|
106
|
-
* caller (typically `ctx.signal`) bounds the total chain.
|
|
122
|
+
* caller (typically `ctx.signal`) bounds the total chain. `execute` receives
|
|
123
|
+
* the zero-based attempt index. On exhaustion the last error keeps its code
|
|
124
|
+
* and an upstream `retryAfter`, so a 429 still tells the caller how long to
|
|
125
|
+
* wait. Only a `ServiceUnavailable` gains `europepmc_unreachable` and its hint;
|
|
126
|
+
* a `Timeout` or `RateLimited` keeps its code with no reason, as the NCBI
|
|
127
|
+
* service reports them.
|
|
107
128
|
*/
|
|
108
129
|
private withRetry;
|
|
109
130
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"europe-pmc-service.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/europe-pmc-service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"europe-pmc-service.d.ts","sourceRoot":"","sources":["../../../src/services/europe-pmc/europe-pmc-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAiBH,OAAO,KAAK,EAAE,QAAQ,EAAgB,MAAM,4CAA4C,CAAC;AAEzF,OAAO,EAAoB,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EACV,uBAAuB,EAEvB,kBAAkB,EAElB,sBAAsB,EACtB,kBAAkB,EAClB,qBAAqB,EAErB,qBAAqB,EACrB,eAAe,EAChB,MAAM,YAAY,CAAC;AAqEpB;;;;;;;;;GASG;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;IAE7C,YACmB,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,qBAAqB,EAC5B,UAAU,EAAE,MAAM,EAQpC;IAED;;;;;;;OAOG;IACH,MAAM,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAWpE;IAED;;;;OAIG;YACW,UAAU;IAwHxB;;;;;;;;;;OAUG;IACG,YAAY,CAChB,IAAI,EAAE,SAAS,kBAAkB,EAAE,EACnC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAQ/B;IAED;;;;;;;OAOG;IACG,WAAW,CACf,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,uBAAuB,CAAC,CAgBlC;IAED;;;;;;;;;;OAUG;IACH,SAAS,CACP,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,sBAAsB,CAAC,CAQjC;IAED;;;;;;;;;;OAUG;IACH,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,sBAAsB,CAAC,CAQjC;IAED;;;;;OAKG;YACW,iBAAiB;IAsE/B;;;;;;;;OAQG;IACH,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CA8BlD;IAED;;;;;;;;;OASG;YACW,SAAS;CAkDxB;AAMD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAyB3C;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,SAAS,CAElE"}
|
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
* from the same `ORDERED_XML_PARSER_OPTIONS` NCBI's ordered parser uses, so
|
|
8
8
|
* `parsePmcArticle` consumes the result without modification.
|
|
9
9
|
*
|
|
10
|
+
* A search's retry boundary covers fetch and response classification, so
|
|
11
|
+
* Europe PMC's intermittent empty `{ version }` envelope is retried like an
|
|
12
|
+
* HTTP outage; only a sort or cursor that explains it is reported as bad input.
|
|
13
|
+
* A search reports the effective query Europe PMC echoes back, or — with no
|
|
14
|
+
* echo — the source-filtered query it sent.
|
|
15
|
+
*
|
|
10
16
|
* Optional service: only constructed when `EUROPEPMC_ENABLED=true` (the
|
|
11
17
|
* default). `getEuropePmcService()` returns `undefined` when disabled so
|
|
12
18
|
* callers can skip the chain step gracefully.
|
|
13
19
|
*
|
|
14
20
|
* @module src/services/europe-pmc/europe-pmc-service
|
|
15
21
|
*/
|
|
16
|
-
import { internalError, McpError, serializationError, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
22
|
+
import { internalError, JsonRpcErrorCode, McpError, serializationError, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
17
23
|
import { defaultIsTransient, logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
|
|
18
24
|
// biome-ignore lint/suspicious/noDeprecatedImports: staying on in-tree XMLValidator — see ncbi/response-handler.ts
|
|
19
25
|
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
@@ -21,7 +27,7 @@ import { getServerConfig } from '../../config/server-config.js';
|
|
|
21
27
|
import { recoveryFor } from '../error-contracts.js';
|
|
22
28
|
import { ORDERED_XML_PARSER_OPTIONS } from '../ncbi/parsing/ordered-xml-parser-options.js';
|
|
23
29
|
import { ensureArray } from '../ncbi/parsing/xml-helpers.js';
|
|
24
|
-
import { EuropePmcApiClient } from './api-client.js';
|
|
30
|
+
import { buildSearchQuery, EuropePmcApiClient } from './api-client.js';
|
|
25
31
|
import { EuropePmcRequestQueue } from './request-queue.js';
|
|
26
32
|
const MAX_BACKOFF_MS = 30_000;
|
|
27
33
|
function abortableSleep(ms, signal) {
|
|
@@ -63,6 +69,28 @@ function recordLookupQuery({ epmcId, source }) {
|
|
|
63
69
|
const extIdClause = `(EXT_ID:${epmcId} AND SRC:${source})`;
|
|
64
70
|
return source === 'PMC' ? `${extIdClause} OR (PMCID:${epmcId})` : extIdClause;
|
|
65
71
|
}
|
|
72
|
+
/** The sort fields Europe PMC documents. */
|
|
73
|
+
const DOCUMENTED_SORT_FIELDS = new Set(['P_PDATE_D', 'CITED', 'AUTH_FIRST', 'PUB_YEAR']);
|
|
74
|
+
/**
|
|
75
|
+
* Whether a sort is the likely cause of an empty `{ version }` envelope: one of
|
|
76
|
+
* its comma-separated keys has a field outside the documented set, or lacks an
|
|
77
|
+
* `asc`/`desc` direction. Europe PMC answers those shapes with the envelope on
|
|
78
|
+
* every request, and orders by every key of a valid list (`PUB_YEAR desc, CITED
|
|
79
|
+
* desc`). Field and direction compare case-insensitively and tolerate extra
|
|
80
|
+
* whitespace, matching Europe PMC's own parsing.
|
|
81
|
+
*
|
|
82
|
+
* Classification only, never a local allowlist: the request always goes out.
|
|
83
|
+
* Europe PMC honors some undocumented fields (`ID asc` reorders results) and
|
|
84
|
+
* silently ignores others (`SCORE desc`), so only the envelope itself decides.
|
|
85
|
+
*/
|
|
86
|
+
function sortExplainsEnvelope(sort) {
|
|
87
|
+
return sort.split(',').some((key) => {
|
|
88
|
+
const [field = '', direction = '', ...rest] = key.trim().split(/\s+/);
|
|
89
|
+
return (rest.length > 0 ||
|
|
90
|
+
!DOCUMENTED_SORT_FIELDS.has(field.toUpperCase()) ||
|
|
91
|
+
!/^(asc|desc)$/i.test(direction));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
66
94
|
/**
|
|
67
95
|
* Facade over the Europe PMC REST API:
|
|
68
96
|
* - `search()` — keyword search across MED/PMC/PPR/PAT/AGR.
|
|
@@ -92,9 +120,21 @@ export class EuropePmcService {
|
|
|
92
120
|
/**
|
|
93
121
|
* Search Europe PMC. Cursor-based pagination — pass `cursorMark: '*'` (or
|
|
94
122
|
* omit) for the first page; pass the returned `nextCursorMark` for the next.
|
|
123
|
+
*
|
|
124
|
+
* The retry boundary covers the fetch and the response classification
|
|
125
|
+
* together, so an empty envelope retries like any other transient failure
|
|
126
|
+
* rather than surfacing after the loop has already returned. (#159)
|
|
95
127
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
search(params) {
|
|
129
|
+
return this.queue.enqueue(() => this.withRetry((attempt) => this.searchOnce(params, attempt === this.maxRetries), 'search', params.signal), 'search', params.signal);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One search attempt: fetch, parse, and classify the body. `isLastAttempt`
|
|
133
|
+
* lets an empty envelope that has persisted through the whole retry budget
|
|
134
|
+
* be attributed to a caller-supplied cursor.
|
|
135
|
+
*/
|
|
136
|
+
async searchOnce(params, isLastAttempt) {
|
|
137
|
+
const text = await this.client.search(params);
|
|
98
138
|
let parsed;
|
|
99
139
|
try {
|
|
100
140
|
parsed = JSON.parse(text);
|
|
@@ -121,40 +161,56 @@ export class EuropePmcService {
|
|
|
121
161
|
});
|
|
122
162
|
}
|
|
123
163
|
/**
|
|
124
|
-
* EPMC
|
|
125
|
-
* `request` echo, no `resultList
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
164
|
+
* EPMC answers some requests with a `{ version }`-only envelope — no
|
|
165
|
+
* `hitCount`, no `request` echo, no `resultList`. Without this guard the
|
|
166
|
+
* response normalizes to a fake 0-hit success. Every cause produces the
|
|
167
|
+
* byte-identical body, so it is classified by its likely cause:
|
|
168
|
+
*
|
|
169
|
+
* - A sort that EPMC rejects every time (see `sortExplainsEnvelope`) fails
|
|
170
|
+
* fast as non-retryable input — retrying only adds backoff.
|
|
171
|
+
* - A non-default cursor that draws it on every attempt is most likely
|
|
172
|
+
* invalid or expired; EPMC rejects a malformed cursor every time.
|
|
173
|
+
* - Otherwise it is intermittent upstream noise — valid requests, with or
|
|
174
|
+
* without a documented sort, draw it on a fraction of calls and succeed
|
|
175
|
+
* when repeated — so it is thrown as a transient `europepmc_unreachable`
|
|
176
|
+
* for the retry loop, and never names the caller's input. (#159)
|
|
177
|
+
*
|
|
178
|
+
* Each throw keeps the diagnosis (message) apart from the next step
|
|
179
|
+
* (recovery hint): the framework mirrors `data.recovery.hint` into
|
|
180
|
+
* `content[]`, and one string passed as both renders byte-identical
|
|
181
|
+
* Error:/Recovery: blocks. (#75)
|
|
130
182
|
*/
|
|
131
183
|
if (parsed.hitCount === undefined &&
|
|
132
184
|
parsed.request === undefined &&
|
|
133
185
|
parsed.resultList === undefined) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
186
|
+
const cursorMark = params.cursorMark && params.cursorMark !== '*' ? params.cursorMark : undefined;
|
|
187
|
+
if (params.sort && sortExplainsEnvelope(params.sort)) {
|
|
188
|
+
throw validationError(`Europe PMC silently rejected the request — most likely the sort "${params.sort}", which needs a documented field and an asc/desc direction.`, {
|
|
189
|
+
reason: 'europepmc_invalid_input',
|
|
190
|
+
sort: params.sort,
|
|
191
|
+
...(cursorMark && { cursorMark }),
|
|
192
|
+
responseSnippet: text.substring(0, 200),
|
|
193
|
+
recovery: {
|
|
194
|
+
hint: 'Use a documented sort (`P_PDATE_D desc`, `CITED desc`, `AUTH_FIRST asc`, or `PUB_YEAR desc`), or omit `sort` for relevance ranking.',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (cursorMark && isLastAttempt) {
|
|
199
|
+
throw validationError(`Europe PMC answered every attempt for cursorMark "${cursorMark}" with an empty response — the cursor is most likely invalid or expired.`, {
|
|
200
|
+
reason: 'europepmc_invalid_input',
|
|
201
|
+
cursorMark,
|
|
202
|
+
responseSnippet: text.substring(0, 200),
|
|
203
|
+
recovery: {
|
|
204
|
+
hint: 'Restart from the first page with `cursorMark: "*"`, or pass the exact `nextCursorMark` from the previous response.',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
throw serviceUnavailable('Europe PMC returned an empty response with no hit count or result list.', { reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') });
|
|
155
209
|
}
|
|
156
210
|
const hits = ensureArray(parsed.resultList?.result);
|
|
157
|
-
|
|
211
|
+
// Without an echo, report the query actually sent — source filter included —
|
|
212
|
+
// not the caller's bare query, which searches every source. (#150)
|
|
213
|
+
const echoed = parsed.request?.queryString ?? buildSearchQuery(params);
|
|
158
214
|
// EPMC's `request.cursorMark` echo is URL-encoded (the wire form), while
|
|
159
215
|
// `nextCursorMark` in the JSON body is raw. Use the caller's input as the
|
|
160
216
|
// current cursor so the echo is consistent and the equality check below
|
|
@@ -331,14 +387,19 @@ export class EuropePmcService {
|
|
|
331
387
|
/**
|
|
332
388
|
* Retry wrapper for transient errors. Mirrors NCBI's `withRetry` minus the
|
|
333
389
|
* service-level deadline — EPMC requests are cheaper individually and the
|
|
334
|
-
* caller (typically `ctx.signal`) bounds the total chain.
|
|
390
|
+
* caller (typically `ctx.signal`) bounds the total chain. `execute` receives
|
|
391
|
+
* the zero-based attempt index. On exhaustion the last error keeps its code
|
|
392
|
+
* and an upstream `retryAfter`, so a 429 still tells the caller how long to
|
|
393
|
+
* wait. Only a `ServiceUnavailable` gains `europepmc_unreachable` and its hint;
|
|
394
|
+
* a `Timeout` or `RateLimited` keeps its code with no reason, as the NCBI
|
|
395
|
+
* service reports them.
|
|
335
396
|
*/
|
|
336
397
|
async withRetry(execute, label, signal) {
|
|
337
398
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
338
399
|
if (signal?.aborted)
|
|
339
400
|
throw signal.reason;
|
|
340
401
|
try {
|
|
341
|
-
return await execute();
|
|
402
|
+
return await execute(attempt);
|
|
342
403
|
}
|
|
343
404
|
catch (error) {
|
|
344
405
|
if (signal?.aborted)
|
|
@@ -359,20 +420,18 @@ export class EuropePmcService {
|
|
|
359
420
|
continue;
|
|
360
421
|
}
|
|
361
422
|
const attempts = this.maxRetries + 1;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
423
|
+
throw new McpError(error.code, `${error.message} (failed after ${attempts} attempts)`, {
|
|
424
|
+
...(error.code === JsonRpcErrorCode.ServiceUnavailable && {
|
|
425
|
+
reason: 'europepmc_unreachable',
|
|
426
|
+
...recoveryFor('europepmc_unreachable'),
|
|
427
|
+
}),
|
|
365
428
|
label,
|
|
366
429
|
attempts,
|
|
367
|
-
...
|
|
430
|
+
...(error.data?.retryAfter !== undefined && { retryAfter: error.data.retryAfter }),
|
|
368
431
|
}, { cause: error });
|
|
369
432
|
}
|
|
370
433
|
}
|
|
371
|
-
throw internalError('Europe PMC request failed after all retries.', {
|
|
372
|
-
reason: 'europepmc_unreachable',
|
|
373
|
-
label,
|
|
374
|
-
...recoveryFor('europepmc_unreachable'),
|
|
375
|
-
});
|
|
434
|
+
throw internalError('Europe PMC request failed after all retries.', { label });
|
|
376
435
|
}
|
|
377
436
|
}
|
|
378
437
|
// ─── Init / Accessor ────────────────────────────────────────────────────────
|