@cyanheads/pubmed-mcp-server 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.13.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-1.2.2-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-1.2.3-blue.svg)](./CHANGELOG.md)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/pubmed-mcp-server/issues)
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/pubmed-mcp-server?style=social)](https://github.com/cyanheads/pubmed-mcp-server)
@@ -35,8 +35,11 @@ const mcpToWinstonLevel = {
35
35
  alert: "error",
36
36
  emerg: "error",
37
37
  };
38
+ // The logsPath from config is already resolved and validated by src/config/index.ts
39
+ const resolvedLogsDir = config.logsPath;
40
+ const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic.
38
41
  /**
39
- * Creates the Winston console log format for interactive TTY sessions.
42
+ * Creates the Winston console log format.
40
43
  * @returns The Winston log format for console output.
41
44
  * @private
42
45
  */
@@ -100,45 +103,47 @@ export class Logger {
100
103
  });
101
104
  return;
102
105
  }
106
+ // Set initialized to true at the beginning of the initialization process.
103
107
  this.initialized = true;
104
108
  this.currentMcpLevel = level;
105
109
  this.currentWinstonLevel = mcpToWinstonLevel[level];
110
+ // The logs directory (config.logsPath / resolvedLogsDir) is expected to be created and validated
111
+ // by the configuration module (src/config/index.ts) before logger initialization.
112
+ // If isLogsDirSafe is true, we assume resolvedLogsDir exists and is usable.
113
+ // No redundant directory creation logic here.
114
+ const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
106
115
  const transports = [];
107
- if (config.logOutputMode === "stdout") {
108
- transports.push(new winston.transports.Console({
109
- format: winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json()),
116
+ const fileTransportOptions = {
117
+ format: fileFormat,
118
+ maxsize: this.LOG_FILE_MAX_SIZE,
119
+ maxFiles: this.LOG_MAX_FILES,
120
+ tailable: true,
121
+ };
122
+ if (isLogsDirSafe) {
123
+ transports.push(new winston.transports.File({
124
+ filename: path.join(resolvedLogsDir, "error.log"),
125
+ level: "error",
126
+ ...fileTransportOptions,
127
+ }), new winston.transports.File({
128
+ filename: path.join(resolvedLogsDir, "warn.log"),
129
+ level: "warn",
130
+ ...fileTransportOptions,
131
+ }), new winston.transports.File({
132
+ filename: path.join(resolvedLogsDir, "info.log"),
133
+ level: "info",
134
+ ...fileTransportOptions,
135
+ }), new winston.transports.File({
136
+ filename: path.join(resolvedLogsDir, "debug.log"),
137
+ level: "debug",
138
+ ...fileTransportOptions,
139
+ }), new winston.transports.File({
140
+ filename: path.join(resolvedLogsDir, "combined.log"),
141
+ ...fileTransportOptions,
110
142
  }));
111
143
  }
112
144
  else {
113
- const resolvedLogsDir = config.logsPath;
114
- if (resolvedLogsDir) {
115
- const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
116
- const fileTransportOptions = {
117
- format: fileFormat,
118
- maxsize: this.LOG_FILE_MAX_SIZE,
119
- maxFiles: this.LOG_MAX_FILES,
120
- tailable: true,
121
- };
122
- transports.push(new winston.transports.File({
123
- filename: path.join(resolvedLogsDir, "error.log"),
124
- level: "error",
125
- ...fileTransportOptions,
126
- }), new winston.transports.File({
127
- filename: path.join(resolvedLogsDir, "warn.log"),
128
- level: "warn",
129
- ...fileTransportOptions,
130
- }), new winston.transports.File({
131
- filename: path.join(resolvedLogsDir, "info.log"),
132
- level: "info",
133
- ...fileTransportOptions,
134
- }), new winston.transports.File({
135
- filename: path.join(resolvedLogsDir, "debug.log"),
136
- level: "debug",
137
- ...fileTransportOptions,
138
- }), new winston.transports.File({
139
- filename: path.join(resolvedLogsDir, "combined.log"),
140
- ...fileTransportOptions,
141
- }));
145
+ if (process.stdout.isTTY) {
146
+ console.warn("File logging disabled as logsPath is not configured or invalid.");
142
147
  }
143
148
  }
144
149
  this.winstonLogger = winston.createLogger({
@@ -146,20 +151,23 @@ export class Logger {
146
151
  transports,
147
152
  exitOnError: false,
148
153
  });
154
+ // Configure console transport after Winston logger is created
149
155
  const consoleStatus = this._configureConsoleTransport();
150
156
  const initialContext = {
151
157
  loggerSetup: true,
152
158
  requestId: "logger-init-deferred",
153
159
  timestamp: new Date().toISOString(),
154
160
  };
161
+ // Removed logging of logsDirCreatedMessage as it's no longer set
155
162
  if (consoleStatus.message) {
156
163
  this.info(consoleStatus.message, initialContext);
157
164
  }
158
- this.info(`Logger initialized. Mode: ${config.logOutputMode}. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}.`, {
165
+ this.initialized = true; // Ensure this is set after successful setup
166
+ this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`, {
159
167
  loggerSetup: true,
160
168
  requestId: "logger-post-init",
161
169
  timestamp: new Date().toISOString(),
162
- logsPathUsed: config.logsPath,
170
+ logsPathUsed: resolvedLogsDir,
163
171
  });
164
172
  }
165
173
  /**
@@ -199,6 +207,7 @@ export class Logger {
199
207
  this.currentMcpLevel = newLevel;
200
208
  this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
201
209
  if (this.winstonLogger) {
210
+ // Ensure winstonLogger is defined
202
211
  this.winstonLogger.level = this.currentWinstonLevel;
203
212
  }
204
213
  const consoleStatus = this._configureConsoleTransport();
@@ -217,12 +226,6 @@ export class Logger {
217
226
  * @private
218
227
  */
219
228
  _configureConsoleTransport() {
220
- if (config.logOutputMode === "stdout") {
221
- return {
222
- enabled: true,
223
- message: "Stdout logging is enabled by configuration.",
224
- };
225
- }
226
229
  if (!this.winstonLogger) {
227
230
  return {
228
231
  enabled: false,
@@ -235,19 +238,17 @@ export class Logger {
235
238
  if (shouldHaveConsole && !consoleTransport) {
236
239
  const consoleFormat = createWinstonConsoleFormat();
237
240
  this.winstonLogger.add(new winston.transports.Console({
238
- level: "debug",
241
+ level: "debug", // Console always logs debug if enabled
239
242
  format: consoleFormat,
240
243
  }));
241
- message =
242
- "Interactive console logging enabled (level: debug, stdout is TTY).";
244
+ message = "Console logging enabled (level: debug, stdout is TTY).";
243
245
  }
244
246
  else if (!shouldHaveConsole && consoleTransport) {
245
247
  this.winstonLogger.remove(consoleTransport);
246
- message =
247
- "Interactive console logging disabled (level not debug or stdout not TTY).";
248
+ message = "Console logging disabled (level not debug or stdout not TTY).";
248
249
  }
249
250
  else {
250
- message = "Interactive console logging status unchanged.";
251
+ message = "Console logging status unchanged.";
251
252
  }
252
253
  return { enabled: shouldHaveConsole, message };
253
254
  }
@@ -287,7 +288,7 @@ export class Logger {
287
288
  if (!this.ensureInitialized())
288
289
  return;
289
290
  if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
290
- return;
291
+ return; // Do not log if message level is less severe than currentMcpLevel
291
292
  }
292
293
  const logData = { ...context };
293
294
  const winstonLevel = mcpToWinstonLevel[level];
@@ -303,6 +304,7 @@ export class Logger {
303
304
  mcpDataPayload.context = context;
304
305
  if (error) {
305
306
  mcpDataPayload.error = { message: error.message };
307
+ // Include stack trace in debug mode for MCP notifications, truncated for brevity
306
308
  if (this.currentMcpLevel === "debug" && error.stack) {
307
309
  mcpDataPayload.error.stack = error.stack.substring(0, this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH);
308
310
  }
@@ -319,7 +321,7 @@ export class Logger {
319
321
  originalLevel: level,
320
322
  originalMessage: msg,
321
323
  sendError: errorMessage,
322
- mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500),
324
+ mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
323
325
  };
324
326
  this.winstonLogger.error("Failed to send MCP log notification", internalErrorContext);
325
327
  }
@@ -85,6 +85,7 @@ export declare class IdGenerator {
85
85
  * @param id - The ID string to validate.
86
86
  * @param entityType - The expected entity type of the ID.
87
87
  * @param options - Optional parameters used during generation for validation consistency.
88
+ * The `charset` from these options will be used for validation.
88
89
  * @returns `true` if the ID is valid, `false` otherwise.
89
90
  */
90
91
  isValid(id: string, entityType: string, options?: IdGenerationOptions): boolean;
@@ -112,7 +113,9 @@ export declare class IdGenerator {
112
113
  getEntityType(id: string, separator?: string): string;
113
114
  /**
114
115
  * Normalizes an entity ID to ensure the prefix matches the registered case
115
- * and the random part is uppercase.
116
+ * and the random part is uppercase. Note: This assumes the charset characters
117
+ * have a meaningful uppercase version if case-insensitivity is desired for the random part.
118
+ * For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
116
119
  * @param id - The ID to normalize (e.g., "proj_a6b3j0").
117
120
  * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
118
121
  * @returns The normalized ID (e.g., "PROJ_A6B3J0").
@@ -60,10 +60,18 @@ export class IdGenerator {
60
60
  * @returns The generated random string.
61
61
  */
62
62
  generateRandomString(length = IdGenerator.DEFAULT_LENGTH, charset = IdGenerator.DEFAULT_CHARSET) {
63
- const bytes = randomBytes(length);
64
63
  let result = "";
65
- for (let i = 0; i < length; i++) {
66
- result += charset[bytes[i] % charset.length];
64
+ // Determine the largest multiple of charset.length that is less than or equal to 256
65
+ // This is the threshold for rejection sampling to avoid bias.
66
+ const maxValidByteValue = Math.floor(256 / charset.length) * charset.length;
67
+ while (result.length < length) {
68
+ const byteBuffer = randomBytes(1); // Get one random byte
69
+ const byte = byteBuffer[0];
70
+ // If the byte is within the valid range (i.e., it won't introduce bias),
71
+ // use it to select a character from the charset. Otherwise, discard and try again.
72
+ if (byte < maxValidByteValue) {
73
+ result += charset[byte % charset.length];
74
+ }
67
75
  }
68
76
  return result;
69
77
  }
@@ -101,16 +109,21 @@ export class IdGenerator {
101
109
  * @param id - The ID string to validate.
102
110
  * @param entityType - The expected entity type of the ID.
103
111
  * @param options - Optional parameters used during generation for validation consistency.
112
+ * The `charset` from these options will be used for validation.
104
113
  * @returns `true` if the ID is valid, `false` otherwise.
105
114
  */
106
115
  isValid(id, entityType, options = {}) {
107
116
  const prefix = this.entityPrefixes[entityType];
108
- const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, } = options;
117
+ const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, charset = IdGenerator.DEFAULT_CHARSET, // Use charset from options or default
118
+ } = options;
109
119
  if (!prefix) {
110
120
  return false;
111
121
  }
112
- // Assumes default charset characters (uppercase letters and digits) for regex.
113
- const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}[A-Z0-9]{${length}}$`);
122
+ // Build regex character class from the charset
123
+ // Escape characters that have special meaning inside a regex character class `[]`
124
+ const escapedCharsetForClass = charset.replace(/[[\]\\^-]/g, "\\$&");
125
+ const charsetRegexPart = `[${escapedCharsetForClass}]`;
126
+ const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}${charsetRegexPart}{${length}}$`);
114
127
  return pattern.test(id);
115
128
  }
116
129
  /**
@@ -153,7 +166,9 @@ export class IdGenerator {
153
166
  }
154
167
  /**
155
168
  * Normalizes an entity ID to ensure the prefix matches the registered case
156
- * and the random part is uppercase.
169
+ * and the random part is uppercase. Note: This assumes the charset characters
170
+ * have a meaningful uppercase version if case-insensitivity is desired for the random part.
171
+ * For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
157
172
  * @param id - The ID to normalize (e.g., "proj_a6b3j0").
158
173
  * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
159
174
  * @returns The normalized ID (e.g., "PROJ_A6B3J0").
@@ -164,6 +179,8 @@ export class IdGenerator {
164
179
  const registeredPrefix = this.entityPrefixes[entityType];
165
180
  const idParts = id.split(separator);
166
181
  const randomPart = idParts.slice(1).join(separator);
182
+ // Consider if randomPart.toUpperCase() is always correct for custom charsets.
183
+ // For now, maintaining existing behavior.
167
184
  return `${registeredPrefix}${separator}${randomPart.toUpperCase()}`;
168
185
  }
169
186
  }
@@ -28,10 +28,6 @@ export interface RateLimitEntry {
28
28
  /**
29
29
  * A generic rate limiter class using an in-memory store.
30
30
  * Controls frequency of operations based on unique keys.
31
- *
32
- * @scalability Note: This is an in-memory store. For horizontal scaling across
33
- * multiple processes or machines, this state would need to be moved to a shared,
34
- * distributed store like Redis or a database.
35
31
  */
36
32
  export declare class RateLimiter {
37
33
  private config;
@@ -9,10 +9,6 @@ import { logger, requestContextService } from "../index.js";
9
9
  /**
10
10
  * A generic rate limiter class using an in-memory store.
11
11
  * Controls frequency of operations based on unique keys.
12
- *
13
- * @scalability Note: This is an in-memory store. For horizontal scaling across
14
- * multiple processes or machines, this state would need to be moved to a shared,
15
- * distributed store like Redis or a database.
16
12
  */
17
13
  export class RateLimiter {
18
14
  /**
@@ -148,6 +148,17 @@ export declare class Sanitization {
148
148
  * Sanitizes input for logging by redacting sensitive fields.
149
149
  * Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
150
150
  * (case-insensitive substring match) with "[REDACTED]".
151
+ *
152
+ * It uses `structuredClone` if available for a high-fidelity deep clone.
153
+ * If `structuredClone` is not available (e.g., in older Node.js environments),
154
+ * it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
155
+ * - `Date` objects are converted to ISO date strings.
156
+ * - `undefined` values within objects are removed.
157
+ * - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
158
+ * - Functions are removed.
159
+ * - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
160
+ * - Circular references will cause `JSON.stringify` to throw an error.
161
+ *
151
162
  * @param input - The input data to sanitize for logging.
152
163
  * @returns A sanitized (deep cloned) version of the input, safe for logging.
153
164
  * Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
@@ -204,8 +204,11 @@ export class Sanitization {
204
204
  })) {
205
205
  throw new Error("Invalid URL format or protocol not in allowed list.");
206
206
  }
207
- if (trimmedInput.toLowerCase().startsWith("javascript:")) {
208
- throw new Error("JavaScript pseudo-protocol is not allowed in URLs.");
207
+ const lowercasedInput = trimmedInput.toLowerCase();
208
+ if (lowercasedInput.startsWith("javascript:") ||
209
+ lowercasedInput.startsWith("data:") ||
210
+ lowercasedInput.startsWith("vbscript:")) {
211
+ throw new Error("Disallowed pseudo-protocol (javascript:, data:, or vbscript:) in URL.");
209
212
  }
210
213
  return trimmedInput;
211
214
  }
@@ -377,6 +380,17 @@ export class Sanitization {
377
380
  * Sanitizes input for logging by redacting sensitive fields.
378
381
  * Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
379
382
  * (case-insensitive substring match) with "[REDACTED]".
383
+ *
384
+ * It uses `structuredClone` if available for a high-fidelity deep clone.
385
+ * If `structuredClone` is not available (e.g., in older Node.js environments),
386
+ * it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
387
+ * - `Date` objects are converted to ISO date strings.
388
+ * - `undefined` values within objects are removed.
389
+ * - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
390
+ * - Functions are removed.
391
+ * - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
392
+ * - Circular references will cause `JSON.stringify` to throw an error.
393
+ *
380
394
  * @param input - The input data to sanitize for logging.
381
395
  * @returns A sanitized (deep cloned) version of the input, safe for logging.
382
396
  * Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/pubmed-mcp-server",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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": [