@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 +1 -1
- package/dist/utils/internal/logger.js +51 -49
- package/dist/utils/security/idGenerator.d.ts +4 -1
- package/dist/utils/security/idGenerator.js +24 -7
- package/dist/utils/security/rateLimiter.d.ts +0 -4
- package/dist/utils/security/rateLimiter.js +0 -4
- package/dist/utils/security/sanitization.d.ts +11 -0
- package/dist/utils/security/sanitization.js +16 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://modelcontextprotocol.io/)
|
|
5
|
-
[](./CHANGELOG.md)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/pubmed-mcp-server/issues)
|
|
8
8
|
[](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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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.
|
|
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:
|
|
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 = "
|
|
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
|
-
|
|
66
|
-
|
|
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,
|
|
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
|
-
//
|
|
113
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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.
|
|
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": [
|