@cyanheads/pubmed-mcp-server 1.2.3 → 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.
@@ -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
+ }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/pubmed-mcp-server",
3
- "version": "1.2.3",
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
  }