@cyanheads/pubmed-mcp-server 1.2.2 → 1.2.4
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/README.md +1 -1
- package/dist/mcp-server/tools/fetchPubMedContent/logic.d.ts +8 -8
- package/dist/mcp-server/tools/fetchPubMedContent/logic.js +4 -3
- package/dist/mcp-server/tools/generatePubMedChart/logic.js +19 -19
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +63 -238
- package/dist/mcp-server/tools/searchPubMedArticles/logic.js +8 -9
- package/dist/services/NCBI/ncbiCoreApiClient.d.ts +1 -1
- package/dist/services/NCBI/ncbiCoreApiClient.js +51 -47
- package/dist/services/NCBI/ncbiService.d.ts +3 -2
- package/dist/services/NCBI/ncbiService.js +13 -1
- package/dist/types-global/pubmedXml.d.ts +15 -0
- package/dist/utils/internal/logger.js +51 -49
- package/dist/utils/parsing/ncbi-parsing/pubmedArticleStructureParser.js +4 -0
- package/dist/utils/security/idGenerator.d.ts +4 -1
- package/dist/utils/security/idGenerator.js +24 -7
- package/dist/utils/security/rateLimiter.d.ts +0 -4
- package/dist/utils/security/rateLimiter.js +0 -4
- package/dist/utils/security/sanitization.d.ts +11 -0
- package/dist/utils/security/sanitization.js +16 -2
- package/package.json +13 -11
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://modelcontextprotocol.io/)
|
|
5
|
-
[](./CHANGELOG.md)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/pubmed-mcp-server/issues)
|
|
8
8
|
[](https://github.com/cyanheads/pubmed-mcp-server)
|
|
@@ -22,17 +22,17 @@ export declare const FetchPubMedContentInputSchema: z.ZodEffects<z.ZodObject<{
|
|
|
22
22
|
includeMeshTerms: boolean;
|
|
23
23
|
includeGrantInfo: boolean;
|
|
24
24
|
outputFormat: "json" | "raw_text";
|
|
25
|
-
pmids?: string[] | undefined;
|
|
26
25
|
queryKey?: string | undefined;
|
|
27
26
|
webEnv?: string | undefined;
|
|
28
|
-
retstart?: number | undefined;
|
|
29
27
|
retmax?: number | undefined;
|
|
30
|
-
|
|
28
|
+
retstart?: number | undefined;
|
|
31
29
|
pmids?: string[] | undefined;
|
|
30
|
+
}, {
|
|
32
31
|
queryKey?: string | undefined;
|
|
33
32
|
webEnv?: string | undefined;
|
|
34
|
-
retstart?: number | undefined;
|
|
35
33
|
retmax?: number | undefined;
|
|
34
|
+
retstart?: number | undefined;
|
|
35
|
+
pmids?: string[] | undefined;
|
|
36
36
|
detailLevel?: "abstract_plus" | "full_xml" | "medline_text" | "citation_data" | undefined;
|
|
37
37
|
includeMeshTerms?: boolean | undefined;
|
|
38
38
|
includeGrantInfo?: boolean | undefined;
|
|
@@ -42,17 +42,17 @@ export declare const FetchPubMedContentInputSchema: z.ZodEffects<z.ZodObject<{
|
|
|
42
42
|
includeMeshTerms: boolean;
|
|
43
43
|
includeGrantInfo: boolean;
|
|
44
44
|
outputFormat: "json" | "raw_text";
|
|
45
|
-
pmids?: string[] | undefined;
|
|
46
45
|
queryKey?: string | undefined;
|
|
47
46
|
webEnv?: string | undefined;
|
|
48
|
-
retstart?: number | undefined;
|
|
49
47
|
retmax?: number | undefined;
|
|
50
|
-
|
|
48
|
+
retstart?: number | undefined;
|
|
51
49
|
pmids?: string[] | undefined;
|
|
50
|
+
}, {
|
|
52
51
|
queryKey?: string | undefined;
|
|
53
52
|
webEnv?: string | undefined;
|
|
54
|
-
retstart?: number | undefined;
|
|
55
53
|
retmax?: number | undefined;
|
|
54
|
+
retstart?: number | undefined;
|
|
55
|
+
pmids?: string[] | undefined;
|
|
56
56
|
detailLevel?: "abstract_plus" | "full_xml" | "medline_text" | "citation_data" | undefined;
|
|
57
57
|
includeMeshTerms?: boolean | undefined;
|
|
58
58
|
includeGrantInfo?: boolean | undefined;
|
|
@@ -242,13 +242,14 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
else if (input.detailLevel === "full_xml") {
|
|
245
|
+
const articlesXml = ensureArray(eFetchResponseData?.PubmedArticleSet?.PubmedArticle || []);
|
|
246
|
+
articlesCount = articlesXml.length;
|
|
245
247
|
if (input.outputFormat === "raw_text") {
|
|
248
|
+
// Note: Raw XML output is requested, but we still parse to get an accurate count.
|
|
249
|
+
// This is a trade-off for robustness over performance in this specific case.
|
|
246
250
|
finalOutputText = String(eFetchResponseData);
|
|
247
|
-
articlesCount = (finalOutputText.match(/<PubmedArticle>/g) || []).length;
|
|
248
251
|
}
|
|
249
252
|
else {
|
|
250
|
-
const articlesXml = ensureArray(eFetchResponseData?.PubmedArticleSet?.PubmedArticle || []);
|
|
251
|
-
articlesCount = articlesXml.length;
|
|
252
253
|
const foundPmidsInXml = new Set();
|
|
253
254
|
const articlesPayload = articlesXml.map((articleXml) => {
|
|
254
255
|
const pmid = extractPmid(articleXml.MedlineCitation) || "unknown_pmid";
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Core logic for the generate_pubmed_chart tool.
|
|
3
|
-
* Generates charts from parameterized input by creating Chart.js configurations
|
|
4
|
-
* and rendering them on the server using chartjs-node-canvas.
|
|
5
|
-
* @module src/mcp-server/tools/generatePubMedChart/logic
|
|
6
|
-
*/
|
|
7
1
|
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
|
|
8
2
|
import { z } from "zod";
|
|
9
3
|
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
@@ -96,8 +90,8 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
|
|
|
96
90
|
const groupedData = groupDataBySeries(dataValues, xField, yField, seriesField);
|
|
97
91
|
datasets = Array.from(groupedData.entries()).map(([seriesName, data]) => ({
|
|
98
92
|
label: seriesName,
|
|
99
|
-
data: labels.map(label => {
|
|
100
|
-
const point = data.find(p => p.x === label);
|
|
93
|
+
data: labels.map((label) => {
|
|
94
|
+
const point = data.find((p) => p.x === label);
|
|
101
95
|
return point ? point.y : null;
|
|
102
96
|
}),
|
|
103
97
|
// You can add backgroundColor, borderColor etc. here for styling
|
|
@@ -107,41 +101,45 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
|
|
|
107
101
|
datasets = [
|
|
108
102
|
{
|
|
109
103
|
label: yField,
|
|
110
|
-
data: labels.map(label => {
|
|
111
|
-
const item = dataValues.find(d => d[xField] === label);
|
|
104
|
+
data: labels.map((label) => {
|
|
105
|
+
const item = dataValues.find((d) => d[xField] === label);
|
|
112
106
|
return item ? item[yField] : null;
|
|
113
107
|
}),
|
|
114
108
|
},
|
|
115
109
|
];
|
|
116
110
|
}
|
|
117
111
|
// For scatter and bubble charts, the data format is different
|
|
118
|
-
if (chartType ===
|
|
112
|
+
if (chartType === "scatter" || chartType === "bubble") {
|
|
119
113
|
if (seriesField) {
|
|
120
114
|
const groupedData = groupDataBySeries(dataValues, xField, yField, seriesField);
|
|
121
115
|
datasets = Array.from(groupedData.entries()).map(([seriesName, data]) => ({
|
|
122
116
|
label: seriesName,
|
|
123
|
-
data: data.map(point => ({
|
|
117
|
+
data: data.map((point) => ({
|
|
124
118
|
x: point.x,
|
|
125
119
|
y: point.y,
|
|
126
|
-
r: chartType ===
|
|
120
|
+
r: chartType === "bubble" && sizeField
|
|
121
|
+
? dataValues.find((d) => d[xField] === point.x)[sizeField]
|
|
122
|
+
: undefined,
|
|
127
123
|
})),
|
|
128
124
|
}));
|
|
129
125
|
}
|
|
130
126
|
else {
|
|
131
|
-
datasets = [
|
|
127
|
+
datasets = [
|
|
128
|
+
{
|
|
132
129
|
label: yField,
|
|
133
|
-
data: dataValues.map(item => ({
|
|
130
|
+
data: dataValues.map((item) => ({
|
|
134
131
|
x: item[xField],
|
|
135
132
|
y: item[yField],
|
|
136
|
-
r: chartType ===
|
|
133
|
+
r: chartType === "bubble" && sizeField ? item[sizeField] : undefined,
|
|
137
134
|
})),
|
|
138
|
-
}
|
|
135
|
+
},
|
|
136
|
+
];
|
|
139
137
|
}
|
|
140
138
|
}
|
|
141
139
|
const configuration = {
|
|
142
140
|
type: chartType,
|
|
143
141
|
data: {
|
|
144
|
-
labels:
|
|
142
|
+
labels: chartType !== "scatter" && chartType !== "bubble" ? labels : undefined,
|
|
145
143
|
datasets: datasets,
|
|
146
144
|
},
|
|
147
145
|
options: {
|
|
@@ -151,7 +149,9 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
|
|
|
151
149
|
text: title,
|
|
152
150
|
},
|
|
153
151
|
},
|
|
154
|
-
scales: chartType === "pie" ||
|
|
152
|
+
scales: chartType === "pie" ||
|
|
153
|
+
chartType === "doughnut" ||
|
|
154
|
+
chartType === "polarArea"
|
|
155
155
|
? undefined
|
|
156
156
|
: {
|
|
157
157
|
x: {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Fetches article details using EFetch and formats them into various citation styles.
|
|
4
4
|
* @module src/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter
|
|
5
5
|
*/
|
|
6
|
+
import Cite from "citation-js";
|
|
6
7
|
import { getNcbiService } from "../../../../services/NCBI/ncbiService.js";
|
|
7
8
|
import { logger, requestContextService, } from "../../../../utils/index.js";
|
|
8
9
|
import { extractAuthors, extractDoi, extractJournalInfo, extractPmid, getText, } from "../../../../utils/parsing/ncbi-parsing/index.js";
|
|
@@ -12,7 +13,6 @@ export async function handleCitationFormats(input, outputData, context) {
|
|
|
12
13
|
db: "pubmed",
|
|
13
14
|
id: input.sourcePmid,
|
|
14
15
|
retmode: "xml",
|
|
15
|
-
// Omitting rettype to hopefully get the fullest XML record by default
|
|
16
16
|
};
|
|
17
17
|
const eFetchBaseUrl = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi";
|
|
18
18
|
const searchParamsString = new URLSearchParams(eFetchParams).toString();
|
|
@@ -26,264 +26,89 @@ export async function handleCitationFormats(input, outputData, context) {
|
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
const article = eFetchResult.PubmedArticleSet.PubmedArticle[0];
|
|
29
|
+
const csl = pubmedArticleToCsl(article, context);
|
|
30
|
+
const cite = new Cite(csl);
|
|
29
31
|
if (input.citationStyles?.includes("ris")) {
|
|
30
|
-
outputData.citations.ris =
|
|
32
|
+
outputData.citations.ris = cite.format("ris");
|
|
31
33
|
}
|
|
32
34
|
if (input.citationStyles?.includes("bibtex")) {
|
|
33
|
-
outputData.citations.bibtex =
|
|
35
|
+
outputData.citations.bibtex = cite.format("bibtex");
|
|
34
36
|
}
|
|
35
37
|
if (input.citationStyles?.includes("apa_string")) {
|
|
36
|
-
outputData.citations.apa_string =
|
|
38
|
+
outputData.citations.apa_string = cite.format("bibliography", {
|
|
39
|
+
format: "text",
|
|
40
|
+
template: "apa",
|
|
41
|
+
});
|
|
37
42
|
}
|
|
38
43
|
if (input.citationStyles?.includes("mla_string")) {
|
|
39
|
-
outputData.citations.mla_string =
|
|
44
|
+
outputData.citations.mla_string = cite.format("bibliography", {
|
|
45
|
+
format: "text",
|
|
46
|
+
template: "mla",
|
|
47
|
+
});
|
|
40
48
|
}
|
|
41
49
|
outputData.retrievedCount = 1;
|
|
42
50
|
}
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Converts an XML PubMed Article object to a CSL-JSON object.
|
|
53
|
+
* @param article The PubMed article in XML format.
|
|
54
|
+
* @param context The request context for logging.
|
|
55
|
+
* @returns A CSL-JSON object compatible with citation-js.
|
|
56
|
+
*/
|
|
57
|
+
function pubmedArticleToCsl(article, context) {
|
|
45
58
|
const medlineCitation = article.MedlineCitation;
|
|
46
59
|
const articleDetails = medlineCitation?.Article;
|
|
47
60
|
const pmid = extractPmid(medlineCitation);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const authors = extractAuthors(articleDetails.AuthorList);
|
|
52
|
-
const journalInfo = extractJournalInfo(articleDetails.Journal, medlineCitation);
|
|
53
|
-
const title = getText(articleDetails.ArticleTitle);
|
|
54
|
-
const doi = extractDoi(articleDetails);
|
|
55
|
-
let risString = "TY - JOUR\n";
|
|
56
|
-
authors.forEach((author) => {
|
|
57
|
-
if (author.lastName && author.firstName) {
|
|
58
|
-
risString += `AU - ${author.lastName}, ${author.firstName}\n`;
|
|
59
|
-
}
|
|
60
|
-
else if (author.lastName) {
|
|
61
|
-
// Handle collective name or incomplete author
|
|
62
|
-
risString += `AU - ${author.lastName}\n`;
|
|
63
|
-
}
|
|
61
|
+
const cslContext = requestContextService.createRequestContext({
|
|
62
|
+
...context,
|
|
63
|
+
pmid,
|
|
64
64
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (journalInfo?.pages) {
|
|
70
|
-
const pages = journalInfo.pages.split("-");
|
|
71
|
-
risString += `SP - ${pages[0] || ""}\n`;
|
|
72
|
-
if (pages.length > 1)
|
|
73
|
-
risString += `EP - ${pages[1] || ""}\n`;
|
|
65
|
+
logger.debug("Converting PubMed XML to CSL-JSON", cslContext);
|
|
66
|
+
if (!articleDetails) {
|
|
67
|
+
logger.warning("Article details not found for CSL conversion", cslContext);
|
|
68
|
+
return { id: pmid || "unknown", title: "Article details not found" };
|
|
74
69
|
}
|
|
75
|
-
|
|
76
|
-
if (doi)
|
|
77
|
-
risString += `DO - ${doi}\n`;
|
|
78
|
-
if (pmid)
|
|
79
|
-
risString += `UR - https://pubmed.ncbi.nlm.nih.gov/${pmid}\n`;
|
|
80
|
-
risString += "ER - \n";
|
|
81
|
-
return risString;
|
|
82
|
-
}
|
|
83
|
-
function formatAsBibTeX(article, context) {
|
|
84
|
-
const medlineCitation = article.MedlineCitation;
|
|
85
|
-
const articleDetails = medlineCitation?.Article;
|
|
86
|
-
const pmid = extractPmid(medlineCitation);
|
|
87
|
-
logger.debug("Formatting BibTeX for article", requestContextService.createRequestContext({ ...context, pmid }));
|
|
88
|
-
if (!articleDetails)
|
|
89
|
-
return `@article{ERROR_ArticleNotFound${pmid || ""},\n title = {Article details not found}\n}\n`;
|
|
90
|
-
const parsedAuthors = extractAuthors(articleDetails.AuthorList);
|
|
91
|
-
const journalInfo = extractJournalInfo(articleDetails.Journal, medlineCitation);
|
|
92
|
-
const title = getText(articleDetails.ArticleTitle) || "N/A";
|
|
93
|
-
const doi = extractDoi(articleDetails);
|
|
94
|
-
const authorsBibtex = parsedAuthors
|
|
95
|
-
.map((author) => {
|
|
96
|
-
if (author.lastName && author.firstName)
|
|
97
|
-
return `${author.lastName}, ${author.firstName}`;
|
|
98
|
-
if (author.lastName)
|
|
99
|
-
return `{${author.lastName}}`; // For collective names or single names
|
|
100
|
-
return "";
|
|
101
|
-
})
|
|
102
|
-
.filter(Boolean)
|
|
103
|
-
.join(" and ");
|
|
104
|
-
const year = journalInfo?.publicationDate?.year || "ND";
|
|
105
|
-
const firstAuthorLastName = parsedAuthors[0]?.lastName?.replace(/\s+/g, "") || "Unknown";
|
|
106
|
-
const bibKey = `${firstAuthorLastName}${year}`;
|
|
107
|
-
let bibtexString = `@article{${bibKey},\n`;
|
|
108
|
-
if (authorsBibtex)
|
|
109
|
-
bibtexString += ` author = {${authorsBibtex}},\n`;
|
|
110
|
-
bibtexString += ` title = {${title}},\n`;
|
|
111
|
-
bibtexString += ` journal = {${journalInfo?.title || "N/A"}},\n`;
|
|
112
|
-
if (journalInfo?.publicationDate?.year)
|
|
113
|
-
bibtexString += ` year = {${journalInfo.publicationDate.year}},\n`;
|
|
114
|
-
if (journalInfo?.volume)
|
|
115
|
-
bibtexString += ` volume = {${journalInfo.volume}},\n`;
|
|
116
|
-
if (journalInfo?.issue)
|
|
117
|
-
bibtexString += ` number = {${journalInfo.issue}},\n`;
|
|
118
|
-
if (journalInfo?.pages)
|
|
119
|
-
bibtexString += ` pages = {${journalInfo.pages.replace("-", "--")}},\n`;
|
|
120
|
-
if (journalInfo?.publicationDate?.month)
|
|
121
|
-
bibtexString += ` month = {${journalInfo.publicationDate.month.toLowerCase()}},\n`;
|
|
122
|
-
if (doi)
|
|
123
|
-
bibtexString += ` doi = {${doi}},\n`;
|
|
124
|
-
if (pmid)
|
|
125
|
-
bibtexString += ` pmid = {${pmid}}\n`;
|
|
126
|
-
bibtexString += `}\n`;
|
|
127
|
-
return bibtexString;
|
|
128
|
-
}
|
|
129
|
-
function formatAsAPA(article, context) {
|
|
130
|
-
const medlineCitation = article.MedlineCitation;
|
|
131
|
-
const articleDetails = medlineCitation?.Article;
|
|
132
|
-
const pmid = extractPmid(medlineCitation);
|
|
133
|
-
logger.debug("Formatting APA for article", requestContextService.createRequestContext({ ...context, pmid }));
|
|
134
|
-
if (!articleDetails)
|
|
135
|
-
return "Article details not found.";
|
|
136
|
-
const parsedAuthors = extractAuthors(articleDetails.AuthorList);
|
|
137
|
-
const journalInfo = extractJournalInfo(articleDetails.Journal, medlineCitation);
|
|
138
|
-
const titleText = getText(articleDetails.ArticleTitle) || "N/A";
|
|
139
|
-
const doi = extractDoi(articleDetails);
|
|
140
|
-
let authorsString = "N/A";
|
|
141
|
-
if (parsedAuthors.length > 0) {
|
|
142
|
-
if (parsedAuthors.length <= 20) {
|
|
143
|
-
authorsString = parsedAuthors
|
|
144
|
-
.map((author) => {
|
|
145
|
-
if (author.lastName && author.firstName) {
|
|
146
|
-
const initials = author.firstName
|
|
147
|
-
.split(/\s+|-/)
|
|
148
|
-
.map((namePart) => namePart.charAt(0).toUpperCase() + ".")
|
|
149
|
-
.join("");
|
|
150
|
-
return `${author.lastName}, ${initials}`;
|
|
151
|
-
}
|
|
152
|
-
if (author.lastName)
|
|
153
|
-
return author.lastName; // Collective name
|
|
154
|
-
return "";
|
|
155
|
-
})
|
|
156
|
-
.filter(Boolean)
|
|
157
|
-
.join(", ");
|
|
158
|
-
if (parsedAuthors.length > 1) {
|
|
159
|
-
const lastCommaIndex = authorsString.lastIndexOf(", ");
|
|
160
|
-
if (lastCommaIndex !== -1) {
|
|
161
|
-
authorsString =
|
|
162
|
-
authorsString.substring(0, lastCommaIndex) +
|
|
163
|
-
" & " +
|
|
164
|
-
authorsString.substring(lastCommaIndex + 2);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
const first19 = parsedAuthors
|
|
170
|
-
.slice(0, 19)
|
|
171
|
-
.map((author) => {
|
|
172
|
-
if (author.lastName && author.firstName) {
|
|
173
|
-
const initials = author.firstName
|
|
174
|
-
.split(/\s+|-/)
|
|
175
|
-
.map((namePart) => namePart.charAt(0).toUpperCase() + ".")
|
|
176
|
-
.join("");
|
|
177
|
-
return `${author.lastName}, ${initials}`;
|
|
178
|
-
}
|
|
179
|
-
if (author.lastName)
|
|
180
|
-
return author.lastName;
|
|
181
|
-
return "";
|
|
182
|
-
})
|
|
183
|
-
.filter(Boolean)
|
|
184
|
-
.join(", ");
|
|
185
|
-
const lastAuthor = parsedAuthors[parsedAuthors.length - 1];
|
|
186
|
-
let lastAuthorString = "";
|
|
187
|
-
if (lastAuthor.lastName && lastAuthor.firstName) {
|
|
188
|
-
const initials = lastAuthor.firstName
|
|
189
|
-
.split(/\s+|-/)
|
|
190
|
-
.map((namePart) => namePart.charAt(0).toUpperCase() + ".")
|
|
191
|
-
.join("");
|
|
192
|
-
lastAuthorString = `${lastAuthor.lastName}, ${initials}`;
|
|
193
|
-
}
|
|
194
|
-
else if (lastAuthor.lastName) {
|
|
195
|
-
lastAuthorString = lastAuthor.lastName;
|
|
196
|
-
}
|
|
197
|
-
authorsString = `${first19}, ..., ${lastAuthorString}`;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const year = journalInfo?.publicationDate?.year || "n.d.";
|
|
201
|
-
const apaTitle = titleText.charAt(0).toUpperCase() + titleText.slice(1); // APA typically sentence case for article titles.
|
|
202
|
-
const journal = journalInfo?.title || "N/A";
|
|
203
|
-
const volume = journalInfo?.volume || "";
|
|
204
|
-
const issue = journalInfo?.issue ? `(${journalInfo.issue})` : "";
|
|
205
|
-
const pages = journalInfo?.pages || "";
|
|
206
|
-
const doiLink = doi ? ` https://doi.org/${doi}` : "";
|
|
207
|
-
let apaString = `${authorsString}. (${year}). ${apaTitle}. ${journal}`;
|
|
208
|
-
if (volume)
|
|
209
|
-
apaString += `, ${volume}`;
|
|
210
|
-
if (issue)
|
|
211
|
-
apaString += issue;
|
|
212
|
-
if (pages)
|
|
213
|
-
apaString += `, ${pages}`;
|
|
214
|
-
apaString += `.${doiLink}`;
|
|
215
|
-
return apaString;
|
|
216
|
-
}
|
|
217
|
-
function formatAsMLA(article, context) {
|
|
218
|
-
const medlineCitation = article.MedlineCitation;
|
|
219
|
-
const articleDetails = medlineCitation?.Article;
|
|
220
|
-
const pmid = extractPmid(medlineCitation);
|
|
221
|
-
logger.debug("Formatting MLA for article", requestContextService.createRequestContext({ ...context, pmid }));
|
|
222
|
-
if (!articleDetails)
|
|
223
|
-
return "Article details not found.";
|
|
224
|
-
const parsedAuthors = extractAuthors(articleDetails.AuthorList);
|
|
70
|
+
const authors = extractAuthors(articleDetails.AuthorList);
|
|
225
71
|
const journalInfo = extractJournalInfo(articleDetails.Journal, medlineCitation);
|
|
226
|
-
const
|
|
72
|
+
const title = getText(articleDetails.ArticleTitle);
|
|
227
73
|
const doi = extractDoi(articleDetails);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
74
|
+
const cslAuthors = authors.map((author) => author.collectiveName
|
|
75
|
+
? { literal: author.collectiveName }
|
|
76
|
+
: { family: author.lastName, given: author.firstName });
|
|
77
|
+
const dateParts = [];
|
|
78
|
+
if (journalInfo?.publicationDate?.year) {
|
|
79
|
+
dateParts.push(parseInt(journalInfo.publicationDate.year, 10));
|
|
80
|
+
if (journalInfo.publicationDate.month) {
|
|
81
|
+
// Convert month name/number to number
|
|
82
|
+
const monthNumber = new Date(`${journalInfo.publicationDate.month} 1, 2000`).getMonth();
|
|
83
|
+
if (!isNaN(monthNumber)) {
|
|
84
|
+
dateParts.push(monthNumber + 1);
|
|
85
|
+
if (journalInfo.publicationDate.day) {
|
|
86
|
+
dateParts.push(parseInt(journalInfo.publicationDate.day, 10));
|
|
237
87
|
}
|
|
238
|
-
if (author.lastName)
|
|
239
|
-
return author.lastName;
|
|
240
|
-
return "";
|
|
241
|
-
})
|
|
242
|
-
.filter(Boolean)
|
|
243
|
-
.join(" and ");
|
|
244
|
-
}
|
|
245
|
-
else {
|
|
246
|
-
const firstAuthor = parsedAuthors[0];
|
|
247
|
-
if (firstAuthor.lastName && firstAuthor.firstName) {
|
|
248
|
-
authorsString = `${firstAuthor.lastName}, ${firstAuthor.firstName}, et al`;
|
|
249
|
-
}
|
|
250
|
-
else if (firstAuthor.lastName) {
|
|
251
|
-
authorsString = `${firstAuthor.lastName}, et al`;
|
|
252
88
|
}
|
|
253
89
|
}
|
|
254
90
|
}
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
91
|
+
const cslData = {
|
|
92
|
+
id: pmid,
|
|
93
|
+
type: "article-journal",
|
|
94
|
+
title: title,
|
|
95
|
+
author: cslAuthors,
|
|
96
|
+
issued: {
|
|
97
|
+
"date-parts": [dateParts],
|
|
98
|
+
},
|
|
99
|
+
"container-title": journalInfo?.title,
|
|
100
|
+
volume: journalInfo?.volume,
|
|
101
|
+
issue: journalInfo?.issue,
|
|
102
|
+
page: journalInfo?.pages,
|
|
103
|
+
DOI: doi,
|
|
104
|
+
PMID: pmid,
|
|
105
|
+
URL: pmid ? `https://pubmed.ncbi.nlm.nih.gov/${pmid}` : undefined,
|
|
106
|
+
};
|
|
107
|
+
// Clean up any undefined/null properties
|
|
108
|
+
for (const key in cslData) {
|
|
109
|
+
if (cslData[key] === undefined || cslData[key] === null) {
|
|
110
|
+
delete cslData[key];
|
|
263
111
|
}
|
|
264
112
|
}
|
|
265
|
-
|
|
266
|
-
publicationDateString = journalInfo.publicationDate.medlineDate;
|
|
267
|
-
}
|
|
268
|
-
const volume = journalInfo?.volume ? `vol. ${journalInfo.volume}` : "";
|
|
269
|
-
const issue = journalInfo?.issue ? `no. ${journalInfo.issue}` : "";
|
|
270
|
-
const pages = journalInfo?.pages
|
|
271
|
-
? `pp. ${journalInfo.pages.replace("-", "–")}`
|
|
272
|
-
: ""; // en-dash for MLA
|
|
273
|
-
const accessUrl = doi
|
|
274
|
-
? `doi:${doi}`
|
|
275
|
-
: pmid
|
|
276
|
-
? `https://pubmed.ncbi.nlm.nih.gov/${pmid}`
|
|
277
|
-
: "";
|
|
278
|
-
let mlaString = `${authorsString}. ${title} ${journal}`;
|
|
279
|
-
if (volume)
|
|
280
|
-
mlaString += `, ${volume}`;
|
|
281
|
-
if (issue)
|
|
282
|
-
mlaString += `, ${issue}`;
|
|
283
|
-
if (publicationDateString)
|
|
284
|
-
mlaString += `, ${publicationDateString}`;
|
|
285
|
-
if (pages)
|
|
286
|
-
mlaString += `, ${pages}`;
|
|
287
|
-
mlaString += `. PubMed Central, ${accessUrl}.`; // Assuming PubMed Central or just PubMed
|
|
288
|
-
return mlaString;
|
|
113
|
+
return cslData;
|
|
289
114
|
}
|
|
@@ -99,7 +99,7 @@ export async function searchPubMedArticlesLogic(input, parentRequestContext) {
|
|
|
99
99
|
sort: input.sortBy,
|
|
100
100
|
usehistory: currentFetchBriefSummaries > 0 ? "y" : "n",
|
|
101
101
|
};
|
|
102
|
-
const
|
|
102
|
+
const esResult = await ncbiService.eSearch(eSearchParams, toolLogicContext);
|
|
103
103
|
const eSearchBase = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi";
|
|
104
104
|
const eSearchQueryStringParams = {};
|
|
105
105
|
for (const key in eSearchParams) {
|
|
@@ -109,15 +109,14 @@ export async function searchPubMedArticlesLogic(input, parentRequestContext) {
|
|
|
109
109
|
}
|
|
110
110
|
const eSearchQueryString = new URLSearchParams(eSearchQueryStringParams).toString();
|
|
111
111
|
const eSearchUrl = `${eSearchBase}?${eSearchQueryString}`;
|
|
112
|
-
if (!
|
|
112
|
+
if (!esResult) {
|
|
113
113
|
throw new McpError(BaseErrorCode.NCBI_PARSING_ERROR, "Invalid or empty ESearch response from NCBI.", {
|
|
114
114
|
...toolLogicContext,
|
|
115
|
-
responsePreview: sanitizeInputForLogging(JSON.stringify(
|
|
115
|
+
responsePreview: sanitizeInputForLogging(JSON.stringify(esResult).substring(0, 200)),
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const totalFound = parseInt(esResult.Count || "0", 10);
|
|
118
|
+
const pmids = esResult.idList || [];
|
|
119
|
+
const totalFound = esResult.count || 0;
|
|
121
120
|
const retrievedPmidCount = pmids.length;
|
|
122
121
|
let briefSummaries = [];
|
|
123
122
|
let eSummaryUrl;
|
|
@@ -127,9 +126,9 @@ export async function searchPubMedArticlesLogic(input, parentRequestContext) {
|
|
|
127
126
|
version: "2.0",
|
|
128
127
|
retmode: "xml",
|
|
129
128
|
};
|
|
130
|
-
if (esResult.
|
|
131
|
-
eSummaryParams.WebEnv = esResult.
|
|
132
|
-
eSummaryParams.query_key = esResult.
|
|
129
|
+
if (esResult.webEnv && esResult.queryKey) {
|
|
130
|
+
eSummaryParams.WebEnv = esResult.webEnv;
|
|
131
|
+
eSummaryParams.query_key = esResult.queryKey;
|
|
133
132
|
eSummaryParams.retmax = currentFetchBriefSummaries;
|
|
134
133
|
}
|
|
135
134
|
else {
|
|
@@ -20,5 +20,5 @@ export declare class NcbiCoreApiClient {
|
|
|
20
20
|
* @returns A Promise resolving to the raw AxiosResponse.
|
|
21
21
|
* @throws {McpError} If the request fails after all retries or an unexpected error occurs.
|
|
22
22
|
*/
|
|
23
|
-
makeRequest(endpoint: string, params: NcbiRequestParams, context: RequestContext, options?: NcbiRequestOptions
|
|
23
|
+
makeRequest(endpoint: string, params: NcbiRequestParams, context: RequestContext, options?: NcbiRequestOptions): Promise<AxiosResponse>;
|
|
24
24
|
}
|
|
@@ -25,14 +25,13 @@ export class NcbiCoreApiClient {
|
|
|
25
25
|
* @returns A Promise resolving to the raw AxiosResponse.
|
|
26
26
|
* @throws {McpError} If the request fails after all retries or an unexpected error occurs.
|
|
27
27
|
*/
|
|
28
|
-
async makeRequest(endpoint, params, context, options = {}
|
|
28
|
+
async makeRequest(endpoint, params, context, options = {}) {
|
|
29
29
|
const rawParams = {
|
|
30
30
|
tool: config.ncbiToolIdentifier,
|
|
31
31
|
email: config.ncbiAdminEmail,
|
|
32
32
|
api_key: config.ncbiApiKey,
|
|
33
33
|
...params,
|
|
34
34
|
};
|
|
35
|
-
// Filter out undefined/null values and convert others to string for URLSearchParams/request body
|
|
36
35
|
const finalParams = {};
|
|
37
36
|
for (const key in rawParams) {
|
|
38
37
|
if (Object.prototype.hasOwnProperty.call(rawParams, key)) {
|
|
@@ -55,59 +54,64 @@ export class NcbiCoreApiClient {
|
|
|
55
54
|
else {
|
|
56
55
|
requestConfig.params = finalParams;
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
operation: "NCBI_HttpRequest",
|
|
62
|
-
endpoint,
|
|
63
|
-
method: requestConfig.method,
|
|
64
|
-
requestParams: sanitizeInputForLogging(finalParams),
|
|
65
|
-
attempt: retries + 1,
|
|
66
|
-
}));
|
|
67
|
-
const response = await this.axiosInstance(requestConfig);
|
|
68
|
-
return response;
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
if (retries < config.ncbiMaxRetries) {
|
|
72
|
-
const retryDelay = Math.pow(2, retries) * 200; // Increased base delay for retries
|
|
73
|
-
logger.warning(`NCBI request to ${endpoint} failed. Retrying (${retries + 1}/${config.ncbiMaxRetries}) in ${retryDelay}ms...`, requestContextService.createRequestContext({
|
|
57
|
+
for (let attempt = 0; attempt <= config.ncbiMaxRetries; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
logger.debug(`Making NCBI HTTP request: ${requestConfig.method} ${requestConfig.url}`, requestContextService.createRequestContext({
|
|
74
60
|
...context,
|
|
75
|
-
operation: "
|
|
61
|
+
operation: "NCBI_HttpRequest",
|
|
76
62
|
endpoint,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
delay: retryDelay,
|
|
63
|
+
method: requestConfig.method,
|
|
64
|
+
requestParams: sanitizeInputForLogging(finalParams),
|
|
65
|
+
attempt: attempt + 1,
|
|
81
66
|
}));
|
|
82
|
-
|
|
83
|
-
return
|
|
67
|
+
const response = await this.axiosInstance(requestConfig);
|
|
68
|
+
return response;
|
|
84
69
|
}
|
|
85
|
-
|
|
86
|
-
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (attempt < config.ncbiMaxRetries) {
|
|
72
|
+
const retryDelay = Math.pow(2, attempt) * 200;
|
|
73
|
+
logger.warning(`NCBI request to ${endpoint} failed. Retrying (${attempt + 1}/${config.ncbiMaxRetries}) in ${retryDelay}ms...`, requestContextService.createRequestContext({
|
|
74
|
+
...context,
|
|
75
|
+
operation: "NCBI_HttpRequestRetry",
|
|
76
|
+
endpoint,
|
|
77
|
+
error: error.message,
|
|
78
|
+
retryCount: attempt + 1,
|
|
79
|
+
maxRetries: config.ncbiMaxRetries,
|
|
80
|
+
delay: retryDelay,
|
|
81
|
+
}));
|
|
82
|
+
await new Promise((r) => setTimeout(r, retryDelay));
|
|
83
|
+
continue; // Continue to the next iteration of the loop
|
|
84
|
+
}
|
|
85
|
+
// If all retries are exhausted, handle the final error
|
|
86
|
+
if (axios.isAxiosError(error)) {
|
|
87
|
+
logger.error(`Axios error during NCBI request to ${endpoint} after ${attempt} retries`, error, requestContextService.createRequestContext({
|
|
88
|
+
...context,
|
|
89
|
+
operation: "NCBI_AxiosError",
|
|
90
|
+
endpoint,
|
|
91
|
+
status: error.response?.status,
|
|
92
|
+
responseData: sanitizeInputForLogging(error.response?.data),
|
|
93
|
+
}));
|
|
94
|
+
throw new McpError(BaseErrorCode.NCBI_SERVICE_UNAVAILABLE, `NCBI request failed: ${error.message}`, {
|
|
95
|
+
endpoint,
|
|
96
|
+
status: error.response?.status,
|
|
97
|
+
details: error.response?.data
|
|
98
|
+
? String(error.response.data).substring(0, 500)
|
|
99
|
+
: undefined,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (error instanceof McpError)
|
|
103
|
+
throw error;
|
|
104
|
+
logger.error(`Unexpected error during NCBI request to ${endpoint} after ${attempt} retries`, error, requestContextService.createRequestContext({
|
|
87
105
|
...context,
|
|
88
|
-
operation: "
|
|
106
|
+
operation: "NCBI_UnexpectedError",
|
|
89
107
|
endpoint,
|
|
90
|
-
|
|
91
|
-
responseData: sanitizeInputForLogging(error.response?.data),
|
|
108
|
+
errorMessage: error.message,
|
|
92
109
|
}));
|
|
93
|
-
throw new McpError(BaseErrorCode.
|
|
94
|
-
endpoint,
|
|
95
|
-
status: error.response?.status,
|
|
96
|
-
details: error.response?.data
|
|
97
|
-
? String(error.response.data).substring(0, 500)
|
|
98
|
-
: undefined,
|
|
99
|
-
});
|
|
110
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Unexpected error communicating with NCBI: ${error.message}`, { endpoint });
|
|
100
111
|
}
|
|
101
|
-
// If it's already an McpError, rethrow it (could be from a previous stage if this function is used more broadly)
|
|
102
|
-
if (error instanceof McpError)
|
|
103
|
-
throw error;
|
|
104
|
-
logger.error(`Unexpected error during NCBI request to ${endpoint} after ${retries} retries`, error, requestContextService.createRequestContext({
|
|
105
|
-
...context,
|
|
106
|
-
operation: "NCBI_UnexpectedError",
|
|
107
|
-
endpoint,
|
|
108
|
-
errorMessage: error.message,
|
|
109
|
-
}));
|
|
110
|
-
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Unexpected error communicating with NCBI: ${error.message}`, { endpoint });
|
|
111
112
|
}
|
|
113
|
+
// This line should theoretically be unreachable, but it satisfies TypeScript's need
|
|
114
|
+
// for a return path if the loop completes without returning or throwing.
|
|
115
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Request failed after all retries.", { endpoint });
|
|
112
116
|
}
|
|
113
117
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* access PubMed data.
|
|
8
8
|
* @module src/services/NCBI/ncbiService
|
|
9
9
|
*/
|
|
10
|
+
import { ESearchResult, EFetchArticleSet } from "../../types-global/pubmedXml.js";
|
|
10
11
|
import { RequestContext } from "../../utils/index.js";
|
|
11
12
|
import { NcbiRequestParams, NcbiRequestOptions } from "./ncbiConstants.js";
|
|
12
13
|
export declare class NcbiService {
|
|
@@ -15,9 +16,9 @@ export declare class NcbiService {
|
|
|
15
16
|
private responseHandler;
|
|
16
17
|
constructor();
|
|
17
18
|
private performNcbiRequest;
|
|
18
|
-
eSearch(params: NcbiRequestParams, context: RequestContext): Promise<
|
|
19
|
+
eSearch(params: NcbiRequestParams, context: RequestContext): Promise<ESearchResult>;
|
|
19
20
|
eSummary(params: NcbiRequestParams, context: RequestContext): Promise<any>;
|
|
20
|
-
eFetch(params: NcbiRequestParams, context: RequestContext, options?: NcbiRequestOptions): Promise<
|
|
21
|
+
eFetch(params: NcbiRequestParams, context: RequestContext, options?: NcbiRequestOptions): Promise<EFetchArticleSet>;
|
|
21
22
|
eLink(params: NcbiRequestParams, context: RequestContext): Promise<any>;
|
|
22
23
|
eInfo(params: NcbiRequestParams, context: RequestContext): Promise<any>;
|
|
23
24
|
}
|
|
@@ -25,9 +25,21 @@ export class NcbiService {
|
|
|
25
25
|
return this.queueManager.enqueueRequest(task, context, endpoint, params);
|
|
26
26
|
}
|
|
27
27
|
async eSearch(params, context) {
|
|
28
|
-
|
|
28
|
+
const response = await this.performNcbiRequest("esearch", params, context, {
|
|
29
29
|
retmode: "xml",
|
|
30
30
|
});
|
|
31
|
+
const esResult = response.eSearchResult;
|
|
32
|
+
return {
|
|
33
|
+
count: parseInt(esResult.Count, 10) || 0,
|
|
34
|
+
retmax: parseInt(esResult.RetMax, 10) || 0,
|
|
35
|
+
retstart: parseInt(esResult.RetStart, 10) || 0,
|
|
36
|
+
queryKey: esResult.QueryKey,
|
|
37
|
+
webEnv: esResult.WebEnv,
|
|
38
|
+
idList: esResult.IdList?.Id || [],
|
|
39
|
+
queryTranslation: esResult.QueryTranslation,
|
|
40
|
+
errorList: esResult.ErrorList,
|
|
41
|
+
warningList: esResult.WarningList,
|
|
42
|
+
};
|
|
31
43
|
}
|
|
32
44
|
async eSummary(params, context) {
|
|
33
45
|
// Determine retmode based on params, default to xml
|
|
@@ -166,6 +166,7 @@ export interface ParsedArticleAuthor {
|
|
|
166
166
|
firstName?: string;
|
|
167
167
|
initials?: string;
|
|
168
168
|
affiliation?: string;
|
|
169
|
+
collectiveName?: string;
|
|
169
170
|
}
|
|
170
171
|
export interface ParsedArticleDate {
|
|
171
172
|
dateType?: string;
|
|
@@ -348,3 +349,17 @@ export interface ESearchResultContent {
|
|
|
348
349
|
export interface ESearchResponseContainer {
|
|
349
350
|
eSearchResult: ESearchResultContent;
|
|
350
351
|
}
|
|
352
|
+
export interface ESearchResult {
|
|
353
|
+
count: number;
|
|
354
|
+
retmax: number;
|
|
355
|
+
retstart: number;
|
|
356
|
+
queryKey?: string;
|
|
357
|
+
webEnv?: string;
|
|
358
|
+
idList: string[];
|
|
359
|
+
queryTranslation: string;
|
|
360
|
+
errorList?: ESearchErrorList;
|
|
361
|
+
warningList?: ESearchWarningList;
|
|
362
|
+
}
|
|
363
|
+
export interface EFetchArticleSet {
|
|
364
|
+
articles: ParsedArticle[];
|
|
365
|
+
}
|
|
@@ -35,8 +35,11 @@ const mcpToWinstonLevel = {
|
|
|
35
35
|
alert: "error",
|
|
36
36
|
emerg: "error",
|
|
37
37
|
};
|
|
38
|
+
// The logsPath from config is already resolved and validated by src/config/index.ts
|
|
39
|
+
const resolvedLogsDir = config.logsPath;
|
|
40
|
+
const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic.
|
|
38
41
|
/**
|
|
39
|
-
* Creates the Winston console log format
|
|
42
|
+
* Creates the Winston console log format.
|
|
40
43
|
* @returns The Winston log format for console output.
|
|
41
44
|
* @private
|
|
42
45
|
*/
|
|
@@ -100,45 +103,47 @@ export class Logger {
|
|
|
100
103
|
});
|
|
101
104
|
return;
|
|
102
105
|
}
|
|
106
|
+
// Set initialized to true at the beginning of the initialization process.
|
|
103
107
|
this.initialized = true;
|
|
104
108
|
this.currentMcpLevel = level;
|
|
105
109
|
this.currentWinstonLevel = mcpToWinstonLevel[level];
|
|
110
|
+
// The logs directory (config.logsPath / resolvedLogsDir) is expected to be created and validated
|
|
111
|
+
// by the configuration module (src/config/index.ts) before logger initialization.
|
|
112
|
+
// If isLogsDirSafe is true, we assume resolvedLogsDir exists and is usable.
|
|
113
|
+
// No redundant directory creation logic here.
|
|
114
|
+
const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
|
|
106
115
|
const transports = [];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
const fileTransportOptions = {
|
|
117
|
+
format: fileFormat,
|
|
118
|
+
maxsize: this.LOG_FILE_MAX_SIZE,
|
|
119
|
+
maxFiles: this.LOG_MAX_FILES,
|
|
120
|
+
tailable: true,
|
|
121
|
+
};
|
|
122
|
+
if (isLogsDirSafe) {
|
|
123
|
+
transports.push(new winston.transports.File({
|
|
124
|
+
filename: path.join(resolvedLogsDir, "error.log"),
|
|
125
|
+
level: "error",
|
|
126
|
+
...fileTransportOptions,
|
|
127
|
+
}), new winston.transports.File({
|
|
128
|
+
filename: path.join(resolvedLogsDir, "warn.log"),
|
|
129
|
+
level: "warn",
|
|
130
|
+
...fileTransportOptions,
|
|
131
|
+
}), new winston.transports.File({
|
|
132
|
+
filename: path.join(resolvedLogsDir, "info.log"),
|
|
133
|
+
level: "info",
|
|
134
|
+
...fileTransportOptions,
|
|
135
|
+
}), new winston.transports.File({
|
|
136
|
+
filename: path.join(resolvedLogsDir, "debug.log"),
|
|
137
|
+
level: "debug",
|
|
138
|
+
...fileTransportOptions,
|
|
139
|
+
}), new winston.transports.File({
|
|
140
|
+
filename: path.join(resolvedLogsDir, "combined.log"),
|
|
141
|
+
...fileTransportOptions,
|
|
110
142
|
}));
|
|
111
143
|
}
|
|
112
144
|
else {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
|
|
116
|
-
const fileTransportOptions = {
|
|
117
|
-
format: fileFormat,
|
|
118
|
-
maxsize: this.LOG_FILE_MAX_SIZE,
|
|
119
|
-
maxFiles: this.LOG_MAX_FILES,
|
|
120
|
-
tailable: true,
|
|
121
|
-
};
|
|
122
|
-
transports.push(new winston.transports.File({
|
|
123
|
-
filename: path.join(resolvedLogsDir, "error.log"),
|
|
124
|
-
level: "error",
|
|
125
|
-
...fileTransportOptions,
|
|
126
|
-
}), new winston.transports.File({
|
|
127
|
-
filename: path.join(resolvedLogsDir, "warn.log"),
|
|
128
|
-
level: "warn",
|
|
129
|
-
...fileTransportOptions,
|
|
130
|
-
}), new winston.transports.File({
|
|
131
|
-
filename: path.join(resolvedLogsDir, "info.log"),
|
|
132
|
-
level: "info",
|
|
133
|
-
...fileTransportOptions,
|
|
134
|
-
}), new winston.transports.File({
|
|
135
|
-
filename: path.join(resolvedLogsDir, "debug.log"),
|
|
136
|
-
level: "debug",
|
|
137
|
-
...fileTransportOptions,
|
|
138
|
-
}), new winston.transports.File({
|
|
139
|
-
filename: path.join(resolvedLogsDir, "combined.log"),
|
|
140
|
-
...fileTransportOptions,
|
|
141
|
-
}));
|
|
145
|
+
if (process.stdout.isTTY) {
|
|
146
|
+
console.warn("File logging disabled as logsPath is not configured or invalid.");
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
149
|
this.winstonLogger = winston.createLogger({
|
|
@@ -146,20 +151,23 @@ export class Logger {
|
|
|
146
151
|
transports,
|
|
147
152
|
exitOnError: false,
|
|
148
153
|
});
|
|
154
|
+
// Configure console transport after Winston logger is created
|
|
149
155
|
const consoleStatus = this._configureConsoleTransport();
|
|
150
156
|
const initialContext = {
|
|
151
157
|
loggerSetup: true,
|
|
152
158
|
requestId: "logger-init-deferred",
|
|
153
159
|
timestamp: new Date().toISOString(),
|
|
154
160
|
};
|
|
161
|
+
// Removed logging of logsDirCreatedMessage as it's no longer set
|
|
155
162
|
if (consoleStatus.message) {
|
|
156
163
|
this.info(consoleStatus.message, initialContext);
|
|
157
164
|
}
|
|
158
|
-
this.
|
|
165
|
+
this.initialized = true; // Ensure this is set after successful setup
|
|
166
|
+
this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`, {
|
|
159
167
|
loggerSetup: true,
|
|
160
168
|
requestId: "logger-post-init",
|
|
161
169
|
timestamp: new Date().toISOString(),
|
|
162
|
-
logsPathUsed:
|
|
170
|
+
logsPathUsed: resolvedLogsDir,
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
/**
|
|
@@ -199,6 +207,7 @@ export class Logger {
|
|
|
199
207
|
this.currentMcpLevel = newLevel;
|
|
200
208
|
this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
|
|
201
209
|
if (this.winstonLogger) {
|
|
210
|
+
// Ensure winstonLogger is defined
|
|
202
211
|
this.winstonLogger.level = this.currentWinstonLevel;
|
|
203
212
|
}
|
|
204
213
|
const consoleStatus = this._configureConsoleTransport();
|
|
@@ -217,12 +226,6 @@ export class Logger {
|
|
|
217
226
|
* @private
|
|
218
227
|
*/
|
|
219
228
|
_configureConsoleTransport() {
|
|
220
|
-
if (config.logOutputMode === "stdout") {
|
|
221
|
-
return {
|
|
222
|
-
enabled: true,
|
|
223
|
-
message: "Stdout logging is enabled by configuration.",
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
229
|
if (!this.winstonLogger) {
|
|
227
230
|
return {
|
|
228
231
|
enabled: false,
|
|
@@ -235,19 +238,17 @@ export class Logger {
|
|
|
235
238
|
if (shouldHaveConsole && !consoleTransport) {
|
|
236
239
|
const consoleFormat = createWinstonConsoleFormat();
|
|
237
240
|
this.winstonLogger.add(new winston.transports.Console({
|
|
238
|
-
level: "debug",
|
|
241
|
+
level: "debug", // Console always logs debug if enabled
|
|
239
242
|
format: consoleFormat,
|
|
240
243
|
}));
|
|
241
|
-
message =
|
|
242
|
-
"Interactive console logging enabled (level: debug, stdout is TTY).";
|
|
244
|
+
message = "Console logging enabled (level: debug, stdout is TTY).";
|
|
243
245
|
}
|
|
244
246
|
else if (!shouldHaveConsole && consoleTransport) {
|
|
245
247
|
this.winstonLogger.remove(consoleTransport);
|
|
246
|
-
message =
|
|
247
|
-
"Interactive console logging disabled (level not debug or stdout not TTY).";
|
|
248
|
+
message = "Console logging disabled (level not debug or stdout not TTY).";
|
|
248
249
|
}
|
|
249
250
|
else {
|
|
250
|
-
message = "
|
|
251
|
+
message = "Console logging status unchanged.";
|
|
251
252
|
}
|
|
252
253
|
return { enabled: shouldHaveConsole, message };
|
|
253
254
|
}
|
|
@@ -287,7 +288,7 @@ export class Logger {
|
|
|
287
288
|
if (!this.ensureInitialized())
|
|
288
289
|
return;
|
|
289
290
|
if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
|
|
290
|
-
return;
|
|
291
|
+
return; // Do not log if message level is less severe than currentMcpLevel
|
|
291
292
|
}
|
|
292
293
|
const logData = { ...context };
|
|
293
294
|
const winstonLevel = mcpToWinstonLevel[level];
|
|
@@ -303,6 +304,7 @@ export class Logger {
|
|
|
303
304
|
mcpDataPayload.context = context;
|
|
304
305
|
if (error) {
|
|
305
306
|
mcpDataPayload.error = { message: error.message };
|
|
307
|
+
// Include stack trace in debug mode for MCP notifications, truncated for brevity
|
|
306
308
|
if (this.currentMcpLevel === "debug" && error.stack) {
|
|
307
309
|
mcpDataPayload.error.stack = error.stack.substring(0, this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH);
|
|
308
310
|
}
|
|
@@ -319,7 +321,7 @@ export class Logger {
|
|
|
319
321
|
originalLevel: level,
|
|
320
322
|
originalMessage: msg,
|
|
321
323
|
sendError: errorMessage,
|
|
322
|
-
mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500),
|
|
324
|
+
mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
|
|
323
325
|
};
|
|
324
326
|
this.winstonLogger.error("Failed to send MCP log notification", internalErrorContext);
|
|
325
327
|
}
|
|
@@ -14,6 +14,10 @@ export function extractAuthors(authorListXml) {
|
|
|
14
14
|
return [];
|
|
15
15
|
const authors = ensureArray(authorListXml.Author);
|
|
16
16
|
return authors.map((auth) => {
|
|
17
|
+
const collectiveName = getText(auth.CollectiveName);
|
|
18
|
+
if (collectiveName) {
|
|
19
|
+
return { collectiveName };
|
|
20
|
+
}
|
|
17
21
|
let affiliation = "";
|
|
18
22
|
const affiliations = ensureArray(auth.AffiliationInfo);
|
|
19
23
|
if (affiliations.length > 0) {
|
|
@@ -85,6 +85,7 @@ export declare class IdGenerator {
|
|
|
85
85
|
* @param id - The ID string to validate.
|
|
86
86
|
* @param entityType - The expected entity type of the ID.
|
|
87
87
|
* @param options - Optional parameters used during generation for validation consistency.
|
|
88
|
+
* The `charset` from these options will be used for validation.
|
|
88
89
|
* @returns `true` if the ID is valid, `false` otherwise.
|
|
89
90
|
*/
|
|
90
91
|
isValid(id: string, entityType: string, options?: IdGenerationOptions): boolean;
|
|
@@ -112,7 +113,9 @@ export declare class IdGenerator {
|
|
|
112
113
|
getEntityType(id: string, separator?: string): string;
|
|
113
114
|
/**
|
|
114
115
|
* Normalizes an entity ID to ensure the prefix matches the registered case
|
|
115
|
-
* and the random part is uppercase.
|
|
116
|
+
* and the random part is uppercase. Note: This assumes the charset characters
|
|
117
|
+
* have a meaningful uppercase version if case-insensitivity is desired for the random part.
|
|
118
|
+
* For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
|
|
116
119
|
* @param id - The ID to normalize (e.g., "proj_a6b3j0").
|
|
117
120
|
* @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
|
|
118
121
|
* @returns The normalized ID (e.g., "PROJ_A6B3J0").
|
|
@@ -60,10 +60,18 @@ export class IdGenerator {
|
|
|
60
60
|
* @returns The generated random string.
|
|
61
61
|
*/
|
|
62
62
|
generateRandomString(length = IdGenerator.DEFAULT_LENGTH, charset = IdGenerator.DEFAULT_CHARSET) {
|
|
63
|
-
const bytes = randomBytes(length);
|
|
64
63
|
let result = "";
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// Determine the largest multiple of charset.length that is less than or equal to 256
|
|
65
|
+
// This is the threshold for rejection sampling to avoid bias.
|
|
66
|
+
const maxValidByteValue = Math.floor(256 / charset.length) * charset.length;
|
|
67
|
+
while (result.length < length) {
|
|
68
|
+
const byteBuffer = randomBytes(1); // Get one random byte
|
|
69
|
+
const byte = byteBuffer[0];
|
|
70
|
+
// If the byte is within the valid range (i.e., it won't introduce bias),
|
|
71
|
+
// use it to select a character from the charset. Otherwise, discard and try again.
|
|
72
|
+
if (byte < maxValidByteValue) {
|
|
73
|
+
result += charset[byte % charset.length];
|
|
74
|
+
}
|
|
67
75
|
}
|
|
68
76
|
return result;
|
|
69
77
|
}
|
|
@@ -101,16 +109,21 @@ export class IdGenerator {
|
|
|
101
109
|
* @param id - The ID string to validate.
|
|
102
110
|
* @param entityType - The expected entity type of the ID.
|
|
103
111
|
* @param options - Optional parameters used during generation for validation consistency.
|
|
112
|
+
* The `charset` from these options will be used for validation.
|
|
104
113
|
* @returns `true` if the ID is valid, `false` otherwise.
|
|
105
114
|
*/
|
|
106
115
|
isValid(id, entityType, options = {}) {
|
|
107
116
|
const prefix = this.entityPrefixes[entityType];
|
|
108
|
-
const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR,
|
|
117
|
+
const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, charset = IdGenerator.DEFAULT_CHARSET, // Use charset from options or default
|
|
118
|
+
} = options;
|
|
109
119
|
if (!prefix) {
|
|
110
120
|
return false;
|
|
111
121
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
122
|
+
// Build regex character class from the charset
|
|
123
|
+
// Escape characters that have special meaning inside a regex character class `[]`
|
|
124
|
+
const escapedCharsetForClass = charset.replace(/[[\]\\^-]/g, "\\$&");
|
|
125
|
+
const charsetRegexPart = `[${escapedCharsetForClass}]`;
|
|
126
|
+
const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}${charsetRegexPart}{${length}}$`);
|
|
114
127
|
return pattern.test(id);
|
|
115
128
|
}
|
|
116
129
|
/**
|
|
@@ -153,7 +166,9 @@ export class IdGenerator {
|
|
|
153
166
|
}
|
|
154
167
|
/**
|
|
155
168
|
* Normalizes an entity ID to ensure the prefix matches the registered case
|
|
156
|
-
* and the random part is uppercase.
|
|
169
|
+
* and the random part is uppercase. Note: This assumes the charset characters
|
|
170
|
+
* have a meaningful uppercase version if case-insensitivity is desired for the random part.
|
|
171
|
+
* For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
|
|
157
172
|
* @param id - The ID to normalize (e.g., "proj_a6b3j0").
|
|
158
173
|
* @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
|
|
159
174
|
* @returns The normalized ID (e.g., "PROJ_A6B3J0").
|
|
@@ -164,6 +179,8 @@ export class IdGenerator {
|
|
|
164
179
|
const registeredPrefix = this.entityPrefixes[entityType];
|
|
165
180
|
const idParts = id.split(separator);
|
|
166
181
|
const randomPart = idParts.slice(1).join(separator);
|
|
182
|
+
// Consider if randomPart.toUpperCase() is always correct for custom charsets.
|
|
183
|
+
// For now, maintaining existing behavior.
|
|
167
184
|
return `${registeredPrefix}${separator}${randomPart.toUpperCase()}`;
|
|
168
185
|
}
|
|
169
186
|
}
|
|
@@ -28,10 +28,6 @@ export interface RateLimitEntry {
|
|
|
28
28
|
/**
|
|
29
29
|
* A generic rate limiter class using an in-memory store.
|
|
30
30
|
* Controls frequency of operations based on unique keys.
|
|
31
|
-
*
|
|
32
|
-
* @scalability Note: This is an in-memory store. For horizontal scaling across
|
|
33
|
-
* multiple processes or machines, this state would need to be moved to a shared,
|
|
34
|
-
* distributed store like Redis or a database.
|
|
35
31
|
*/
|
|
36
32
|
export declare class RateLimiter {
|
|
37
33
|
private config;
|
|
@@ -9,10 +9,6 @@ import { logger, requestContextService } from "../index.js";
|
|
|
9
9
|
/**
|
|
10
10
|
* A generic rate limiter class using an in-memory store.
|
|
11
11
|
* Controls frequency of operations based on unique keys.
|
|
12
|
-
*
|
|
13
|
-
* @scalability Note: This is an in-memory store. For horizontal scaling across
|
|
14
|
-
* multiple processes or machines, this state would need to be moved to a shared,
|
|
15
|
-
* distributed store like Redis or a database.
|
|
16
12
|
*/
|
|
17
13
|
export class RateLimiter {
|
|
18
14
|
/**
|
|
@@ -148,6 +148,17 @@ export declare class Sanitization {
|
|
|
148
148
|
* Sanitizes input for logging by redacting sensitive fields.
|
|
149
149
|
* Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
|
|
150
150
|
* (case-insensitive substring match) with "[REDACTED]".
|
|
151
|
+
*
|
|
152
|
+
* It uses `structuredClone` if available for a high-fidelity deep clone.
|
|
153
|
+
* If `structuredClone` is not available (e.g., in older Node.js environments),
|
|
154
|
+
* it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
|
|
155
|
+
* - `Date` objects are converted to ISO date strings.
|
|
156
|
+
* - `undefined` values within objects are removed.
|
|
157
|
+
* - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
|
|
158
|
+
* - Functions are removed.
|
|
159
|
+
* - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
|
|
160
|
+
* - Circular references will cause `JSON.stringify` to throw an error.
|
|
161
|
+
*
|
|
151
162
|
* @param input - The input data to sanitize for logging.
|
|
152
163
|
* @returns A sanitized (deep cloned) version of the input, safe for logging.
|
|
153
164
|
* Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
|
|
@@ -204,8 +204,11 @@ export class Sanitization {
|
|
|
204
204
|
})) {
|
|
205
205
|
throw new Error("Invalid URL format or protocol not in allowed list.");
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
const lowercasedInput = trimmedInput.toLowerCase();
|
|
208
|
+
if (lowercasedInput.startsWith("javascript:") ||
|
|
209
|
+
lowercasedInput.startsWith("data:") ||
|
|
210
|
+
lowercasedInput.startsWith("vbscript:")) {
|
|
211
|
+
throw new Error("Disallowed pseudo-protocol (javascript:, data:, or vbscript:) in URL.");
|
|
209
212
|
}
|
|
210
213
|
return trimmedInput;
|
|
211
214
|
}
|
|
@@ -377,6 +380,17 @@ export class Sanitization {
|
|
|
377
380
|
* Sanitizes input for logging by redacting sensitive fields.
|
|
378
381
|
* Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
|
|
379
382
|
* (case-insensitive substring match) with "[REDACTED]".
|
|
383
|
+
*
|
|
384
|
+
* It uses `structuredClone` if available for a high-fidelity deep clone.
|
|
385
|
+
* If `structuredClone` is not available (e.g., in older Node.js environments),
|
|
386
|
+
* it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
|
|
387
|
+
* - `Date` objects are converted to ISO date strings.
|
|
388
|
+
* - `undefined` values within objects are removed.
|
|
389
|
+
* - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
|
|
390
|
+
* - Functions are removed.
|
|
391
|
+
* - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
|
|
392
|
+
* - Circular references will cause `JSON.stringify` to throw an error.
|
|
393
|
+
*
|
|
380
394
|
* @param input - The input data to sanitize for logging.
|
|
381
395
|
* @returns A sanitized (deep cloned) version of the input, safe for logging.
|
|
382
396
|
* Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/pubmed-mcp-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server enabling AI agents to intelligently search, retrieve, and analyze biomedical literature from PubMed via NCBI E-utilities. Built on the mcp-ts-template for robust, production-ready performance.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -32,31 +32,32 @@
|
|
|
32
32
|
"inspector": "mcp-inspector --config mcp.json --server pubmed-mcp-server"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@hono/node-server": "^1.
|
|
36
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
35
|
+
"@hono/node-server": "^1.15.0",
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
37
37
|
"@types/jsonwebtoken": "^9.0.10",
|
|
38
|
-
"@types/node": "^24.0.
|
|
38
|
+
"@types/node": "^24.0.10",
|
|
39
39
|
"@types/sanitize-html": "^2.16.0",
|
|
40
40
|
"@types/validator": "13.15.2",
|
|
41
41
|
"axios": "^1.10.0",
|
|
42
|
+
"chart.js": "^4.5.0",
|
|
43
|
+
"chartjs-node-canvas": "^5.0.0",
|
|
42
44
|
"chrono-node": "^2.8.3",
|
|
45
|
+
"citation-js": "^0.7.20",
|
|
43
46
|
"dotenv": "^16.5.0",
|
|
44
47
|
"fast-xml-parser": "^5.2.5",
|
|
45
|
-
"hono": "^4.8.
|
|
48
|
+
"hono": "^4.8.4",
|
|
46
49
|
"jose": "^6.0.11",
|
|
47
50
|
"jsonwebtoken": "^9.0.2",
|
|
48
|
-
"openai": "^5.
|
|
51
|
+
"openai": "^5.8.2",
|
|
49
52
|
"partial-json": "^0.1.7",
|
|
50
53
|
"sanitize-html": "^2.17.0",
|
|
51
54
|
"tiktoken": "^1.0.21",
|
|
52
55
|
"ts-node": "^10.9.2",
|
|
53
56
|
"typescript": "^5.8.3",
|
|
54
57
|
"validator": "13.15.15",
|
|
55
|
-
"chart.js": "^4.5.0",
|
|
56
|
-
"chartjs-node-canvas": "^5.0.0",
|
|
57
58
|
"winston": "^3.17.0",
|
|
58
59
|
"winston-transport": "^4.9.0",
|
|
59
|
-
"zod": "^3.25.
|
|
60
|
+
"zod": "^3.25.74"
|
|
60
61
|
},
|
|
61
62
|
"keywords": [
|
|
62
63
|
"mcp",
|
|
@@ -98,7 +99,8 @@
|
|
|
98
99
|
"devDependencies": {
|
|
99
100
|
"@types/js-yaml": "^4.0.9",
|
|
100
101
|
"js-yaml": "^4.1.0",
|
|
101
|
-
"
|
|
102
|
-
"
|
|
102
|
+
"patch-package": "^8.0.0",
|
|
103
|
+
"prettier": "^3.6.2",
|
|
104
|
+
"typedoc": "^0.28.7"
|
|
103
105
|
}
|
|
104
106
|
}
|