@cyanheads/pubmed-mcp-server 2.8.0 → 2.9.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 (40) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +40 -1
  7. package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
  8. package/dist/mcp-server/tools/definitions/find-related.tool.js +314 -79
  9. package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +17 -4
  12. package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
  13. package/dist/services/error-contracts.d.ts +19 -1
  14. package/dist/services/error-contracts.d.ts.map +1 -1
  15. package/dist/services/error-contracts.js +27 -1
  16. package/dist/services/error-contracts.js.map +1 -1
  17. package/dist/services/europe-pmc/api-client.d.ts +14 -0
  18. package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
  19. package/dist/services/europe-pmc/api-client.js +42 -0
  20. package/dist/services/europe-pmc/api-client.js.map +1 -1
  21. package/dist/services/europe-pmc/europe-pmc-service.d.ts +28 -1
  22. package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
  23. package/dist/services/europe-pmc/europe-pmc-service.js +59 -0
  24. package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
  25. package/dist/services/europe-pmc/types.d.ts +31 -0
  26. package/dist/services/europe-pmc/types.d.ts.map +1 -1
  27. package/dist/services/openalex/api-client.d.ts +39 -0
  28. package/dist/services/openalex/api-client.d.ts.map +1 -0
  29. package/dist/services/openalex/api-client.js +174 -0
  30. package/dist/services/openalex/api-client.js.map +1 -0
  31. package/dist/services/openalex/openalex-service.d.ts +59 -0
  32. package/dist/services/openalex/openalex-service.d.ts.map +1 -0
  33. package/dist/services/openalex/openalex-service.js +195 -0
  34. package/dist/services/openalex/openalex-service.js.map +1 -0
  35. package/dist/services/openalex/types.d.ts +56 -0
  36. package/dist/services/openalex/types.d.ts.map +1 -0
  37. package/dist/services/openalex/types.js +15 -0
  38. package/dist/services/openalex/types.js.map +1 -0
  39. package/package.json +1 -1
  40. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** @cyanheads/pubmed-mcp-server
4
- **Version:** 2.8.0
4
+ **Version:** 2.9.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.9.21`
6
6
  **Engines:** Bun ≥1.3.2, Node ≥24.0.0
7
7
 
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** @cyanheads/pubmed-mcp-server
4
- **Version:** 2.8.0
4
+ **Version:** 2.9.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.9.21`
6
6
  **Engines:** Bun ≥1.3.2, Node ≥24.0.0
7
7
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
 
11
11
 
12
- [![Version](https://img.shields.io/badge/Version-2.8.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
12
+ [![Version](https://img.shields.io/badge/Version-2.9.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pubmed-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
13
13
 
14
14
  </div>
15
15
 
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { searchArticlesTool } from './mcp-server/tools/definitions/search-articl
19
19
  import { spellCheckTool } from './mcp-server/tools/definitions/spell-check.tool.js';
20
20
  import { initEuropePmcService } from './services/europe-pmc/europe-pmc-service.js';
21
21
  import { initNcbiService } from './services/ncbi/ncbi-service.js';
22
+ import { initOpenAlexService } from './services/openalex/openalex-service.js';
22
23
  import { initUnpaywallService } from './services/unpaywall/unpaywall-service.js';
23
24
  const config = getServerConfig();
24
25
  const tools = [
@@ -66,6 +67,7 @@ await createApp({
66
67
  initNcbiService();
67
68
  initUnpaywallService();
68
69
  initEuropePmcService();
70
+ initOpenAlexService();
69
71
  },
70
72
  });
71
73
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0DAA0D,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,8DAA8D,CAAC;AACpG,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAE,MAAM,yDAAyD,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,yBAAyB,EAAE,MAAM,gEAAgE,CAAC;AAC3G,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,oBAAoB,EAAE,MAAM,6CAA6C,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,MAAM,MAAM,GAAG,eAAe,EAAE,CAAC;AACjC,MAAM,KAAK,GAAG;IACZ,kBAAkB;IAClB,iBAAiB;IACjB,iBAAiB;IACjB,mBAAmB;IACnB,eAAe;IACf,cAAc;IACd,cAAc;IACd,kBAAkB;IAClB,cAAc;IACd,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;CAChE,CAAC;AAEF,MAAM,SAAS,CAAC;IACd,KAAK;IACL,SAAS,EAAE,CAAC,oBAAoB,CAAC;IACjC,OAAO,EAAE,CAAC,kBAAkB,CAAC;IAC7B,YAAY,EACV,iqBAAiqB;IACnqB,OAAO,EAAE;QACP,WAAW,EAAE,KAAK;QAClB,OAAO,EACL,uGAAuG;QACzG,QAAQ,EAAE,gDAAgD;QAC1D,KAAK,EAAE;YACL,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,kCAAkC,EAAE,QAAQ,EAAE,IAAI,EAAE;YAC7E;gBACE,KAAK,EAAE,kBAAkB;gBACzB,IAAI,EAAE,8CAA8C;gBACpD,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,KAAK,EAAE,qBAAqB;gBAC5B,IAAI,EAAE,gDAAgD;gBACtD,QAAQ,EAAE,IAAI;aACf;YACD,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,4BAA4B,EAAE,QAAQ,EAAE,IAAI,EAAE;SAC9E;QACD,UAAU,EAAE;YACV,YAAY,EAAE,mBAAmB;YACjC,gBAAgB,EAAE,iBAAiB;YACnC,eAAe,EAAE,iBAAiB;SACnC;KACF;IACD,KAAK;QACH,eAAe,EAAE,CAAC;QAClB,oBAAoB,EAAE,CAAC;QACvB,oBAAoB,EAAE,CAAC;IACzB,CAAC;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,0DAA0D,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,8DAA8D,CAAC;AACpG,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,iBAAiB,EAAE,MAAM,uDAAuD,CAAC;AAC1F,OAAO,EAAE,eAAe,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAE,MAAM,yDAAyD,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,yBAAyB,EAAE,MAAM,gEAAgE,CAAC;AAC3G,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAC;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,oDAAoD,CAAC;AACpF,OAAO,EAAE,oBAAoB,EAAE,MAAM,6CAA6C,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,MAAM,MAAM,GAAG,eAAe,EAAE,CAAC;AACjC,MAAM,KAAK,GAAG;IACZ,kBAAkB;IAClB,iBAAiB;IACjB,iBAAiB;IACjB,mBAAmB;IACnB,eAAe;IACf,cAAc;IACd,cAAc;IACd,kBAAkB;IAClB,cAAc;IACd,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;CAChE,CAAC;AAEF,MAAM,SAAS,CAAC;IACd,KAAK;IACL,SAAS,EAAE,CAAC,oBAAoB,CAAC;IACjC,OAAO,EAAE,CAAC,kBAAkB,CAAC;IAC7B,YAAY,EACV,iqBAAiqB;IACnqB,OAAO,EAAE;QACP,WAAW,EAAE,KAAK;QAClB,OAAO,EACL,uGAAuG;QACzG,QAAQ,EAAE,gDAAgD;QAC1D,KAAK,EAAE;YACL,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,kCAAkC,EAAE,QAAQ,EAAE,IAAI,EAAE;YAC7E;gBACE,KAAK,EAAE,kBAAkB;gBACzB,IAAI,EAAE,8CAA8C;gBACpD,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,KAAK,EAAE,qBAAqB;gBAC5B,IAAI,EAAE,gDAAgD;gBACtD,QAAQ,EAAE,IAAI;aACf;YACD,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,4BAA4B,EAAE,QAAQ,EAAE,IAAI,EAAE;SAC9E;QACD,UAAU,EAAE;YACV,YAAY,EAAE,mBAAmB;YACjC,gBAAgB,EAAE,iBAAiB;YACnC,eAAe,EAAE,iBAAiB;SACnC;KACF;IACD,KAAK;QACH,eAAe,EAAE,CAAC;QAClB,oBAAoB,EAAE,CAAC;QACvB,oBAAoB,EAAE,CAAC;QACvB,mBAAmB,EAAE,CAAC;IACxB,CAAC;CACF,CAAC,CAAC"}
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * @fileoverview PubMed related articles tool — finds articles related to a
3
- * source article via NCBI ELink and enriches results with ESummary data.
3
+ * source article via a provider chain: NCBI ELink (primary) Europe PMC
4
+ * OpenAlex. First success wins; results are never merged across sources.
5
+ * Supports offset pagination on the returned window.
4
6
  * @module src/mcp-server/tools/definitions/find-related.tool
5
7
  */
6
8
  import { z } from '@cyanheads/mcp-ts-core';
@@ -12,6 +14,7 @@ export declare const findRelatedTool: import("@cyanheads/mcp-ts-core").ToolDefin
12
14
  cited_by: "cited_by";
13
15
  }>>;
14
16
  maxResults: z.ZodDefault<z.ZodNumber>;
17
+ offset: z.ZodDefault<z.ZodNumber>;
15
18
  }, z.core.$strip>, z.ZodObject<{
16
19
  sourcePmid: z.ZodString;
17
20
  relationship: z.ZodEnum<{
@@ -19,6 +22,7 @@ export declare const findRelatedTool: import("@cyanheads/mcp-ts-core").ToolDefin
19
22
  similar: "similar";
20
23
  cited_by: "cited_by";
21
24
  }>;
25
+ offset: z.ZodNumber;
22
26
  articles: z.ZodArray<z.ZodObject<{
23
27
  pmid: z.ZodString;
24
28
  title: z.ZodOptional<z.ZodString>;
@@ -56,8 +60,43 @@ export declare const findRelatedTool: import("@cyanheads/mcp-ts-core").ToolDefin
56
60
  readonly when: "NCBI returned a structured \"not found\" error for the requested ID(s).";
57
61
  readonly recovery: "Verify the ID exists in PubMed; the resource was not found in NCBI and retrying will not help.";
58
62
  readonly retryable: false;
63
+ }, {
64
+ readonly reason: "europepmc_unreachable";
65
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.ServiceUnavailable;
66
+ readonly when: "Europe PMC was unreachable after all retry attempts.";
67
+ readonly recovery: "Retry after a brief delay; Europe PMC was unreachable. NCBI PMC and Unpaywall remain available.";
68
+ readonly retryable: true;
69
+ }, {
70
+ readonly reason: "europepmc_invalid_response";
71
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.SerializationError;
72
+ readonly when: "Europe PMC returned a body that could not be parsed (invalid JSON or XML).";
73
+ readonly recovery: "Retry the request; Europe PMC returned a malformed response that could not be parsed.";
74
+ readonly retryable: true;
75
+ }, {
76
+ readonly reason: "europepmc_invalid_input";
77
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.ValidationError;
78
+ readonly when: "Europe PMC rejected the request input (empty query, unknown sort field, malformed parameter).";
79
+ readonly recovery: "Adjust the input — usually the query or sort field — before retrying; the same input will be rejected again.";
80
+ readonly retryable: false;
81
+ }, {
82
+ readonly reason: "openalex_unreachable";
83
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.ServiceUnavailable;
84
+ readonly when: "OpenAlex was unreachable after all retry attempts.";
85
+ readonly recovery: "Retry after a brief delay; OpenAlex was unreachable. NCBI and Europe PMC remain available.";
86
+ readonly retryable: true;
87
+ }, {
88
+ readonly reason: "openalex_invalid_response";
89
+ readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.SerializationError;
90
+ readonly when: "OpenAlex returned a body that could not be parsed (invalid JSON).";
91
+ readonly recovery: "Retry the request; OpenAlex returned a malformed response that could not be parsed.";
92
+ readonly retryable: true;
59
93
  }], {
60
94
  readonly totalFound: z.ZodNumber;
95
+ readonly source: z.ZodEnum<{
96
+ europepmc: "europepmc";
97
+ openalex: "openalex";
98
+ ncbi: "ncbi";
99
+ }>;
61
100
  readonly notice: z.ZodOptional<z.ZodString>;
62
101
  }>;
63
102
  //# sourceMappingURL=find-related.tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"find-related.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/find-related.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAqCjD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsM1B,CAAC"}
1
+ {"version":3,"file":"find-related.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/find-related.tool.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AA6KjD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8W1B,CAAC"}
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * @fileoverview PubMed related articles tool — finds articles related to a
3
- * source article via NCBI ELink and enriches results with ESummary data.
3
+ * source article via a provider chain: NCBI ELink (primary) Europe PMC
4
+ * OpenAlex. First success wins; results are never merged across sources.
5
+ * Supports offset pagination on the returned window.
4
6
  * @module src/mcp-server/tools/definitions/find-related.tool
5
7
  */
6
8
  import { tool, z } from '@cyanheads/mcp-ts-core';
7
9
  import { McpError } from '@cyanheads/mcp-ts-core/errors';
8
- import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js';
10
+ import { EUROPEPMC_SERVICE_ERRORS, NCBI_SERVICE_ERRORS, OPENALEX_SERVICE_ERRORS, } from '../../../services/error-contracts.js';
11
+ import { getEuropePmcService } from '../../../services/europe-pmc/europe-pmc-service.js';
9
12
  import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
10
13
  import { extractBriefSummaries } from '../../../services/ncbi/parsing/esummary-parser.js';
11
14
  import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
15
+ import { getOpenAlexServiceOptional } from '../../../services/openalex/openalex-service.js';
12
16
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
13
17
  import { pmidStringSchema } from './_schemas.js';
14
18
  function extractValue(field) {
@@ -18,13 +22,99 @@ function extractValue(field) {
18
22
  return field['#text'] !== undefined ? String(field['#text']) : '';
19
23
  return String(field);
20
24
  }
25
+ // ─── NCBI provider ────────────────────────────────────────────────────────────
26
+ async function ncbiProvider(pmid, relationship, signal) {
27
+ const ncbi = getNcbiService();
28
+ const linkName = relationship === 'cited_by'
29
+ ? 'pubmed_pubmed_citedin'
30
+ : relationship === 'references'
31
+ ? 'pubmed_pubmed_refs'
32
+ : 'pubmed_pubmed';
33
+ const eLinkResult = (await ncbi.eLink({
34
+ dbfrom: 'pubmed',
35
+ db: 'pubmed',
36
+ id: pmid,
37
+ cmd: 'neighbor',
38
+ linkname: linkName,
39
+ retmode: 'xml',
40
+ }, { signal }));
41
+ const eLinkResultsArray = ensureArray(eLinkResult?.eLinkResult);
42
+ const firstResult = eLinkResultsArray[0];
43
+ const linkSet = firstResult?.LinkSet;
44
+ let foundPmids = [];
45
+ if (linkSet?.LinkSetDb) {
46
+ const linkSetDbArray = ensureArray(linkSet.LinkSetDb);
47
+ const targetDb = linkSetDbArray.find((db) => db.LinkName === linkName) ?? linkSetDbArray[0];
48
+ if (targetDb?.Link) {
49
+ foundPmids = ensureArray(targetDb.Link)
50
+ .map((link) => extractValue(link.Id))
51
+ .filter((p) => p && p !== pmid && p !== '0');
52
+ }
53
+ }
54
+ return { allPmids: foundPmids, totalCount: foundPmids.length, source: 'ncbi' };
55
+ }
56
+ // ─── Europe PMC provider ──────────────────────────────────────────────────────
57
+ /**
58
+ * Europe PMC supports citations and references for MED-source records.
59
+ * It has no `similar` equivalent, so we skip it for that relationship.
60
+ */
61
+ function epmcSupports(relationship) {
62
+ return relationship === 'cited_by' || relationship === 'references';
63
+ }
64
+ /**
65
+ * Fetch enough pages to cover [offset, offset+maxResults) from EPMC.
66
+ * EPMC uses 1-based page numbers and its endpoint supports pageSize up to 1000.
67
+ * We fetch one page containing the window we need.
68
+ */
69
+ async function epmcProvider(pmid, relationship, offset, maxResults, signal) {
70
+ const epmc = getEuropePmcService();
71
+ if (!epmc)
72
+ throw new Error('Europe PMC service not available');
73
+ // EPMC is 1-based; we need the page covering [offset, offset+maxResults).
74
+ // Simplest approach: fetch one page starting at the right position.
75
+ // pageSize = maxResults, page = floor(offset/maxResults) + 1 won't align
76
+ // cleanly. Instead, use a large pageSize and slice client-side, capped at 100.
77
+ const pageSize = Math.min(offset + maxResults, 100);
78
+ const page = 1;
79
+ const result = relationship === 'cited_by'
80
+ ? await epmc.citations(pmid, pageSize, page, signal)
81
+ : await epmc.references(pmid, pageSize, page, signal);
82
+ return { allPmids: result.pmids, totalCount: result.totalCount, source: 'europepmc' };
83
+ }
84
+ // ─── OpenAlex provider ────────────────────────────────────────────────────────
85
+ async function openAlexProvider(pmid, relationship, maxNeeded, signal) {
86
+ const oa = getOpenAlexServiceOptional();
87
+ if (!oa)
88
+ throw new Error('OpenAlex service not available');
89
+ let result;
90
+ switch (relationship) {
91
+ case 'similar':
92
+ result = await oa.similar(pmid, maxNeeded, signal);
93
+ break;
94
+ case 'cited_by':
95
+ result = await oa.citedBy(pmid, maxNeeded, signal);
96
+ break;
97
+ case 'references':
98
+ result = await oa.references(pmid, maxNeeded, signal);
99
+ break;
100
+ }
101
+ return { allPmids: result.pmids, totalCount: result.totalCount, source: 'openalex' };
102
+ }
103
+ /** Compact, log-safe description of an unknown thrown value. */
104
+ function describeError(err) {
105
+ return err instanceof Error ? err.message : String(err);
106
+ }
21
107
  // ─── Tool Definition ─────────────────────────────────────────────────────────
22
108
  export const findRelatedTool = tool('pubmed_find_related', {
23
- description: 'Find articles related to a source article — similar content (similar), articles citing this one (cited_by), or articles this one cites (references).',
109
+ description: 'Find articles related to a source article — similar content (similar), articles citing this one (cited_by), or articles this one cites (references). Uses NCBI ELink as the primary source; falls back to Europe PMC then OpenAlex when NCBI is unavailable.',
24
110
  annotations: { readOnlyHint: true, openWorldHint: true },
25
111
  _meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL]),
26
112
  sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/find-related.tool.ts',
27
- errors: [...NCBI_SERVICE_ERRORS],
113
+ errors: [
114
+ ...NCBI_SERVICE_ERRORS,
115
+ ...EUROPEPMC_SERVICE_ERRORS,
116
+ ...OPENALEX_SERVICE_ERRORS,
117
+ ],
28
118
  input: z.object({
29
119
  pmid: pmidStringSchema.describe('Source PubMed ID'),
30
120
  relationship: z
@@ -32,10 +122,17 @@ export const findRelatedTool = tool('pubmed_find_related', {
32
122
  .default('similar')
33
123
  .describe('Relationship type: similar (content-based), cited_by (articles citing this one), references (articles this one cites)'),
34
124
  maxResults: z.number().int().min(1).max(50).default(10).describe('Maximum related articles'),
125
+ offset: z
126
+ .number()
127
+ .int()
128
+ .min(0)
129
+ .default(0)
130
+ .describe('Result offset for pagination (0-based); page through results by incrementing by maxResults'),
35
131
  }),
36
132
  output: z.object({
37
133
  sourcePmid: z.string().describe('Source PubMed ID'),
38
134
  relationship: z.enum(['similar', 'cited_by', 'references']).describe('Relationship type used'),
135
+ offset: z.number().describe('Result offset used'),
39
136
  articles: z
40
137
  .array(z
41
138
  .object({
@@ -48,129 +145,267 @@ export const findRelatedTool = tool('pubmed_find_related', {
48
145
  .describe('Related article with enriched summary'))
49
146
  .describe('Related articles'),
50
147
  }),
51
- // Result-set context the agent reasons with — the pre-truncation match count and
52
- // recovery guidance (invalid source PMID, or references for a non-PMC source).
53
- // Surfaced via ctx.enrich(...) to structuredContent and content[]; out of the return.
148
+ // Result-set context the agent reasons with — pre-truncation match count,
149
+ // the answering provider, and recovery guidance. Surfaced via ctx.enrich(...)
150
+ // to structuredContent and content[]; kept out of the domain return.
54
151
  enrichment: {
55
- totalFound: z.number().describe('Total related articles found before truncation'),
152
+ totalFound: z.number().describe('Total related articles found before windowing'),
153
+ source: z
154
+ .enum(['ncbi', 'europepmc', 'openalex'])
155
+ .describe('Provider that answered this request'),
56
156
  notice: z
57
157
  .string()
58
158
  .optional()
59
- .describe('Optional guidance when results are empty e.g. invalid source PMID, or references requested for a non-PMC source. Absent on successful result pages.'),
159
+ .describe('Guidance when results are empty, a fallback provider answered, or offset overshot. Absent on a clean NCBI result page.'),
60
160
  },
61
- // content[] trailer presentation for the enrichment block. totalFound is scalar —
62
- // a label restores the human-readable key; notice renders as a blockquote kind-tag.
63
161
  enrichmentTrailer: {
64
162
  totalFound: { label: 'Total Found' },
163
+ source: { label: 'Source' },
65
164
  },
66
165
  async handler(input, ctx) {
67
166
  const ncbi = getNcbiService();
68
167
  ctx.log.debug('Finding related articles', {
69
168
  pmid: input.pmid,
70
169
  relationship: input.relationship,
170
+ offset: input.offset,
71
171
  });
72
- // `cmd=neighbor_score` is unstable on NCBI's side — it intermittently fails
73
- // with a TXCLIENT::readAll EOF for high-traffic PMIDs. `cmd=neighbor` returns
74
- // the `pubmed_pubmed` list in relevance order anyway, so we keep the ranking
75
- // without the (arbitrary-scale) numeric score.
76
- const linkName = input.relationship === 'cited_by'
77
- ? 'pubmed_pubmed_citedin'
78
- : input.relationship === 'references'
79
- ? 'pubmed_pubmed_refs'
80
- : 'pubmed_pubmed';
81
- const eLinkResult = (await ncbi.eLink({
82
- dbfrom: 'pubmed',
83
- db: 'pubmed',
84
- id: input.pmid,
85
- cmd: 'neighbor',
86
- linkname: linkName,
87
- retmode: 'xml',
88
- }, { signal: ctx.signal }));
89
- const eLinkResultsArray = ensureArray(eLinkResult?.eLinkResult);
90
- const firstResult = eLinkResultsArray[0];
91
- const linkSet = firstResult?.LinkSet;
92
- let foundPmids = [];
93
- if (linkSet?.LinkSetDb) {
94
- const linkSetDbArray = ensureArray(linkSet.LinkSetDb);
95
- const targetDb = linkSetDbArray.find((db) => db.LinkName === linkName) ?? linkSetDbArray[0];
96
- if (targetDb?.Link) {
97
- foundPmids = ensureArray(targetDb.Link)
98
- .map((link) => extractValue(link.Id))
99
- .filter((pmid) => pmid && pmid !== input.pmid && pmid !== '0');
172
+ // ── Provider chain ──────────────────────────────────────────────────────
173
+ // Try NCBI first; on failure fall back to Europe PMC then OpenAlex (first
174
+ // success wins, never merged). A non-PMC `references` source returns an empty
175
+ // NCBI set rather than throwing — that structural fallback is handled below.
176
+ let providerResult = null;
177
+ let providerError;
178
+ // Records WHY a non-primary provider answered, so the provenance notice can
179
+ // distinguish an NCBI outage from references coverage for a non-PMC source.
180
+ let fallbackKind;
181
+ // 1. NCBI (primary)
182
+ try {
183
+ providerResult = await ncbiProvider(input.pmid, input.relationship, ctx.signal);
184
+ }
185
+ catch (err) {
186
+ ctx.log.warning('NCBI eLink failed, trying fallback providers', {
187
+ pmid: input.pmid,
188
+ err: describeError(err),
189
+ });
190
+ providerError = err;
191
+ }
192
+ // 2. Europe PMC (fallback for cited_by / references only). An empty EPMC
193
+ // result is not "served" — fall through to OpenAlex rather than return 0.
194
+ if (providerResult === null && epmcSupports(input.relationship)) {
195
+ try {
196
+ const epmcResult = await epmcProvider(input.pmid, input.relationship, input.offset, input.maxResults, ctx.signal);
197
+ if (epmcResult.allPmids.length > 0) {
198
+ providerResult = epmcResult;
199
+ fallbackKind = 'outage';
200
+ }
201
+ providerError = undefined;
202
+ }
203
+ catch (err) {
204
+ ctx.log.warning('Europe PMC fallback failed, trying OpenAlex', {
205
+ pmid: input.pmid,
206
+ err: describeError(err),
207
+ });
208
+ providerError = err;
100
209
  }
101
210
  }
102
- const totalFound = foundPmids.length;
103
- ctx.enrich({ totalFound });
104
- if (foundPmids.length === 0) {
105
- // ELink returns an empty LinkSet for both invalid source PMIDs and valid
106
- // PMIDs that simply have no related articles, so a single ESummary on the
107
- // source disambiguates the two cases for every relationship type. NCBI's
108
- // pubmed_pubmed_refs ELink also only resolves references for PMC-indexed
109
- // sources, so the same lookup yields the PMCID for the references hint.
211
+ // 3. OpenAlex (last resort for all relationships)
212
+ if (providerResult === null) {
213
+ const oa = getOpenAlexServiceOptional();
214
+ if (oa) {
215
+ try {
216
+ providerResult = await openAlexProvider(input.pmid, input.relationship, input.offset + input.maxResults, ctx.signal);
217
+ fallbackKind = 'outage';
218
+ providerError = undefined;
219
+ }
220
+ catch (err) {
221
+ ctx.log.warning('OpenAlex fallback failed', {
222
+ pmid: input.pmid,
223
+ err: describeError(err),
224
+ });
225
+ providerError = err;
226
+ }
227
+ }
228
+ }
229
+ // ── Every provider failed ─────────────────────────────────────────────────
230
+ if (providerResult === null) {
231
+ ctx.enrich({ totalFound: 0, source: 'ncbi' });
232
+ ctx.enrich.notice(`All providers failed to retrieve related articles (NCBI, Europe PMC, OpenAlex). Last error: ${describeError(providerError ?? 'unknown')}. Retry after a brief delay.`);
233
+ return {
234
+ sourcePmid: input.pmid,
235
+ relationship: input.relationship,
236
+ offset: input.offset,
237
+ articles: [],
238
+ };
239
+ }
240
+ // ── NCBI returned an empty set ──────────────────────────────────────────────
241
+ // ELink returns an empty LinkSet for both invalid source PMIDs and valid PMIDs
242
+ // with no neighbors; a single ESummary disambiguates and yields the PMCID. For
243
+ // `references`, a valid non-PMC source has no NCBI reference list — Europe PMC
244
+ // and OpenAlex serve references for any source, so try them before giving up.
245
+ if (providerResult.source === 'ncbi' && providerResult.allPmids.length === 0) {
110
246
  let sourceSummary;
111
247
  let sourceConfirmedMissing = false;
112
248
  try {
113
249
  const summaryResult = await ncbi.eSummary({ db: 'pubmed', id: input.pmid }, { signal: ctx.signal });
114
250
  const summaries = await extractBriefSummaries(summaryResult);
115
251
  sourceSummary = summaries[0];
116
- // ESummary succeeded but returned nothing parseable — PMID is unknown.
117
252
  if (!sourceSummary?.title)
118
253
  sourceConfirmedMissing = true;
119
254
  }
120
255
  catch (err) {
121
256
  ctx.log.debug('Source PMID ESummary failed', { err });
122
- // Key off the contract reason rather than the JSON-RPC code: the service
123
- // layer stamps `ncbi_resource_not_found` for "unknown UID" responses,
124
- // while transient transport failures keep their ServiceUnavailable code
125
- // with no such reason.
126
257
  const reason = err instanceof McpError
127
258
  ? err.data?.reason
128
259
  : undefined;
129
- if (reason === 'ncbi_resource_not_found') {
260
+ if (reason === 'ncbi_resource_not_found')
130
261
  sourceConfirmedMissing = true;
131
- }
132
262
  }
133
- let notice;
134
263
  if (sourceConfirmedMissing) {
135
- notice = `Source PMID ${input.pmid} not found in PubMed. Verify the ID with \`pubmed_fetch_articles\` or \`pubmed_search_articles\`.`;
264
+ ctx.enrich({ totalFound: 0, source: 'ncbi' });
265
+ ctx.enrich.notice(`Source PMID ${input.pmid} not found in PubMed. Verify the ID with \`pubmed_fetch_articles\` or \`pubmed_search_articles\`.`);
266
+ return {
267
+ sourcePmid: input.pmid,
268
+ relationship: input.relationship,
269
+ offset: input.offset,
270
+ articles: [],
271
+ };
136
272
  }
137
- else if (sourceSummary?.title && input.relationship === 'references') {
138
- const sourcePmcId = sourceSummary.pmcId;
139
- notice = sourcePmcId
140
- ? `No reference list found in PMC for PMID ${input.pmid} (PMCID ${sourcePmcId}).`
141
- : `Reference lists require the source article to be indexed in PMC. PMID ${input.pmid} has no PMCID — references unavailable. Use pubmed_fetch_articles to inspect the article record, or try relationship: "similar" / "cited_by".`;
273
+ // References coverage fallback only for a confirmed-valid source. NCBI
274
+ // resolves references only for PMC-indexed sources, so a valid non-PMC
275
+ // source returns empty; Europe PMC then OpenAlex serve them. An unconfirmed
276
+ // source (ESummary itself failed) stays silent the empty set is most
277
+ // likely a transient NCBI issue, not a real "no references".
278
+ if (input.relationship === 'references' && sourceSummary?.title) {
279
+ let refFallback = null;
280
+ const refAttempts = [
281
+ () => epmcProvider(input.pmid, 'references', input.offset, input.maxResults, ctx.signal),
282
+ () => openAlexProvider(input.pmid, 'references', input.offset + input.maxResults, ctx.signal),
283
+ ];
284
+ for (const attempt of refAttempts) {
285
+ try {
286
+ const result = await attempt();
287
+ if (result.allPmids.length > 0) {
288
+ refFallback = result;
289
+ break;
290
+ }
291
+ }
292
+ catch (err) {
293
+ ctx.log.warning('References fallback provider failed', { err: describeError(err) });
294
+ }
295
+ }
296
+ if (refFallback) {
297
+ providerResult = refFallback;
298
+ fallbackKind = 'references_coverage';
299
+ }
300
+ else {
301
+ ctx.enrich({ totalFound: 0, source: 'ncbi' });
302
+ const sourcePmcId = sourceSummary.pmcId;
303
+ ctx.enrich.notice(sourcePmcId
304
+ ? `No reference list found for PMID ${input.pmid} (PMCID ${sourcePmcId}) via NCBI, Europe PMC, or OpenAlex.`
305
+ : `No reference list available for PMID ${input.pmid} via NCBI, Europe PMC, or OpenAlex. Use pubmed_fetch_articles to inspect the article record, or try relationship: "similar" / "cited_by".`);
306
+ return {
307
+ sourcePmid: input.pmid,
308
+ relationship: input.relationship,
309
+ offset: input.offset,
310
+ articles: [],
311
+ };
312
+ }
313
+ }
314
+ else {
315
+ // similar / cited_by empty for a valid source, or references with an
316
+ // unconfirmed source — return the honest empty without a notice.
317
+ ctx.enrich({ totalFound: 0, source: 'ncbi' });
318
+ return {
319
+ sourcePmid: input.pmid,
320
+ relationship: input.relationship,
321
+ offset: input.offset,
322
+ articles: [],
323
+ };
142
324
  }
143
- if (notice)
144
- ctx.enrich.notice(notice);
325
+ }
326
+ // ── Window the result set + enrich ──────────────────────────────────────────
327
+ const { allPmids, totalCount, source } = providerResult;
328
+ ctx.enrich({ totalFound: totalCount, source });
329
+ // For NCBI the full neighbor set is in memory; for EPMC/OpenAlex the provider
330
+ // pre-fetched enough to cover the window. Slice the requested page either way.
331
+ const window = allPmids.slice(input.offset, input.offset + input.maxResults);
332
+ // Only the LAST ctx.enrich.notice survives, so collect the applicable
333
+ // fragments (overshoot, provenance, enrichment-degraded) and emit them once.
334
+ const notices = [];
335
+ if (input.offset > 0 && input.offset >= totalCount) {
336
+ notices.push(`Offset ${input.offset} exceeds totalFound (${totalCount}). Reset offset to 0 or reduce it below ${totalCount} to page through results.`);
337
+ }
338
+ if (source !== 'ncbi') {
339
+ const providerName = source === 'europepmc' ? 'Europe PMC' : 'OpenAlex';
340
+ const detail = source === 'openalex'
341
+ ? input.relationship === 'similar'
342
+ ? 'related_works — OpenAlex similarity, not PubMed’s neighbor algorithm'
343
+ : input.relationship === 'cited_by'
344
+ ? 'cites: filter'
345
+ : 'referenced_works'
346
+ : `${input.relationship === 'cited_by' ? 'citations' : 'references'} index`;
347
+ notices.push(fallbackKind === 'references_coverage'
348
+ ? `NCBI has no PMC-indexed reference list for PMID ${input.pmid} — references served by ${providerName} (${detail}).`
349
+ : `NCBI eLink unavailable — related articles served by ${providerName} (${detail}).`);
350
+ }
351
+ if (window.length === 0) {
352
+ if (notices.length > 0)
353
+ ctx.enrich.notice(notices.join(' '));
145
354
  return {
146
355
  sourcePmid: input.pmid,
147
356
  relationship: input.relationship,
357
+ offset: input.offset,
148
358
  articles: [],
149
359
  };
150
360
  }
151
- const pmidsToEnrich = foundPmids.slice(0, input.maxResults);
152
- const summaryResult = await ncbi.eSummary({
153
- db: 'pubmed',
154
- id: pmidsToEnrich.join(','),
155
- }, { signal: ctx.signal });
156
- const briefSummaries = await extractBriefSummaries(summaryResult);
157
- const summaryMap = new Map(briefSummaries.map((bs) => [bs.pmid, bs]));
158
- const articles = pmidsToEnrich.map((pmid) => {
159
- const details = summaryMap.get(pmid);
361
+ // Enrich the window with ESummary. When a fallback answered because NCBI is
362
+ // down, eSummary hits the same host and may also fail — degrade to bare PMIDs
363
+ // (the article metadata fields are all optional) rather than failing the whole
364
+ // request, so the chain's resilience survives the enrichment step.
365
+ try {
366
+ const summaryResult = await ncbi.eSummary({ db: 'pubmed', id: window.join(',') }, { signal: ctx.signal });
367
+ const briefSummaries = await extractBriefSummaries(summaryResult);
368
+ const summaryMap = new Map(briefSummaries.map((bs) => [bs.pmid, bs]));
369
+ const articles = window.map((pmid) => {
370
+ const details = summaryMap.get(pmid);
371
+ return {
372
+ pmid,
373
+ title: details?.title,
374
+ authors: details?.authors,
375
+ source: details?.source,
376
+ pubDate: details?.pubDate,
377
+ };
378
+ });
379
+ if (notices.length > 0)
380
+ ctx.enrich.notice(notices.join(' '));
160
381
  return {
161
- pmid,
162
- title: details?.title,
163
- authors: details?.authors,
164
- source: details?.source,
165
- pubDate: details?.pubDate,
382
+ sourcePmid: input.pmid,
383
+ relationship: input.relationship,
384
+ offset: input.offset,
385
+ articles,
166
386
  };
167
- });
168
- return { sourcePmid: input.pmid, relationship: input.relationship, articles };
387
+ }
388
+ catch (err) {
389
+ if (ctx.signal.aborted)
390
+ throw err;
391
+ ctx.log.warning('ESummary enrichment failed; returning related PMIDs without metadata', {
392
+ err: describeError(err),
393
+ });
394
+ notices.push('Article metadata is temporarily unavailable (NCBI eSummary did not respond); returning related PMIDs only. Retry for full metadata, or use pubmed_fetch_articles.');
395
+ ctx.enrich.notice(notices.join(' '));
396
+ return {
397
+ sourcePmid: input.pmid,
398
+ relationship: input.relationship,
399
+ offset: input.offset,
400
+ articles: window.map((pmid) => ({ pmid })),
401
+ };
402
+ }
169
403
  },
170
404
  format: (result) => {
171
405
  const lines = [
172
406
  `# Related Articles for PMID ${result.sourcePmid}`,
173
407
  `**Relationship:** ${result.relationship}`,
408
+ `**Returned:** ${result.articles.length} | **Offset:** ${result.offset}`,
174
409
  ];
175
410
  if (result.articles.length === 0) {
176
411
  lines.push('No related articles found.');