@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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +40 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +314 -79
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- 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 +17 -4
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +19 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +27 -1
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts +14 -0
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +42 -0
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts +28 -1
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +59 -0
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +31 -0
- package/dist/services/europe-pmc/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts +39 -0
- package/dist/services/openalex/api-client.d.ts.map +1 -0
- package/dist/services/openalex/api-client.js +174 -0
- package/dist/services/openalex/api-client.js.map +1 -0
- package/dist/services/openalex/openalex-service.d.ts +59 -0
- package/dist/services/openalex/openalex-service.d.ts.map +1 -0
- package/dist/services/openalex/openalex-service.js +195 -0
- package/dist/services/openalex/openalex-service.js.map +1 -0
- package/dist/services/openalex/types.d.ts +56 -0
- package/dist/services/openalex/types.d.ts.map +1 -0
- package/dist/services/openalex/types.js +15 -0
- package/dist/services/openalex/types.js.map +1 -0
- package/package.json +1 -1
- package/server.json +3 -3
package/AGENTS.md
CHANGED
package/CLAUDE.md
CHANGED
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/pubmed-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/pubmed-mcp-server) [](https://www.typescriptlang.org/) [](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;
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
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 —
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
|
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('
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
pubDate: details?.pubDate,
|
|
382
|
+
sourcePmid: input.pmid,
|
|
383
|
+
relationship: input.relationship,
|
|
384
|
+
offset: input.offset,
|
|
385
|
+
articles,
|
|
166
386
|
};
|
|
167
|
-
}
|
|
168
|
-
|
|
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.');
|