@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.13.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-1.2.2-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-1.2.3-blue.svg)](./CHANGELOG.md)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/pubmed-mcp-server/issues)
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/pubmed-mcp-server?style=social)](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 === 'scatter' || chartType === 'bubble') {
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 === 'bubble' && sizeField ? dataValues.find(d => d[xField] === point.x)[sizeField] : undefined
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 === 'bubble' && sizeField ? item[sizeField] : undefined
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: (chartType !== 'scatter' && chartType !== 'bubble') ? labels : undefined,
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" || chartType === "doughnut" || chartType === "polarArea"
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 = formatAsRIS(article, context);
32
+ outputData.citations.ris = cite.format("ris");
31
33
  }
32
34
  if (input.citationStyles?.includes("bibtex")) {
33
- outputData.citations.bibtex = formatAsBibTeX(article, context);
35
+ outputData.citations.bibtex = cite.format("bibtex");
34
36
  }
35
37
  if (input.citationStyles?.includes("apa_string")) {
36
- outputData.citations.apa_string = formatAsAPA(article, context);
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 = formatAsMLA(article, context);
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
- // --- Citation Formatting Helper Functions ---
44
- function formatAsRIS(article, context) {
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
- logger.debug("Formatting RIS for article", requestContextService.createRequestContext({ ...context, pmid }));
49
- if (!articleDetails)
50
- return "TY - ERROR\nTI - Article details not found\nER - \n";
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
- risString += `TI - ${title || "N/A"}\n`;
66
- risString += `JO - ${journalInfo?.title || "N/A"}\n`;
67
- risString += `VL - ${journalInfo?.volume || ""}\n`;
68
- risString += `IS - ${journalInfo?.issue || ""}\n`;
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
- risString += `PY - ${journalInfo?.publicationDate?.year || ""}\n`;
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 titleText = getText(articleDetails.ArticleTitle);
72
+ const title = getText(articleDetails.ArticleTitle);
227
73
  const doi = extractDoi(articleDetails);
228
- let authorsString = "N/A";
229
- if (parsedAuthors.length > 0) {
230
- if (parsedAuthors.length <= 2) {
231
- authorsString = parsedAuthors
232
- .map((author, index) => {
233
- if (author.lastName && author.firstName) {
234
- return index === 0
235
- ? `${author.lastName}, ${author.firstName}`
236
- : `${author.firstName} ${author.lastName}`;
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 title = titleText ? `"${titleText}."` : "N/A.";
256
- const journal = journalInfo?.title || "N/A";
257
- let publicationDateString = journalInfo?.publicationDate?.year || "";
258
- if (journalInfo?.publicationDate?.month && journalInfo.publicationDate.year) {
259
- const month = journalInfo.publicationDate.month.substring(0, 3) + "."; // Abbreviate month
260
- publicationDateString = `${month} ${journalInfo.publicationDate.year}`;
261
- if (journalInfo.publicationDate.day) {
262
- publicationDateString = `${journalInfo.publicationDate.day} ${month} ${journalInfo.publicationDate.year}`;
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
- else if (journalInfo?.publicationDate?.medlineDate) {
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 eSearchResponse = await ncbiService.eSearch(eSearchParams, toolLogicContext);
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 (!eSearchResponse || !eSearchResponse.eSearchResult) {
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(eSearchResponse).substring(0, 200)),
115
+ responsePreview: sanitizeInputForLogging(JSON.stringify(esResult).substring(0, 200)),
116
116
  });
117
117
  }
118
- const esResult = eSearchResponse.eSearchResult;
119
- const pmids = esResult.IdList?.Id || [];
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.WebEnv && esResult.QueryKey) {
131
- eSummaryParams.WebEnv = esResult.WebEnv;
132
- eSummaryParams.query_key = esResult.QueryKey;
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, retries?: number): Promise<AxiosResponse>;
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 = {}, retries = 0) {
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
- try {
59
- logger.debug(`Making NCBI HTTP request: ${requestConfig.method} ${requestConfig.url}`, requestContextService.createRequestContext({
60
- ...context,
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: "NCBI_HttpRequestRetry",
61
+ operation: "NCBI_HttpRequest",
76
62
  endpoint,
77
- error: error.message,
78
- retryCount: retries + 1,
79
- maxRetries: config.ncbiMaxRetries,
80
- delay: retryDelay,
63
+ method: requestConfig.method,
64
+ requestParams: sanitizeInputForLogging(finalParams),
65
+ attempt: attempt + 1,
81
66
  }));
82
- await new Promise((r) => setTimeout(r, retryDelay));
83
- return this.makeRequest(endpoint, params, context, options, retries + 1);
67
+ const response = await this.axiosInstance(requestConfig);
68
+ return response;
84
69
  }
85
- if (axios.isAxiosError(error)) {
86
- logger.error(`Axios error during NCBI request to ${endpoint} after ${retries} retries`, error, requestContextService.createRequestContext({
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: "NCBI_AxiosError",
106
+ operation: "NCBI_UnexpectedError",
89
107
  endpoint,
90
- status: error.response?.status,
91
- responseData: sanitizeInputForLogging(error.response?.data),
108
+ errorMessage: error.message,
92
109
  }));
93
- throw new McpError(BaseErrorCode.NCBI_SERVICE_UNAVAILABLE, `NCBI request failed: ${error.message}`, {
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<any>;
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<any>;
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
- return this.performNcbiRequest("esearch", params, context, {
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 for interactive TTY sessions.
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
- if (config.logOutputMode === "stdout") {
108
- transports.push(new winston.transports.Console({
109
- format: 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
+ 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
- const resolvedLogsDir = config.logsPath;
114
- if (resolvedLogsDir) {
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.info(`Logger initialized. Mode: ${config.logOutputMode}. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}.`, {
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: config.logsPath,
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 = "Interactive console logging status unchanged.";
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
- for (let i = 0; i < length; i++) {
66
- result += charset[bytes[i] % charset.length];
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, } = options;
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
- // Assumes default charset characters (uppercase letters and digits) for regex.
113
- const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}[A-Z0-9]{${length}}$`);
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
- if (trimmedInput.toLowerCase().startsWith("javascript:")) {
208
- throw new Error("JavaScript pseudo-protocol is not allowed in URLs.");
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.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.14.4",
36
- "@modelcontextprotocol/sdk": "^1.13.0",
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.3",
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.2",
48
+ "hono": "^4.8.4",
46
49
  "jose": "^6.0.11",
47
50
  "jsonwebtoken": "^9.0.2",
48
- "openai": "^5.6.0",
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.67"
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
- "prettier": "^3.5.3",
102
- "typedoc": "^0.28.5"
102
+ "patch-package": "^8.0.0",
103
+ "prettier": "^3.6.2",
104
+ "typedoc": "^0.28.7"
103
105
  }
104
106
  }