@cyanheads/pubmed-mcp-server 1.1.2 → 1.1.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.
Files changed (23) hide show
  1. package/README.md +19 -16
  2. package/dist/config/index.js +39 -1
  3. package/dist/mcp-server/tools/fetchPubMedContent/logic.js +59 -160
  4. package/dist/mcp-server/tools/fetchPubMedContent/registration.d.ts +1 -1
  5. package/dist/mcp-server/tools/fetchPubMedContent/registration.js +11 -15
  6. package/dist/mcp-server/tools/generatePubMedChart/logic.js +20 -22
  7. package/dist/mcp-server/tools/generatePubMedChart/registration.d.ts +1 -1
  8. package/dist/mcp-server/tools/generatePubMedChart/registration.js +10 -19
  9. package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +3 -3
  10. package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.js +9 -6
  11. package/dist/mcp-server/tools/getPubMedArticleConnections/registration.d.ts +1 -2
  12. package/dist/mcp-server/tools/getPubMedArticleConnections/registration.js +16 -26
  13. package/dist/mcp-server/tools/pubmedResearchAgent/logic.d.ts +1 -2
  14. package/dist/mcp-server/tools/pubmedResearchAgent/logic.js +7 -6
  15. package/dist/mcp-server/tools/pubmedResearchAgent/registration.d.ts +1 -1
  16. package/dist/mcp-server/tools/pubmedResearchAgent/registration.js +9 -14
  17. package/dist/mcp-server/tools/searchPubMedArticles/logic.js +9 -4
  18. package/dist/mcp-server/tools/searchPubMedArticles/registration.d.ts +1 -1
  19. package/dist/mcp-server/tools/searchPubMedArticles/registration.js +13 -20
  20. package/dist/mcp-server/transports/httpTransport.js +9 -0
  21. package/dist/utils/security/rateLimiter.d.ts +4 -0
  22. package/dist/utils/security/rateLimiter.js +4 -0
  23. package/package.json +1 -1
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.12.1-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-1.0.16-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-1.1.4-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)
@@ -17,13 +17,13 @@ Built on the [`cyanheads/mcp-ts-template`](https://github.com/cyanheads/mcp-ts-t
17
17
 
18
18
  This server equips your AI with specialized tools to interact with PubMed:
19
19
 
20
- | Tool Name | Description | Key Features |
21
- | :-------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22
- | [`search_pubmed_articles`](./src/mcp-server/tools/searchPubMedArticles/) | Searches PubMed for articles based on your query. (See [Example](./examples/search_pubmed_articles_example.md)) | - Filter by max results, sort order, date range, publication types.<br/>- Uses NCBI ESearch for PMIDs.<br/>- Optionally fetches brief summaries (title, authors, source, dates) via ESummary. |
23
- | [`fetch_pubmed_content`](./src/mcp-server/tools/fetchPubMedContent/) | Retrieves detailed information for PubMed articles. Can use a list of PMIDs or ESearch history (queryKey/webEnv) with pagination. (See [Example](./examples/fetch_pubmed_content_example.md)) | - Flexible `detailLevel`: `abstract_plus` (parsed details, optional MeSH/grant), `full_xml` (JSON representation of the PubMedArticle XML structure), `medline_text` (MEDLINE format), `citation_data` (minimal for citations).<br/>- Supports direct PMID list or `queryKey`/`webEnv` from ESearch history.<br/>- Supports `retstart`/`retmax` for pagination with history.<br/>- Uses NCBI EFetch. |
24
- | [`get_pubmed_article_connections`](./src/mcp-server/tools/getPubMedArticleConnections/) | Finds related articles (cited by, similar, references) or formats citations for a PMID. (See [Ex.1](./examples/get_pubmed_article_connections_1.md), [Ex.2](./examples/get_pubmed_article_connections_2.md)) | - Uses NCBI ELink for relationships.<br/>- Uses NCBI EFetch for citation data (RIS, BibTeX, APA, MLA).<br/>- Filter by max related results. |
25
- | [`pubmed_research_agent`](./src/mcp-server/tools/pubmedResearchAgent/) | Generates a standardized JSON research plan outline from component details. (See [Example](./examples/pubmed_research_agent_example.md)) | - Accepts granular inputs for all research phases.<br/>- Optionally embeds instructive prompts for agent execution.<br/>- Structures rough ideas into a formal, machine-readable plan for further processing. |
26
- | [`generate_pubmed_chart`](./src/mcp-server/tools/generatePubMedChart/) | Generates a chart image (PNG) from given input data. (See [Bar](./examples/generate_pubmed_chart_example_bar.svg), [Line](./examples/generate_pubmed_chart_example_line.svg), [Scatter](./examples/generate_pubmed_chart_example_scatter.svg)) | - Supports 'bar', 'line', and 'scatter' chart types.<br/>- Takes data values and field specifications for axes and encoding.<br/>- Constructs a Vega-Lite specification internally and renders it as a PNG. |
20
+ | Tool Name | Description | Key Features |
21
+ | :-------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22
+ | [`search_pubmed_articles`](./src/mcp-server/tools/searchPubMedArticles/) | Searches PubMed for articles based on your query. (See [Example](./examples/search_pubmed_articles_example.md)) | - Filter by max results, sort order, date range, publication types.<br/>- Uses NCBI ESearch for PMIDs.<br/>- Optionally fetches brief summaries (title, authors, source, dates) via ESummary. |
23
+ | [`fetch_pubmed_content`](./src/mcp-server/tools/fetchPubMedContent/) | Retrieves detailed information for PubMed articles. Can use a list of PMIDs or ESearch history (queryKey/webEnv) with pagination. (See [Example](./examples/fetch_pubmed_content_example.md)) | - Flexible `detailLevel`: `abstract_plus` (parsed details, optional MeSH/grant), `full_xml` (JSON representation of the PubMedArticle XML structure), `medline_text` (MEDLINE format), `citation_data` (minimal for citations).<br/>- Supports direct PMID list or `queryKey`/`webEnv` from ESearch history.<br/>- Supports `retstart`/`retmax` for pagination with history.<br/>- Uses NCBI EFetch. |
24
+ | [`get_pubmed_article_connections`](./src/mcp-server/tools/getPubMedArticleConnections/) | Finds related articles (cited by, similar, references) or formats citations for a PMID. (See [Ex.1](./examples/get_pubmed_article_connections_1.md), [Ex.2](./examples/get_pubmed_article_connections_2.md)) | - Uses NCBI ELink for relationships.<br/>- Uses NCBI EFetch for citation data (RIS, BibTeX, APA, MLA).<br/>- Filter by max related results. |
25
+ | [`pubmed_research_agent`](./src/mcp-server/tools/pubmedResearchAgent/) | Generates a standardized JSON research plan outline from component details. (See [Example](./examples/pubmed_research_agent_example.md)) | - Accepts granular inputs for all research phases.<br/>- Optionally embeds instructive prompts for agent execution.<br/>- Structures rough ideas into a formal, machine-readable plan for further processing. |
26
+ | [`generate_pubmed_chart`](./src/mcp-server/tools/generatePubMedChart/) | Generates a chart image (PNG) from given input data. (See [Examples](./examples/)) | - Supports 'bar', 'line', and 'scatter' chart types.<br/>- Takes data values and field specifications for axes and encoding.<br/>- Constructs a Vega-Lite specification internally and renders it as a PNG using a canvas renderer. |
27
27
 
28
28
  ---
29
29
 
@@ -56,14 +56,15 @@ Built on the robust `mcp-ts-template`, this server provides a standardized, secu
56
56
 
57
57
  Leverages the robust utilities provided by the `mcp-ts-template`:
58
58
 
59
- - **Logging**: Structured, configurable logging (file rotation, console, MCP notifications) with sensitive data redaction.
59
+ - **Logging**: Structured, configurable logging (file rotation, stdout JSON, MCP notifications) with sensitive data redaction.
60
60
  - **Error Handling**: Centralized error processing, standardized error types (`McpError`), and automatic logging.
61
61
  - **Configuration**: Environment variable loading (`dotenv`) with comprehensive validation.
62
62
  - **Input Validation/Sanitization**: Uses `zod` for schema validation and custom sanitization logic.
63
- - **Request Context**: Tracking and correlation of operations via unique request IDs.
63
+ - **Request Context**: Tracking and correlation of operations via unique request IDs using `AsyncLocalStorage`.
64
64
  - **Type Safety**: Strong typing enforced by TypeScript and Zod schemas.
65
- - **HTTP Transport Option**: Built-in Express server with SSE, session management, CORS support, and JWT authentication.
66
- - **Rate Limiting**: Built-in request queuing and delay management for NCBI API compliance.
65
+ - **HTTP Transport**: High-performance HTTP server using **Hono**, featuring session management with garbage collection, CORS, and IP-based rate limiting.
66
+ - **Authentication**: Robust authentication layer supporting JWT and OAuth 2.1, with fine-grained scope enforcement.
67
+ - **Deployment**: Multi-stage `Dockerfile` for creating small, secure production images with native dependency support.
67
68
 
68
69
  ### PubMed Integration
69
70
 
@@ -82,6 +83,7 @@ Leverages the robust utilities provided by the `mcp-ts-template`:
82
83
 
83
84
  - [Node.js (>=18.0.0)](https://nodejs.org/)
84
85
  - [npm](https://www.npmjs.com/) (comes with Node.js)
86
+ - [Docker](https://www.docker.com/) (optional, for containerized deployment)
85
87
  - **NCBI API Key** (recommended for higher rate limits) - [Get one here](https://ncbiinsights.ncbi.nlm.nih.gov/2017/11/02/new-api-keys-for-the-e-utilities/)
86
88
 
87
89
  ### Install via npm (recommended)
@@ -124,10 +126,12 @@ Configure the server using environment variables. These environmental variables
124
126
  | `MCP_HTTP_HOST` | Host address for the HTTP server (if `MCP_TRANSPORT_TYPE=http`). | `127.0.0.1` |
125
127
  | `MCP_ALLOWED_ORIGINS` | Comma-separated list of allowed origins for CORS (if `MCP_TRANSPORT_TYPE=http`). | (none) |
126
128
  | `MCP_LOG_LEVEL` | Logging level (`debug`, `info`, `notice`, `warning`, `error`, `crit`, `alert`, `emerg`). | `debug` |
127
- | `MCP_AUTH_SECRET_KEY` | **Required for HTTP transport.** Minimum 32-character secret key for JWT authentication. | (none) |
129
+ | `LOG_OUTPUT_MODE` | Logging output mode: `file` or `stdout`. | `file` |
130
+ | `MCP_AUTH_MODE` | Authentication mode for HTTP: `jwt` or `oauth`. | `jwt` |
131
+ | `MCP_AUTH_SECRET_KEY` | **Required for `jwt` auth.** Minimum 32-character secret key for JWT authentication. | (none) |
128
132
  | `NCBI_API_KEY` | **Recommended.** Your NCBI API Key for higher rate limits and reliable access. | (none) |
129
133
  | `NCBI_TOOL_IDENTIFIER` | Tool identifier for NCBI E-utility requests. | `@cyanheads/pubmed-mcp-server` |
130
- | `LOGS_DIR` | Directory for log file storage. | `logs/` |
134
+ | `LOGS_DIR` | Directory for log file storage (if `LOG_OUTPUT_MODE=file`). | `logs/` |
131
135
 
132
136
  ### MCP Client Settings
133
137
 
@@ -166,8 +170,7 @@ src/
166
170
  │ ├── resources/ # MCP Resource implementations
167
171
  │ └── tools/ # MCP Tool implementations (subdirs per tool)
168
172
  ├── services/ # External service integrations
169
- ├── NCBI/ # NCBI E-utilities API client and parsing
170
- │ └── llm-providers/ # LLM provider integrations (optional)
173
+ └── NCBI/ # NCBI E-utilities API client and parsing
171
174
  ├── types-global/ # Shared TypeScript type definitions
172
175
  └── utils/ # Common utility functions (logger, error handler, etc.)
173
176
  ```
@@ -71,7 +71,8 @@ catch (error) {
71
71
  * Provides type safety, validation, defaults, and clear error messages.
72
72
  * @private
73
73
  */
74
- const EnvSchema = z.object({
74
+ const EnvSchema = z
75
+ .object({
75
76
  /** Optional. The desired name for the MCP server. Defaults to `package.json` name. */
76
77
  MCP_SERVER_NAME: z.string().optional(),
77
78
  /** Optional. The version of the MCP server. Defaults to `package.json` version. */
@@ -146,6 +147,43 @@ const EnvSchema = z.object({
146
147
  NCBI_REQUEST_DELAY_MS: z.coerce.number().int().positive().optional(), // Default will be set conditionally
147
148
  /** Maximum number of retries for failed NCBI requests. Default: 3. */
148
149
  NCBI_MAX_RETRIES: z.coerce.number().int().nonnegative().default(3),
150
+ })
151
+ .superRefine((data, ctx) => {
152
+ // Rule 1: MCP_AUTH_SECRET_KEY is required for http transport in production with jwt auth
153
+ if (data.NODE_ENV === "production" &&
154
+ data.MCP_TRANSPORT_TYPE === "http" &&
155
+ data.MCP_AUTH_MODE === "jwt" &&
156
+ !data.MCP_AUTH_SECRET_KEY) {
157
+ ctx.addIssue({
158
+ code: z.ZodIssueCode.custom,
159
+ path: ["MCP_AUTH_SECRET_KEY"],
160
+ message: "MCP_AUTH_SECRET_KEY is required for 'jwt' auth with 'http' transport in a 'production' environment.",
161
+ });
162
+ }
163
+ // Rule 2: Core OAuth variables are required when MCP_AUTH_MODE is 'oauth'
164
+ if (data.MCP_AUTH_MODE === "oauth") {
165
+ if (!data.OAUTH_ISSUER_URL) {
166
+ ctx.addIssue({
167
+ code: z.ZodIssueCode.custom,
168
+ path: ["OAUTH_ISSUER_URL"],
169
+ message: "OAUTH_ISSUER_URL is required when MCP_AUTH_MODE is 'oauth'.",
170
+ });
171
+ }
172
+ if (!data.OAUTH_AUDIENCE) {
173
+ ctx.addIssue({
174
+ code: z.ZodIssueCode.custom,
175
+ path: ["OAUTH_AUDIENCE"],
176
+ message: "OAUTH_AUDIENCE is required when MCP_AUTH_MODE is 'oauth'.",
177
+ });
178
+ }
179
+ if (!data.OAUTH_JWKS_URI) {
180
+ ctx.addIssue({
181
+ code: z.ZodIssueCode.custom,
182
+ path: ["OAUTH_JWKS_URI"],
183
+ message: "OAUTH_JWKS_URI is required when MCP_AUTH_MODE is 'oauth'.",
184
+ });
185
+ }
186
+ }
149
187
  });
150
188
  const parsedEnv = EnvSchema.safeParse(process.env);
151
189
  if (!parsedEnv.success) {
@@ -97,14 +97,12 @@ export const FetchPubMedContentInputSchema = z
97
97
  });
98
98
  }
99
99
  });
100
- function parsePubMedArticleSet(xmlData, // Changed from { PubmedArticleSet?: XmlPubmedArticleSet } | any
101
- input, parentContext) {
100
+ function parsePubMedArticleSet(xmlData, input, parentContext) {
102
101
  const articles = [];
103
102
  const operationContext = requestContextService.createRequestContext({
104
103
  parentRequestId: parentContext.requestId,
105
104
  operation: "parsePubMedArticleSet",
106
105
  });
107
- // Type guard for xmlData
108
106
  if (!xmlData ||
109
107
  typeof xmlData !== "object" ||
110
108
  !("PubmedArticleSet" in xmlData)) {
@@ -115,30 +113,17 @@ input, parentContext) {
115
113
  });
116
114
  return articles;
117
115
  }
118
- const typedXmlData = xmlData; // Cast after check
116
+ const typedXmlData = xmlData;
119
117
  const articleSet = typedXmlData.PubmedArticleSet;
120
118
  if (!articleSet || !articleSet.PubmedArticle) {
121
- logger.warning("PubmedArticleSet or PubmedArticle array not found in EFetch XML response.", requestContextService.createRequestContext({
119
+ logger.warning("PubmedArticleSet or PubmedArticle array not found in EFetch XML response.", {
122
120
  ...operationContext,
123
121
  xmlDataPreview: sanitizeInputForLogging(JSON.stringify(typedXmlData).substring(0, 200)),
124
- }));
122
+ });
125
123
  return articles;
126
124
  }
127
125
  const pubmedArticlesXml = ensureArray(articleSet.PubmedArticle);
128
- logger.debug("Result of ensureArray(articleSet.PubmedArticle):", {
129
- ...operationContext,
130
- pubmedArticlesXmlPreview: sanitizeInputForLogging(JSON.stringify(pubmedArticlesXml).substring(0, 500)),
131
- isPubmedArticlesXmlArray: Array.isArray(pubmedArticlesXml),
132
- pubmedArticlesXmlLength: Array.isArray(pubmedArticlesXml)
133
- ? pubmedArticlesXml.length
134
- : undefined,
135
- });
136
- if (Array.isArray(pubmedArticlesXml) && pubmedArticlesXml.length > 0) {
137
- logger.debug("First item of pubmedArticlesXml:", {
138
- ...operationContext,
139
- firstItemPreview: sanitizeInputForLogging(JSON.stringify(pubmedArticlesXml[0]).substring(0, 500)),
140
- });
141
- }
126
+ const totalArticlesInXml = pubmedArticlesXml.length;
142
127
  for (const articleXml of pubmedArticlesXml) {
143
128
  if (!articleXml || typeof articleXml !== "object") {
144
129
  logger.warning("Skipping invalid articleXml item in pubmedArticlesXml array", {
@@ -156,13 +141,13 @@ input, parentContext) {
156
141
  continue;
157
142
  }
158
143
  const pmid = extractPmid(medlineCitation);
159
- logger.debug("Extracted PMID from MedlineCitation:", {
160
- ...operationContext,
161
- extractedPmid: pmid,
162
- medlineCitationPreview: sanitizeInputForLogging(JSON.stringify(medlineCitation).substring(0, 200)),
163
- });
164
- if (!pmid)
144
+ if (!pmid) {
145
+ logger.warning("Could not extract PMID from MedlineCitation, skipping.", {
146
+ ...operationContext,
147
+ medlineCitationPreview: sanitizeInputForLogging(JSON.stringify(medlineCitation).substring(0, 200)),
148
+ });
165
149
  continue;
150
+ }
166
151
  const articleNode = medlineCitation.Article;
167
152
  const parsedArticle = {
168
153
  pmid: pmid,
@@ -201,82 +186,24 @@ input, parentContext) {
201
186
  }
202
187
  articles.push(parsedArticle);
203
188
  }
189
+ logger.debug(`Successfully parsed ${articles.length} of ${totalArticlesInXml} articles from XML.`, {
190
+ ...operationContext,
191
+ parsedCount: articles.length,
192
+ totalInXml: totalArticlesInXml,
193
+ });
204
194
  return articles;
205
195
  }
206
196
  export async function fetchPubMedContentLogic(input, parentRequestContext) {
207
- // Manual validation for conditions superRefine should catch,
208
- // as SDK might call handler even with refinement issues.
209
- if (input.queryKey && !input.webEnv) {
210
- return {
211
- content: [
212
- {
213
- type: "text",
214
- text: JSON.stringify({
215
- error: {
216
- code: BaseErrorCode.VALIDATION_ERROR,
217
- message: "webEnv is required if queryKey is provided.",
218
- },
219
- }),
220
- },
221
- ],
222
- isError: true,
223
- };
224
- }
225
- if (!input.queryKey && input.webEnv) {
226
- return {
227
- content: [
228
- {
229
- type: "text",
230
- text: JSON.stringify({
231
- error: {
232
- code: BaseErrorCode.VALIDATION_ERROR,
233
- message: "queryKey is required if webEnv is provided.",
234
- },
235
- }),
236
- },
237
- ],
238
- isError: true,
239
- };
240
- }
241
- if (input.pmids &&
242
- input.pmids.length > 0 &&
243
- (input.queryKey || input.webEnv)) {
244
- return {
245
- content: [
246
- {
247
- type: "text",
248
- text: JSON.stringify({
249
- error: {
250
- code: BaseErrorCode.VALIDATION_ERROR,
251
- message: "Cannot use pmids and queryKey/webEnv simultaneously. Please choose one method.",
252
- },
253
- }),
254
- },
255
- ],
256
- isError: true,
257
- };
258
- }
259
- if ((input.retstart !== undefined || input.retmax !== undefined) &&
260
- !(input.queryKey && input.webEnv)) {
261
- return {
262
- content: [
263
- {
264
- type: "text",
265
- text: JSON.stringify({
266
- error: {
267
- code: BaseErrorCode.VALIDATION_ERROR,
268
- message: "retstart/retmax can only be used with queryKey and webEnv.",
269
- },
270
- }),
271
- },
272
- ],
273
- isError: true,
274
- };
275
- }
276
- // SuperRefine also checks: if ((!data.pmids || data.pmids.length === 0) && !(data.queryKey && data.webEnv))
277
- // This should ideally be caught before handler, but as a safeguard:
278
- if ((!input.pmids || input.pmids.length === 0) &&
279
- !(input.queryKey && input.webEnv)) {
197
+ const toolLogicContext = requestContextService.createRequestContext({
198
+ parentRequestId: parentRequestContext.requestId,
199
+ operation: "fetchPubMedContentLogic",
200
+ input: sanitizeInputForLogging(input),
201
+ });
202
+ // Manual validation safeguard
203
+ const validationResult = FetchPubMedContentInputSchema.safeParse(input);
204
+ if (!validationResult.success) {
205
+ const errorMessage = validationResult.error.errors[0]?.message || "Invalid input";
206
+ logger.warning(`Input validation failed pre-check: ${errorMessage}`, toolLogicContext);
280
207
  return {
281
208
  content: [
282
209
  {
@@ -284,7 +211,8 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
284
211
  text: JSON.stringify({
285
212
  error: {
286
213
  code: BaseErrorCode.VALIDATION_ERROR,
287
- message: "Either pmids (non-empty array) or both queryKey and webEnv must be provided.",
214
+ message: errorMessage,
215
+ details: validationResult.error.flatten(),
288
216
  },
289
217
  }),
290
218
  },
@@ -293,18 +221,13 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
293
221
  };
294
222
  }
295
223
  const ncbiService = getNcbiService();
296
- const toolLogicContext = requestContextService.createRequestContext({
297
- parentRequestId: parentRequestContext.requestId,
298
- operation: "fetchPubMedContentLogic",
299
- input: sanitizeInputForLogging(input),
300
- });
301
224
  logger.info("Executing fetch_pubmed_content tool", toolLogicContext);
302
225
  const eFetchParams = { db: "pubmed" };
303
226
  let usingHistory = false;
304
227
  if (input.queryKey && input.webEnv) {
305
228
  usingHistory = true;
306
229
  eFetchParams.query_key = input.queryKey;
307
- eFetchParams.WebEnv = input.webEnv; // NCBI uses WebEnv with capital E
230
+ eFetchParams.WebEnv = input.webEnv;
308
231
  if (input.retstart !== undefined) {
309
232
  eFetchParams.retstart = String(input.retstart);
310
233
  }
@@ -315,10 +238,8 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
315
238
  else if (input.pmids && input.pmids.length > 0) {
316
239
  eFetchParams.id = input.pmids.join(",");
317
240
  }
318
- // The superRefine ensures that either pmids or (queryKey & webEnv) is provided.
319
- let serviceRetmode = "xml"; // Renamed to avoid conflict with local retmode variable if any
241
+ let serviceRetmode = "xml";
320
242
  let rettype;
321
- // responseContentType is determined by input.outputFormat at the end
322
243
  switch (input.detailLevel) {
323
244
  case "full_xml":
324
245
  serviceRetmode = "xml";
@@ -329,7 +250,7 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
329
250
  break;
330
251
  case "abstract_plus":
331
252
  case "citation_data":
332
- serviceRetmode = "xml"; // Parsed by server, so fetch XML
253
+ serviceRetmode = "xml";
333
254
  break;
334
255
  }
335
256
  eFetchParams.retmode = serviceRetmode;
@@ -340,11 +261,11 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
340
261
  const eFetchBase = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi";
341
262
  const eFetchQueryString = new URLSearchParams(eFetchParams).toString();
342
263
  eFetchUrl = `${eFetchBase}?${eFetchQueryString}`;
343
- // Determine if raw XML should be fetched
344
264
  const shouldReturnRawXml = input.detailLevel === "full_xml" && input.outputFormat === "raw_text";
345
265
  const eFetchResponseData = await ncbiService.eFetch(eFetchParams, toolLogicContext, { retmode: serviceRetmode, rettype, returnRawXml: shouldReturnRawXml });
346
266
  let finalOutputText;
347
- let structuredResponseData; // Used for building the JSON response
267
+ let structuredResponseData;
268
+ let articlesCount = 0;
348
269
  if (input.detailLevel === "medline_text") {
349
270
  const medlineText = String(eFetchResponseData);
350
271
  const foundPmidsInMedline = new Set();
@@ -353,6 +274,7 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
353
274
  while ((match = pmidRegex.exec(medlineText)) !== null) {
354
275
  foundPmidsInMedline.add(match[1]);
355
276
  }
277
+ articlesCount = foundPmidsInMedline.size;
356
278
  let notFoundPmids = [];
357
279
  if (input.pmids && input.pmids.length > 0) {
358
280
  notFoundPmids = input.pmids.filter((pmid) => !foundPmidsInMedline.has(pmid));
@@ -375,48 +297,30 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
375
297
  : "GET",
376
298
  },
377
299
  };
378
- if (input.outputFormat === "raw_text") {
379
- finalOutputText = String(eFetchResponseData);
380
- }
381
- else {
382
- finalOutputText = JSON.stringify(structuredResponseData);
383
- }
300
+ finalOutputText =
301
+ input.outputFormat === "raw_text"
302
+ ? medlineText
303
+ : JSON.stringify(structuredResponseData);
384
304
  }
385
305
  else if (input.detailLevel === "full_xml") {
386
306
  if (input.outputFormat === "raw_text") {
387
- // eFetchResponseData is already the raw XML string due to returnRawXml: true
388
307
  finalOutputText = String(eFetchResponseData);
389
- // Optionally, wrap it in a minimal JSON structure if that's preferred for raw_text output consistency
390
- // For now, returning the direct XML string as per user expectation for "raw_text"
308
+ articlesCount = (finalOutputText.match(/<PubmedArticle>/g) || []).length;
391
309
  }
392
310
  else {
393
- // outputFormat is 'json', so eFetchResponseData is the parsed XML object
394
311
  const articlesXml = ensureArray(eFetchResponseData?.PubmedArticleSet?.PubmedArticle || []);
312
+ articlesCount = articlesXml.length;
395
313
  const articlesPayload = [];
396
314
  const foundPmidsInXml = new Set();
397
315
  for (const articleXml of articlesXml) {
398
- let pmid = "unknown_pmid";
399
- if (articleXml?.MedlineCitation) {
400
- const extracted = extractPmid(articleXml.MedlineCitation);
401
- if (extracted) {
402
- pmid = extracted;
403
- }
404
- }
405
- if (pmid !== "unknown_pmid") {
316
+ const pmid = extractPmid(articleXml.MedlineCitation) || "unknown_pmid";
317
+ if (pmid !== "unknown_pmid")
406
318
  foundPmidsInXml.add(pmid);
407
- }
408
- articlesPayload.push({
409
- pmid: pmid,
410
- fullXmlContent: articleXml,
411
- });
412
- }
413
- let notFoundPmids = [];
414
- if (input.pmids && input.pmids.length > 0) {
415
- notFoundPmids = input.pmids.filter((pmid) => !foundPmidsInXml.has(pmid));
416
- }
417
- else if (usingHistory) {
418
- notFoundPmids = "N/A (used history query)";
319
+ articlesPayload.push({ pmid, fullXmlContent: articleXml });
419
320
  }
321
+ const notFoundPmids = input.pmids && input.pmids.length > 0
322
+ ? input.pmids.filter((pmid) => !foundPmidsInXml.has(pmid))
323
+ : "N/A (used history query)";
420
324
  structuredResponseData = {
421
325
  requestedPmids: input.pmids || "N/A (used history query)",
422
326
  articles: articlesPayload,
@@ -432,17 +336,13 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
432
336
  }
433
337
  }
434
338
  else {
435
- // abstract_plus or citation_data (outputFormat is always 'json' effectively)
436
- // eFetchResponseData is the parsed XML object
339
+ // abstract_plus or citation_data
437
340
  const parsedArticles = parsePubMedArticleSet(eFetchResponseData, input, toolLogicContext);
341
+ articlesCount = parsedArticles.length;
438
342
  const foundPmids = new Set(parsedArticles.map((p) => p.pmid));
439
- let notFoundPmids = [];
440
- if (input.pmids && input.pmids.length > 0) {
441
- notFoundPmids = input.pmids.filter((pmid) => !foundPmids.has(pmid));
442
- }
443
- else if (usingHistory) {
444
- notFoundPmids = "N/A (used history query)";
445
- }
343
+ const notFoundPmids = input.pmids && input.pmids.length > 0
344
+ ? input.pmids.filter((pmid) => !foundPmids.has(pmid))
345
+ : "N/A (used history query)";
446
346
  structuredResponseData = {
447
347
  requestedPmids: input.pmids || "N/A (used history query)",
448
348
  articles: parsedArticles,
@@ -471,21 +371,20 @@ export async function fetchPubMedContentLogic(input, parentRequestContext) {
471
371
  year: article.journalInfo?.publicationDate?.year,
472
372
  },
473
373
  doi: article.doi,
474
- // Conditionally include meshTerms if the input flag was set (it defaults to true)
475
374
  ...(input.includeMeshTerms && { meshTerms: article.meshTerms }),
476
375
  }));
477
376
  }
478
- // For abstract_plus and citation_data, outputFormat 'raw_text' doesn't make sense,
479
- // as the data is inherently structured. So, always output JSON.
480
377
  finalOutputText = JSON.stringify(structuredResponseData);
481
378
  }
379
+ logger.notice("Successfully executed fetch_pubmed_content tool.", {
380
+ ...toolLogicContext,
381
+ detailLevel: input.detailLevel,
382
+ outputFormat: input.outputFormat,
383
+ articlesReturned: articlesCount,
384
+ usingHistory,
385
+ });
482
386
  return {
483
- content: [
484
- {
485
- type: "text",
486
- text: finalOutputText,
487
- },
488
- ],
387
+ content: [{ type: "text", text: finalOutputText }],
489
388
  isError: false,
490
389
  };
491
390
  }
@@ -7,4 +7,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  * Registers the fetch_pubmed_content tool with the MCP server.
8
8
  * @param server - The McpServer instance.
9
9
  */
10
- export declare function registerFetchPubMedContentTool(server: McpServer): void;
10
+ export declare function registerFetchPubMedContentTool(server: McpServer): Promise<void>;
@@ -2,17 +2,17 @@
2
2
  * @fileoverview Registration for the fetch_pubmed_content MCP tool.
3
3
  * @module src/mcp-server/tools/fetchPubMedContent/registration
4
4
  */
5
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
6
- import { ErrorHandler, requestContextService } from "../../../utils/index.js";
5
+ import { BaseErrorCode } from "../../../types-global/errors.js";
6
+ import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
7
7
  import { FetchPubMedContentInputSchema, fetchPubMedContentLogic, } from "./logic.js";
8
8
  /**
9
9
  * Registers the fetch_pubmed_content tool with the MCP server.
10
10
  * @param server - The McpServer instance.
11
11
  */
12
- export function registerFetchPubMedContentTool(server) {
12
+ export async function registerFetchPubMedContentTool(server) {
13
13
  const operation = "registerFetchPubMedContentTool";
14
14
  const context = requestContextService.createRequestContext({ operation });
15
- try {
15
+ await ErrorHandler.tryCatch(() => {
16
16
  server.tool("fetch_pubmed_content", "Fetches detailed information from PubMed using NCBI EFetch. Can be used with a direct list of PMIDs or with queryKey/webEnv from an ESearch history entry. Supports pagination (retstart, retmax) when using history. Available 'detailLevel' options: 'abstract_plus' (parsed title, abstract, authors, journal, keywords, DOI, optional MeSH/grant info), 'full_xml' (JSON representation of the PubMedArticle XML structure), 'medline_text' (MEDLINE format), or 'citation_data' (minimal data for citations). Returns a JSON object containing results, any PMIDs not found (if applicable), and EFetch details.", FetchPubMedContentInputSchema._def.schema.shape, // Access .shape from the ZodObject before superRefine
17
17
  async (input, toolContext) => {
18
18
  // Added 'any' type for toolContext
@@ -23,15 +23,11 @@ export function registerFetchPubMedContentTool(server) {
23
23
  });
24
24
  return fetchPubMedContentLogic(input, richContext);
25
25
  });
26
- }
27
- catch (error) {
28
- ErrorHandler.handleError(new McpError(BaseErrorCode.INITIALIZATION_FAILED, "Failed to register fetch_pubmed_content tool", {
29
- originalError: error instanceof Error ? error.message : String(error),
30
- }), {
31
- operation,
32
- context,
33
- errorCode: BaseErrorCode.INITIALIZATION_FAILED,
34
- critical: true,
35
- });
36
- }
26
+ logger.notice("Tool 'fetch_pubmed_content' registered.", context);
27
+ }, {
28
+ operation,
29
+ context,
30
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED,
31
+ critical: true,
32
+ });
37
33
  }
@@ -2,6 +2,11 @@ import * as vega from "vega";
2
2
  import * as vegaLite from "vega-lite";
3
3
  import { z } from "zod";
4
4
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
5
+ // IMPORTANT DEPLOYMENT NOTE:
6
+ // This tool uses the 'canvas' package to render PNG images on the server.
7
+ // The 'canvas' package has native system dependencies (e.g., cairo, pango, libjpeg).
8
+ // Ensure these are installed on the deployment environment.
9
+ // See: https://www.npmjs.com/package/canvas#compiling
5
10
  import { logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js";
6
11
  export const GeneratePubMedChartInputSchema = z.object({
7
12
  chartType: z
@@ -73,8 +78,6 @@ export const GeneratePubMedChartInputSchema = z.object({
73
78
  .enum(["quantitative", "ordinal"])
74
79
  .optional()
75
80
  .describe("Optional. Specifies the data type of the `sizeField`. Options: 'quantitative', 'ordinal'. Defaults to 'quantitative' if `sizeField` is provided and this is omitted."),
76
- // shapeField: z.string().optional().describe("Optional field for encoding point shape in scatter plots."), // Future enhancement
77
- // shapeFieldType: z.enum(["nominal", "ordinal"]).optional().describe("Type of the shape field."), // Future enhancement
78
81
  });
79
82
  export async function generatePubMedChartLogic(input, parentRequestContext) {
80
83
  const operationContext = requestContextService.createRequestContext({
@@ -84,9 +87,7 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
84
87
  });
85
88
  logger.info(`Executing 'generate_pubmed_chart'. Chart type: ${input.chartType}, Output format: ${input.outputFormat}`, operationContext);
86
89
  if (input.outputFormat !== "png") {
87
- // Changed from svg to png
88
- const unsupportedFormatError = new McpError(BaseErrorCode.VALIDATION_ERROR, `Unsupported output format: ${input.outputFormat}. Currently, only 'png' is supported.`, // Changed message
89
- { requestedFormat: input.outputFormat });
90
+ const unsupportedFormatError = new McpError(BaseErrorCode.VALIDATION_ERROR, `Unsupported output format: ${input.outputFormat}. Currently, only 'png' is supported.`, { requestedFormat: input.outputFormat });
90
91
  logger.warning(unsupportedFormatError.message, operationContext);
91
92
  return {
92
93
  content: [
@@ -105,15 +106,14 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
105
106
  };
106
107
  }
107
108
  try {
108
- let vegaLiteSpec = {
109
+ const vegaLiteSpec = {
109
110
  $schema: "https://vega.github.io/schema/vega-lite/v5.json",
110
111
  title: input.title,
111
112
  width: input.width,
112
113
  height: input.height,
113
114
  data: { values: input.dataValues },
114
- encoding: {}, // To be populated by chart type specific logic
115
+ encoding: {},
115
116
  };
116
- // Default field types if not provided
117
117
  const yEncType = input.yFieldType || "quantitative";
118
118
  const colorEncType = input.colorFieldType || "nominal";
119
119
  const seriesEncType = input.seriesFieldType || "nominal";
@@ -158,14 +158,12 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
158
158
  },
159
159
  };
160
160
  if (input.seriesField) {
161
- // For line charts, seriesField is typically used for color
162
161
  vegaLiteSpec.encoding.color = {
163
162
  field: input.seriesField,
164
163
  type: seriesEncType,
165
164
  };
166
165
  }
167
166
  else if (input.colorField) {
168
- // Allow direct colorField as well
169
167
  vegaLiteSpec.encoding.color = {
170
168
  field: input.colorField,
171
169
  type: colorEncType,
@@ -174,7 +172,7 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
174
172
  break;
175
173
  case "scatter":
176
174
  xEncType = input.xFieldType || "quantitative";
177
- vegaLiteSpec.mark = "point"; // "circle" is also an option
175
+ vegaLiteSpec.mark = "point";
178
176
  vegaLiteSpec.encoding = {
179
177
  x: {
180
178
  field: input.xField,
@@ -199,28 +197,28 @@ export async function generatePubMedChartLogic(input, parentRequestContext) {
199
197
  type: sizeEncType,
200
198
  };
201
199
  }
202
- // Add shape encoding here if shapeField is implemented
203
200
  break;
204
- // No default case needed as chartType is an enum and Zod validates it.
205
201
  }
206
202
  const compiledVegaSpec = vegaLite.compile(vegaLiteSpec).spec;
207
203
  const view = new vega.View(vega.parse(compiledVegaSpec), {
208
- renderer: "canvas", // Explicitly set renderer to 'canvas'
204
+ renderer: "canvas",
209
205
  });
210
- // const svgString = await view.toSVG(); // Old SVG method
211
- // New PNG method
212
- // Initialize the view to ensure canvas is ready
213
- await view.runAsync(); // Initialize and run the view
214
- const canvas = await view.toCanvas(); // Render to canvas
215
- // Cast to 'any' to access toBuffer, assuming it's a Node Canvas instance at runtime
216
- const imageBuffer = await canvas.toBuffer("image/png"); // Get PNG buffer from canvas
206
+ await view.runAsync();
207
+ const canvas = await view.toCanvas();
208
+ const imageBuffer = await canvas.toBuffer("image/png");
217
209
  const base64Data = imageBuffer.toString("base64");
210
+ logger.notice("Successfully generated chart.", {
211
+ ...operationContext,
212
+ chartType: input.chartType,
213
+ outputFormat: input.outputFormat,
214
+ dataPoints: input.dataValues.length,
215
+ });
218
216
  return {
219
217
  content: [
220
218
  {
221
219
  type: "image",
222
220
  data: base64Data,
223
- mimeType: "image/png", // Changed MIME type to image/png
221
+ mimeType: "image/png",
224
222
  },
225
223
  ],
226
224
  isError: false,
@@ -4,4 +4,4 @@
4
4
  * @module src/mcp-server/tools/generatePubMedChart/registration
5
5
  */
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
- export declare function registerGeneratePubMedChartTool(server: McpServer): void;
7
+ export declare function registerGeneratePubMedChartTool(server: McpServer): Promise<void>;
@@ -1,10 +1,10 @@
1
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
1
+ import { BaseErrorCode } from "../../../types-global/errors.js";
2
2
  import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
3
3
  import { GeneratePubMedChartInputSchema, generatePubMedChartLogic, } from "./logic.js";
4
- export function registerGeneratePubMedChartTool(server) {
4
+ export async function registerGeneratePubMedChartTool(server) {
5
5
  const operation = "registerGeneratePubMedChartTool";
6
6
  const regContext = requestContextService.createRequestContext({ operation });
7
- try {
7
+ await ErrorHandler.tryCatch(() => {
8
8
  server.tool("generate_pubmed_chart", "Generates a customizable chart (PNG) from structured data. " +
9
9
  "Supports 'bar', 'line', and 'scatter' plots. " +
10
10
  "Requires data values and field mappings for axes. " +
@@ -17,20 +17,11 @@ export function registerGeneratePubMedChartTool(server) {
17
17
  });
18
18
  return generatePubMedChartLogic(validatedInput, handlerRequestContext);
19
19
  });
20
- logger.notice(`Tool 'generate_pubmed_chart' registered with updated schema (added scatter).`, regContext);
21
- }
22
- catch (error) {
23
- const mcpError = error instanceof McpError
24
- ? error
25
- : new McpError(BaseErrorCode.INITIALIZATION_FAILED, `Failed to register 'generate_pubmed_chart': ${error instanceof Error ? error.message : String(error)}`, {
26
- originalErrorName: error instanceof Error ? error.name : "UnknownError",
27
- details: "Error during server.tool() call for generate_pubmed_chart.",
28
- });
29
- ErrorHandler.handleError(mcpError, {
30
- operation,
31
- context: regContext,
32
- errorCode: BaseErrorCode.INITIALIZATION_FAILED,
33
- critical: true,
34
- });
35
- }
20
+ logger.notice("Tool 'generate_pubmed_chart' registered.", regContext);
21
+ }, {
22
+ operation,
23
+ context: regContext,
24
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED,
25
+ critical: true,
26
+ });
36
27
  }
@@ -199,8 +199,8 @@ function formatAsAPA(article, context) {
199
199
  }
200
200
  const year = journalInfo?.publicationDate?.year || "n.d.";
201
201
  const apaTitle = titleText.charAt(0).toUpperCase() + titleText.slice(1); // APA typically sentence case for article titles.
202
- const journal = journalInfo?.title ? `<em>${journalInfo.title}</em>` : "N/A";
203
- const volume = journalInfo?.volume ? `<em>${journalInfo.volume}</em>` : "";
202
+ const journal = journalInfo?.title || "N/A";
203
+ const volume = journalInfo?.volume || "";
204
204
  const issue = journalInfo?.issue ? `(${journalInfo.issue})` : "";
205
205
  const pages = journalInfo?.pages || "";
206
206
  const doiLink = doi ? ` https://doi.org/${doi}` : "";
@@ -253,7 +253,7 @@ function formatAsMLA(article, context) {
253
253
  }
254
254
  }
255
255
  const title = titleText ? `"${titleText}."` : "N/A.";
256
- const journal = journalInfo?.title ? `<em>${journalInfo.title}</em>` : "N/A";
256
+ const journal = journalInfo?.title || "N/A";
257
257
  let publicationDateString = journalInfo?.publicationDate?.year || "";
258
258
  if (journalInfo?.publicationDate?.month && journalInfo.publicationDate.year) {
259
259
  const month = journalInfo.publicationDate.month.substring(0, 3) + "."; // Abbreviate month
@@ -15,7 +15,7 @@ import { handleELinkRelationships } from "./elinkHandler.js";
15
15
  */
16
16
  export async function handleGetPubMedArticleConnections(input, context) {
17
17
  const toolLogicContext = requestContextService.createRequestContext({
18
- parentRequestId: context.requestId, // Link to parent context
18
+ parentRequestId: context.requestId,
19
19
  operation: "handleGetPubMedArticleConnections",
20
20
  toolName: "get_pubmed_article_connections",
21
21
  input: sanitizeInputForLogging(input),
@@ -27,8 +27,8 @@ export async function handleGetPubMedArticleConnections(input, context) {
27
27
  relatedArticles: [],
28
28
  citations: {},
29
29
  retrievedCount: 0,
30
- eUtilityUrl: undefined, // Initialize
31
- message: undefined, // Initialize
30
+ eUtilityUrl: undefined,
31
+ message: undefined,
32
32
  };
33
33
  try {
34
34
  switch (input.relationshipType) {
@@ -41,10 +41,8 @@ export async function handleGetPubMedArticleConnections(input, context) {
41
41
  await handleCitationFormats(input, outputData, toolLogicContext);
42
42
  break;
43
43
  default:
44
- // This case should ideally be caught by Zod schema validation beforehand
45
44
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Unsupported relationshipType: ${input.relationshipType}`, { receivedType: input.relationshipType });
46
45
  }
47
- // Ensure message is set if no specific error but also no results from handlers
48
46
  if (outputData.retrievedCount === 0 &&
49
47
  !outputData.message &&
50
48
  (input.relationshipType !== "citation_formats" ||
@@ -58,7 +56,6 @@ export async function handleGetPubMedArticleConnections(input, context) {
58
56
  error instanceof McpError
59
57
  ? error.message
60
58
  : "An unexpected error occurred while processing the request.";
61
- // Ensure critical details from McpError are passed through if available
62
59
  const errorDetails = error instanceof McpError
63
60
  ? error.details
64
61
  : { originalError: error.message };
@@ -75,6 +72,12 @@ export async function handleGetPubMedArticleConnections(input, context) {
75
72
  ];
76
73
  return { content, isError: true };
77
74
  }
75
+ logger.notice("Successfully executed get_pubmed_article_connections tool.", {
76
+ ...toolLogicContext,
77
+ relationshipType: input.relationshipType,
78
+ retrievedCount: outputData.retrievedCount,
79
+ citationsGenerated: Object.keys(outputData.citations).length,
80
+ });
78
81
  const responseContent = [
79
82
  { type: "text", text: JSON.stringify(outputData) },
80
83
  ];
@@ -31,6 +31,5 @@ export type GetPubMedArticleConnectionsInput = z.infer<typeof GetPubMedArticleCo
31
31
  /**
32
32
  * Registers the 'get_pubmed_article_connections' tool with the given MCP server instance.
33
33
  * @param {McpServer} server - The MCP server instance.
34
- * @param {RequestContext} registrationContext - The context for this registration operation.
35
34
  */
36
- export declare function registerGetPubMedArticleConnectionsTool(server: McpServer): void;
35
+ export declare function registerGetPubMedArticleConnectionsTool(server: McpServer): Promise<void>;
@@ -4,8 +4,8 @@
4
4
  * @module src/mcp-server/tools/getPubMedArticleConnections/registration
5
5
  */
6
6
  import { z } from "zod";
7
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Import McpError
8
- import { ErrorHandler, requestContextService, } from "../../../utils/index.js"; // Import requestContextService
7
+ import { BaseErrorCode } from "../../../types-global/errors.js";
8
+ import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
9
9
  import { handleGetPubMedArticleConnections } from "./logic.js";
10
10
  /**
11
11
  * Zod schema for the input parameters of the 'get_pubmed_article_connections' tool.
@@ -23,7 +23,7 @@ export const GetPubMedArticleConnectionsInputSchema = z.object({
23
23
  "citation_formats",
24
24
  ])
25
25
  .default("pubmed_similar_articles")
26
- .describe("Specifies the type of connection or action: \n- 'pubmed_similar_articles': Finds articles similar to the source PMID (uses ELink `cmd=neighbor`). \n- 'pubmed_citedin': Finds articles in PubMed that cite the source PMID (uses ELink `linkname=pubmed_pubmed_citedin`). \n- 'pubmed_references': Finds articles in PubMed referenced by the source PMID (uses ELink `linkname=pubmed_pubmed_refs`). \n- 'citation_formats': Retrieves data for the source PMID and formats it into specified citation styles (uses EFetch)."),
26
+ .describe("Specifies the type of connection or action: \n- 'pubmed_similar_articles': Finds articles similar to the source PMID (uses ELink `cmd=neighbor`). \n- 'pubmed_citedin': Finds articles in PubMed that cite the source PMID (uses ELink `linkname=pubmed_pubmed_citedin`). \n- 'pubmed_references': Finds articles in PubMed referenced by the source PMID (uses ELink `linkname=pubmed_pubmed_refs`). \n- 'citation_formats': Retrieves data for the source PMID and formats it into specified citation styles (RIS, BibTeX, APA, MLA via NCBI EFetch and server-side formatting)."),
27
27
  maxRelatedResults: z
28
28
  .number()
29
29
  .int()
@@ -41,36 +41,26 @@ export const GetPubMedArticleConnectionsInputSchema = z.object({
41
41
  /**
42
42
  * Registers the 'get_pubmed_article_connections' tool with the given MCP server instance.
43
43
  * @param {McpServer} server - The MCP server instance.
44
- * @param {RequestContext} registrationContext - The context for this registration operation.
45
44
  */
46
- export function registerGetPubMedArticleConnectionsTool(server) {
45
+ export async function registerGetPubMedArticleConnectionsTool(server) {
47
46
  const operation = "registerGetPubMedArticleConnectionsTool";
48
47
  const registrationContext = requestContextService.createRequestContext({
49
48
  operation,
50
49
  });
51
- try {
52
- server.tool("get_pubmed_article_connections", "Finds articles related to a source PubMed ID (PMID) or retrieves formatted citations for it. Supports finding similar articles, articles that cite the source, articles referenced by the source (via NCBI ELink), or fetching data to generate citations in various styles (RIS, BibTeX, APA, MLA via NCBI EFetch and server-side formatting). Returns a JSON object detailing the connections or formatted citations.", GetPubMedArticleConnectionsInputSchema.shape, // Pass .shape
53
- async (validatedInput, toolContext) => {
54
- // Create a new rich context for the logic handler
50
+ await ErrorHandler.tryCatch(() => {
51
+ server.tool("get_pubmed_article_connections", "Finds articles related to a source PubMed ID (PMID) or retrieves formatted citations for it. Supports finding similar articles, articles that cite the source, articles referenced by the source (via NCBI ELink), or fetching data to generate citations in various styles (RIS, BibTeX, APA, MLA via NCBI EFetch and server-side formatting). Returns a JSON object detailing the connections or formatted citations.", GetPubMedArticleConnectionsInputSchema.shape, async (validatedInput, toolContext) => {
55
52
  const richLogicContext = requestContextService.createRequestContext({
56
- parentRequestId: registrationContext.requestId, // Link to registration context
53
+ parentRequestId: registrationContext.requestId,
57
54
  operation: "getPubMedArticleConnectionsToolHandler",
58
- mcpToolContext: toolContext, // Include MCP-provided context
55
+ mcpToolContext: toolContext,
59
56
  });
60
- return await handleGetPubMedArticleConnections(validatedInput, richLogicContext);
57
+ return handleGetPubMedArticleConnections(validatedInput, richLogicContext);
61
58
  });
62
- // Consistent with other tools, explicit success logging here might be omitted,
63
- // relying on ErrorHandler for issues or higher-level logging.
64
- }
65
- catch (error) {
66
- ErrorHandler.handleError(new McpError(// Create an McpError for consistent handling
67
- BaseErrorCode.INITIALIZATION_FAILED, "Failed to register 'get_pubmed_article_connections' tool.", {
68
- originalError: error instanceof Error ? error.message : String(error),
69
- }), {
70
- operation,
71
- context: registrationContext, // Use the context of the registration operation
72
- errorCode: BaseErrorCode.INITIALIZATION_FAILED,
73
- critical: true, // Registration failure is critical
74
- });
75
- }
59
+ logger.notice("Tool 'get_pubmed_article_connections' registered.", registrationContext);
60
+ }, {
61
+ operation,
62
+ context: registrationContext,
63
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED,
64
+ critical: true,
65
+ });
76
66
  }
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { RequestContext } from "../../../utils/index.js";
9
- import { // Re-export for registration
10
- PubMedResearchAgentInput, PubMedResearchAgentInputSchema } from "./logic/index.js";
9
+ import { PubMedResearchAgentInput, PubMedResearchAgentInputSchema } from "./logic/index.js";
11
10
  export { PubMedResearchAgentInput, PubMedResearchAgentInputSchema };
12
11
  export declare function pubmedResearchAgentLogic(input: PubMedResearchAgentInput, parentRequestContext: RequestContext): Promise<CallToolResult>;
@@ -6,9 +6,7 @@
6
6
  */
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
8
8
  import { logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js";
9
- import { generateFullResearchPlanOutline, PubMedResearchAgentInputSchema, // Re-export for registration
10
- } from "./logic/index.js";
11
- // Re-export schema and input type for easy access by registration.ts
9
+ import { generateFullResearchPlanOutline, PubMedResearchAgentInputSchema, } from "./logic/index.js";
12
10
  export { PubMedResearchAgentInputSchema };
13
11
  export async function pubmedResearchAgentLogic(input, parentRequestContext) {
14
12
  const operationContext = requestContextService.createRequestContext({
@@ -19,6 +17,10 @@ export async function pubmedResearchAgentLogic(input, parentRequestContext) {
19
17
  logger.info(`Executing 'pubmed_research_agent' to generate research plan outline. Keywords: ${input.research_keywords.join(", ")}`, operationContext);
20
18
  try {
21
19
  const researchPlanOutline = generateFullResearchPlanOutline(input, operationContext);
20
+ logger.notice("Successfully generated research plan outline.", {
21
+ ...operationContext,
22
+ projectTitle: input.project_title_suggestion,
23
+ });
22
24
  return {
23
25
  content: [
24
26
  { type: "text", text: JSON.stringify(researchPlanOutline, null, 2) },
@@ -30,11 +32,10 @@ export async function pubmedResearchAgentLogic(input, parentRequestContext) {
30
32
  logger.error("Execution failed for 'pubmed_research_agent'", error, operationContext);
31
33
  const mcpError = error instanceof McpError
32
34
  ? error
33
- : new McpError(BaseErrorCode.INTERNAL_ERROR, // Using a generic internal error
34
- `'pubmed_research_agent' tool failed during plan outline generation: ${error.message || "Internal server error."}`, {
35
+ : new McpError(BaseErrorCode.INTERNAL_ERROR, `'pubmed_research_agent' tool failed during plan outline generation: ${error.message || "Internal server error."}`, {
35
36
  originalErrorName: error.name,
36
37
  requestId: operationContext.requestId,
37
- inputKeywords: input.research_keywords, // Adding some input context to error
38
+ inputKeywords: input.research_keywords,
38
39
  });
39
40
  return {
40
41
  content: [
@@ -7,4 +7,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  * Registers the pubmed_research_agent tool with the MCP server.
8
8
  * @param server - The McpServer instance.
9
9
  */
10
- export declare function registerPubMedResearchAgentTool(server: McpServer): void;
10
+ export declare function registerPubMedResearchAgentTool(server: McpServer): Promise<void>;
@@ -2,17 +2,17 @@
2
2
  * @fileoverview Registration for the pubmed_research_agent tool.
3
3
  * @module pubmedResearchAgent/registration
4
4
  */
5
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
5
+ import { BaseErrorCode } from "../../../types-global/errors.js";
6
6
  import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
7
7
  import { PubMedResearchAgentInputSchema, pubmedResearchAgentLogic, } from "./logic.js";
8
8
  /**
9
9
  * Registers the pubmed_research_agent tool with the MCP server.
10
10
  * @param server - The McpServer instance.
11
11
  */
12
- export function registerPubMedResearchAgentTool(server) {
12
+ export async function registerPubMedResearchAgentTool(server) {
13
13
  const operation = "registerPubMedResearchAgentTool";
14
14
  const context = requestContextService.createRequestContext({ operation });
15
- try {
15
+ await ErrorHandler.tryCatch(() => {
16
16
  server.tool("pubmed_research_agent", "Generates a standardized JSON research plan outline from component details you provide. It accepts granular inputs for all research phases (conception, data collection, analysis, dissemination, cross-cutting concerns). If `include_detailed_prompts_for_agent` is true, the output plan will embed instructive prompts and detailed guidance notes to aid the research agent. The tool's primary function is to organize and structure your rough ideas into a formal, machine-readable plan. This plan is intended for further processing; as the research agent, you should then utilize your full suite of tools (e.g., file manipulation, `get_pubmed_article_connections` for literature/data search via PMID) to execute the outlined research, tailored to the user's request.", PubMedResearchAgentInputSchema.shape, async (validatedInput, mcpProvidedContext) => {
17
17
  const handlerRequestContext = requestContextService.createRequestContext({
18
18
  parentRequestId: context.requestId,
@@ -22,15 +22,10 @@ export function registerPubMedResearchAgentTool(server) {
22
22
  return pubmedResearchAgentLogic(validatedInput, handlerRequestContext);
23
23
  });
24
24
  logger.notice(`Tool 'pubmed_research_agent' registered.`, context);
25
- }
26
- catch (error) {
27
- ErrorHandler.handleError(new McpError(BaseErrorCode.INITIALIZATION_FAILED, "Failed to register pubmed_research_agent tool", {
28
- originalError: error instanceof Error ? error.message : String(error),
29
- }), {
30
- operation,
31
- context,
32
- errorCode: BaseErrorCode.INITIALIZATION_FAILED,
33
- critical: true,
34
- });
35
- }
25
+ }, {
26
+ operation,
27
+ context,
28
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED,
29
+ critical: true,
30
+ });
36
31
  }
@@ -140,10 +140,9 @@ export async function searchPubMedArticlesLogic(input, parentRequestContext) {
140
140
  if (esResult.WebEnv && esResult.QueryKey) {
141
141
  eSummaryParams.WebEnv = esResult.WebEnv;
142
142
  eSummaryParams.query_key = esResult.QueryKey;
143
- eSummaryParams.retmax = currentFetchBriefSummaries; // Use history with retmax
143
+ eSummaryParams.retmax = currentFetchBriefSummaries;
144
144
  }
145
145
  else {
146
- // Fallback to using explicit IDs if history is not available (should not happen if usehistory='y' was successful)
147
146
  const pmidsForSummary = pmids
148
147
  .slice(0, currentFetchBriefSummaries)
149
148
  .join(",");
@@ -185,13 +184,19 @@ export async function searchPubMedArticlesLogic(input, parentRequestContext) {
185
184
  effectiveESearchTerm: effectiveQuery,
186
185
  totalFound,
187
186
  retrievedPmidCount,
188
- pmids, // These are the PMIDs from ESearch, limited by input.maxResults
189
- briefSummaries, // These should now be limited by currentFetchBriefSummaries
187
+ pmids,
188
+ briefSummaries,
190
189
  eSearchUrl,
191
190
  eSummaryUrl: currentFetchBriefSummaries > 0 && pmids.length > 0
192
191
  ? eSummaryUrl
193
192
  : undefined,
194
193
  };
194
+ logger.notice("Successfully executed searchPubMedArticles tool.", {
195
+ ...toolLogicContext,
196
+ totalFound,
197
+ retrievedPmidCount,
198
+ summariesFetched: briefSummaries.length,
199
+ });
195
200
  return {
196
201
  content: [{ type: "text", text: JSON.stringify(resultPayload) }],
197
202
  isError: false,
@@ -7,4 +7,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  * Registers the searchPubMedArticles tool with the MCP server.
8
8
  * @param server - The McpServer instance.
9
9
  */
10
- export declare function registerSearchPubMedArticlesTool(server: McpServer): void;
10
+ export declare function registerSearchPubMedArticlesTool(server: McpServer): Promise<void>;
@@ -2,37 +2,30 @@
2
2
  * @fileoverview Registration for the searchPubMedArticles MCP tool.
3
3
  * @module src/mcp-server/tools/searchPubMedArticles/registration
4
4
  */
5
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Import McpError
6
- import { ErrorHandler, requestContextService } from "../../../utils/index.js";
5
+ import { BaseErrorCode } from "../../../types-global/errors.js";
6
+ import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
7
7
  import { SearchPubMedArticlesInputSchema, searchPubMedArticlesLogic, } from "./logic.js";
8
8
  /**
9
9
  * Registers the searchPubMedArticles tool with the MCP server.
10
10
  * @param server - The McpServer instance.
11
11
  */
12
- export function registerSearchPubMedArticlesTool(server) {
12
+ export async function registerSearchPubMedArticlesTool(server) {
13
13
  const operation = "registerSearchPubMedArticlesTool";
14
14
  const context = requestContextService.createRequestContext({ operation });
15
- try {
16
- server.tool("search_pubmed_articles", "Searches PubMed for articles using a query term and optional filters (max results, sort, date range, publication types). Uses NCBI ESearch to find PMIDs and ESummary (optional) for brief summaries. Returns a JSON object with search parameters, ESearch term, result counts, PMIDs, optional summaries (PMID, title, authors, source, dates), and E-utility URLs.", SearchPubMedArticlesInputSchema.shape, // Pass the .shape for ZodRawShape
17
- async (input, toolContext) => {
18
- // Explicitly type input
15
+ await ErrorHandler.tryCatch(() => {
16
+ server.tool("search_pubmed_articles", "Searches PubMed for articles using a query term and optional filters (max results, sort, date range, publication types). Uses NCBI ESearch to find PMIDs and ESummary (optional) for brief summaries. Returns a JSON object with search parameters, ESearch term, result counts, PMIDs, optional summaries (PMID, title, authors, source, dates), and E-utility URLs.", SearchPubMedArticlesInputSchema.shape, async (input, toolContext) => {
19
17
  const richContext = requestContextService.createRequestContext({
20
18
  parentRequestId: context.requestId,
21
19
  operation: "searchPubMedArticlesToolHandler",
22
- mcpToolContext: toolContext, // Include MCP-provided context if any
20
+ mcpToolContext: toolContext,
23
21
  });
24
22
  return searchPubMedArticlesLogic(input, richContext);
25
23
  });
26
- }
27
- catch (error) {
28
- ErrorHandler.handleError(new McpError(// Create an McpError for consistent handling
29
- BaseErrorCode.INITIALIZATION_FAILED, "Failed to register searchPubMedArticles tool", {
30
- originalError: error instanceof Error ? error.message : String(error),
31
- }), {
32
- operation,
33
- context,
34
- errorCode: BaseErrorCode.INITIALIZATION_FAILED, // errorCode is still useful for categorization
35
- critical: true,
36
- });
37
- }
24
+ logger.notice("Tool 'search_pubmed_articles' registered.", context);
25
+ }, {
26
+ operation,
27
+ context,
28
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED,
29
+ critical: true,
30
+ });
38
31
  }
@@ -54,6 +54,11 @@ const MAX_PORT_RETRIES = 15;
54
54
  * Stores active `StreamableHTTPServerTransport` instances from the SDK, keyed by their session ID.
55
55
  * This is essential for routing subsequent HTTP requests (GET, DELETE, non-initialize POST)
56
56
  * to the correct stateful session transport instance.
57
+ *
58
+ * @scalability Note: This is an in-memory store. For horizontal scaling across
59
+ * multiple processes or machines, this state would need to be moved to a shared,
60
+ * distributed store like Redis or a database.
61
+ *
57
62
  * @type {Record<string, StreamableHTTPServerTransport>}
58
63
  * @private
59
64
  */
@@ -61,6 +66,10 @@ const httpTransports = {};
61
66
  /**
62
67
  * Stores the last activity timestamp for each session, keyed by session ID.
63
68
  * Used for garbage collecting stale/abandoned sessions.
69
+ *
70
+ * @scalability Note: This is an in-memory store. Like `httpTransports`, this
71
+ * would need to be moved to a distributed store for multi-instance deployments.
72
+ *
64
73
  * @type {Record<string, number>}
65
74
  * @private
66
75
  */
@@ -28,6 +28,10 @@ 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.
31
35
  */
32
36
  export declare class RateLimiter {
33
37
  private config;
@@ -9,6 +9,10 @@ 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.
12
16
  */
13
17
  export class RateLimiter {
14
18
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/pubmed-mcp-server",
3
- "version": "1.1.2",
3
+ "version": "1.1.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": [