@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.
Files changed (78) hide show
  1. package/AGENTS.md +4 -4
  2. package/CLAUDE.md +4 -4
  3. package/README.md +24 -15
  4. package/changelog/2.10.x/2.10.14.md +1 -0
  5. package/changelog/2.10.x/2.10.15.md +28 -0
  6. package/changelog/2.10.x/2.10.16.md +26 -0
  7. package/dist/mcp-server/tools/definitions/_schemas.d.ts +11 -1
  8. package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/_schemas.js +13 -1
  10. package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +10 -2
  12. package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/convert-ids.tool.js +41 -9
  14. package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +10 -3
  16. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +12 -4
  18. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +16 -5
  20. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +35 -12
  22. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +7 -63
  24. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/find-related.tool.js +42 -25
  26. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +10 -3
  28. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/format-citations.tool.js +12 -4
  30. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +18 -4
  32. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +62 -41
  34. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +10 -5
  36. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +3 -1
  38. package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
  39. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +6 -3
  40. package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
  41. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +11 -5
  42. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  43. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +20 -11
  44. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  45. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +16 -5
  46. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/definitions/search-articles.tool.js +71 -11
  48. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  49. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +12 -6
  50. package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
  51. package/dist/mcp-server/tools/definitions/spell-check.tool.js +4 -3
  52. package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
  53. package/dist/services/error-contracts.d.ts +32 -15
  54. package/dist/services/error-contracts.d.ts.map +1 -1
  55. package/dist/services/error-contracts.js +32 -15
  56. package/dist/services/error-contracts.js.map +1 -1
  57. package/dist/services/europe-pmc/api-client.d.ts +24 -9
  58. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  59. package/dist/services/europe-pmc/api-client.js +47 -18
  60. package/dist/services/europe-pmc/api-client.js.map +1 -1
  61. package/dist/services/europe-pmc/europe-pmc-service.d.ts +22 -1
  62. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  63. package/dist/services/europe-pmc/europe-pmc-service.js +102 -43
  64. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  65. package/dist/services/ncbi/ncbi-service.d.ts +18 -24
  66. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  67. package/dist/services/ncbi/ncbi-service.js +111 -120
  68. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  69. package/dist/services/ncbi/request-queue.d.ts +22 -30
  70. package/dist/services/ncbi/request-queue.d.ts.map +1 -1
  71. package/dist/services/ncbi/request-queue.js +29 -128
  72. package/dist/services/ncbi/request-queue.js.map +1 -1
  73. package/dist/services/ncbi/response-handler.d.ts +14 -1
  74. package/dist/services/ncbi/response-handler.d.ts.map +1 -1
  75. package/dist/services/ncbi/response-handler.js +65 -9
  76. package/dist/services/ncbi/response-handler.js.map +1 -1
  77. package/package.json +6 -6
  78. 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 that consume
21
- * `getNcbiService()` should spread these into their own `errors[]` so the
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: 'Local NCBI request queue is at capacity.',
29
- recovery: 'Retry after 1-2 seconds; the request queue hit the NCBI rate limit.',
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 any markup the tool strips first, are removed — so NCBI would receive a blank term.',
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 that consume
116
- * `getOpenAlexService()` / `getOpenAlexServiceOptional()` should spread
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 that consume
137
- * `getEuropePmcService()` should spread these into their `errors[]`.
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 was unreachable after all retry attempts.',
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 (empty query, unknown sort field, malformed parameter).',
158
- recovery: 'Adjust the input — usually the query or sort field — before retrying; the same input will be rejected again.',
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":"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"}
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
- * injects the optional contact email, and exposes single-attempt search and
4
- * fullTextXML calls. Retry logic lives in `EuropePmcService`.
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;;;;;GAKG;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;;;;OAIG;IACG,MAAM,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,CAwB3D;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;IActB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;CAMzB"}
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
- * injects the optional contact email, and exposes single-attempt search and
4
- * fullTextXML calls. Retry logic lives in `EuropePmcService`.
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
- throw error;
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: this.buildQueryString(params),
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
- * Combine the caller's query with an optional source filter. EPMC's query
154
- * syntax supports `SRC:"X"` field tokens — we OR-join the requested sources
155
- * into a parenthesized clause and AND it with the user's query.
156
- */
157
- buildQueryString(params) {
158
- const base = params.query.trim();
159
- if (!params.sources || params.sources.length === 0)
160
- return base;
161
- const sourceClause = params.sources.map((s) => `SRC:"${s}"`).join(' OR ');
162
- return `(${base}) AND (${sourceClause})`;
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":"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"}
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;;;;;;;;;;;;;;GAcG;AAeH,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,kBAAkB,EAElB,sBAAsB,EACtB,kBAAkB,EAClB,qBAAqB,EAErB,qBAAqB,EACrB,eAAe,EAChB,MAAM,YAAY,CAAC;AA2CpB;;;;;;;;;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;;;OAGG;IACG,MAAM,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA6F1E;IAED;;;;;;;;;;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;;;;OAIG;YACW,SAAS;CAoDxB;AAMD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAyB3C;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,SAAS,CAElE"}
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
- async search(params) {
97
- const text = await this.queue.enqueue(() => this.withRetry(() => this.client.search(params), 'search', params.signal), 'search', params.signal);
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 silently returns a `{ version }`-only envelope (no `hitCount`, no
125
- * `request` echo, no `resultList`) when it rejects a parameter — most
126
- * commonly an undocumented `sort` field. Without this guard the response
127
- * normalizes to a fake 0-hit success and the caller never learns the sort
128
- * was rejected. Route to ValidationError (non-retryable) since retrying
129
- * the same input will be rejected again.
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
- // Split the diagnosis (message) from the actionable next step (recovery
135
- // hint). The framework mirrors data.recovery.hint into content[], so
136
- // passing one string as both renders byte-identical Error:/Recovery:
137
- // blocks — the same distinct message-vs-hint shape as the errMsg throw
138
- // ~20 lines up. (#75)
139
- const { message, hint } = params.sort
140
- ? {
141
- message: `Europe PMC silently rejected the request — most likely the invalid sort field "${params.sort}".`,
142
- hint: 'Use a documented sort (`P_PDATE_D desc`, `CITED desc`, `AUTH_FIRST asc`, or `PUB_YEAR desc`), or omit `sort` for relevance ranking.',
143
- }
144
- : {
145
- message: 'Europe PMC silently rejected the request — empty envelope with no hitCount.',
146
- hint: 'Verify the query syntax, sort field, and cursorMark, then retry.',
147
- };
148
- throw validationError(message, {
149
- reason: 'europepmc_invalid_input',
150
- ...(params.sort && { sort: params.sort }),
151
- ...(params.cursorMark && params.cursorMark !== '*' && { cursorMark: params.cursorMark }),
152
- responseSnippet: text.substring(0, 200),
153
- recovery: { hint },
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
- const echoed = parsed.request?.queryString ?? params.query;
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
- const msg = error instanceof Error ? error.message : String(error);
363
- throw new McpError(error.code, `${msg} (failed after ${attempts} attempts)`, {
364
- reason: 'europepmc_unreachable',
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
- ...recoveryFor('europepmc_unreachable'),
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 ────────────────────────────────────────────────────────