@cyanheads/pubmed-mcp-server 1.1.1 → 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 (26) hide show
  1. package/README.md +19 -16
  2. package/dist/config/index.d.ts +4 -2
  3. package/dist/config/index.js +52 -7
  4. package/dist/mcp-server/tools/fetchPubMedContent/logic.js +59 -160
  5. package/dist/mcp-server/tools/fetchPubMedContent/registration.d.ts +1 -1
  6. package/dist/mcp-server/tools/fetchPubMedContent/registration.js +11 -15
  7. package/dist/mcp-server/tools/generatePubMedChart/logic.js +21 -22
  8. package/dist/mcp-server/tools/generatePubMedChart/registration.d.ts +1 -1
  9. package/dist/mcp-server/tools/generatePubMedChart/registration.js +10 -19
  10. package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +3 -3
  11. package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.js +9 -6
  12. package/dist/mcp-server/tools/getPubMedArticleConnections/registration.d.ts +1 -2
  13. package/dist/mcp-server/tools/getPubMedArticleConnections/registration.js +16 -26
  14. package/dist/mcp-server/tools/pubmedResearchAgent/logic/planOrchestrator.js +1 -3
  15. package/dist/mcp-server/tools/pubmedResearchAgent/logic.d.ts +1 -2
  16. package/dist/mcp-server/tools/pubmedResearchAgent/logic.js +7 -6
  17. package/dist/mcp-server/tools/pubmedResearchAgent/registration.d.ts +1 -1
  18. package/dist/mcp-server/tools/pubmedResearchAgent/registration.js +9 -14
  19. package/dist/mcp-server/tools/searchPubMedArticles/logic.js +9 -4
  20. package/dist/mcp-server/tools/searchPubMedArticles/registration.d.ts +1 -1
  21. package/dist/mcp-server/tools/searchPubMedArticles/registration.js +13 -20
  22. package/dist/mcp-server/transports/httpTransport.js +9 -0
  23. package/dist/utils/internal/logger.js +49 -51
  24. package/dist/utils/security/rateLimiter.d.ts +4 -0
  25. package/dist/utils/security/rateLimiter.js +4 -0
  26. package/package.json +2 -3
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
  ```
@@ -25,8 +25,10 @@ export declare const config: {
25
25
  mcpServerVersion: string;
26
26
  /** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
27
27
  logLevel: string;
28
- /** Absolute path to the logs directory. From `LOGS_DIR` env var. */
29
- logsPath: string;
28
+ /** Defines the logging output mode ('file' or 'stdout'). From `LOG_OUTPUT_MODE`. */
29
+ logOutputMode: "file" | "stdout";
30
+ /** Absolute path to the logs directory (if logOutputMode is 'file'). From `LOGS_DIR`. */
31
+ logsPath: string | null;
30
32
  /** Runtime environment. From `NODE_ENV` env var. Default: "development". */
31
33
  environment: string;
32
34
  /** MCP transport type ('stdio' or 'http'). From `MCP_TRANSPORT_TYPE` env var. Default: "stdio". */
@@ -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. */
@@ -80,6 +81,8 @@ const EnvSchema = z.object({
80
81
  MCP_LOG_LEVEL: z.string().default("debug"),
81
82
  /** Directory for log files. Defaults to "logs" in project root. */
82
83
  LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
84
+ /** Defines the logging output mode. "file" for logs in LOGS_DIR, "stdout" for console logging. */
85
+ LOG_OUTPUT_MODE: z.enum(["file", "stdout"]).default("file"),
83
86
  /** Runtime environment (e.g., "development", "production"). Default: "development". */
84
87
  NODE_ENV: z.string().default("development"),
85
88
  /** MCP communication transport ("stdio" or "http"). Default: "stdio". */
@@ -144,6 +147,43 @@ const EnvSchema = z.object({
144
147
  NCBI_REQUEST_DELAY_MS: z.coerce.number().int().positive().optional(), // Default will be set conditionally
145
148
  /** Maximum number of retries for failed NCBI requests. Default: 3. */
146
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
+ }
147
187
  });
148
188
  const parsedEnv = EnvSchema.safeParse(process.env);
149
189
  if (!parsedEnv.success) {
@@ -209,12 +249,15 @@ const ensureDirectory = (dirPath, rootDir, dirName) => {
209
249
  };
210
250
  // --- End Directory Ensurance Function ---
211
251
  // --- Logs Directory Handling ---
212
- const validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
213
- if (!validatedLogsPath) {
214
- if (process.stdout.isTTY) {
215
- console.error("FATAL: Logs directory configuration is invalid or could not be created. Please check permissions and path. Exiting.");
252
+ let validatedLogsPath = null;
253
+ if (env.LOG_OUTPUT_MODE === "file") {
254
+ validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
255
+ if (!validatedLogsPath) {
256
+ if (process.stdout.isTTY) {
257
+ console.error("FATAL: Log mode is 'file' but logs directory is invalid or could not be created. Please check LOGS_DIR, permissions, and path. Exiting.");
258
+ }
259
+ process.exit(1); // Exit if file logging is configured but directory is not usable
216
260
  }
217
- process.exit(1); // Exit if logs directory is not usable
218
261
  }
219
262
  // --- End Logs Directory Handling ---
220
263
  /**
@@ -228,7 +271,9 @@ export const config = {
228
271
  mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
229
272
  /** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
230
273
  logLevel: env.MCP_LOG_LEVEL,
231
- /** Absolute path to the logs directory. From `LOGS_DIR` env var. */
274
+ /** Defines the logging output mode ('file' or 'stdout'). From `LOG_OUTPUT_MODE`. */
275
+ logOutputMode: env.LOG_OUTPUT_MODE,
276
+ /** Absolute path to the logs directory (if logOutputMode is 'file'). From `LOGS_DIR`. */
232
277
  logsPath: validatedLogsPath,
233
278
  /** Runtime environment. From `NODE_ENV` env var. Default: "development". */
234
279
  environment: env.NODE_ENV,
@@ -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
  }