@cyanheads/pubmed-mcp-server 1.1.2 → 1.2.1
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 +25 -24
- package/dist/config/index.js +39 -1
- package/dist/mcp-server/server.d.ts +0 -7
- package/dist/mcp-server/server.js +17 -53
- package/dist/mcp-server/tools/fetchPubMedContent/logic.d.ts +6 -2
- package/dist/mcp-server/tools/fetchPubMedContent/logic.js +102 -311
- package/dist/mcp-server/tools/fetchPubMedContent/registration.d.ts +1 -1
- package/dist/mcp-server/tools/fetchPubMedContent/registration.js +50 -18
- package/dist/mcp-server/tools/generatePubMedChart/logic.d.ts +9 -28
- package/dist/mcp-server/tools/generatePubMedChart/logic.js +137 -198
- package/dist/mcp-server/tools/generatePubMedChart/registration.d.ts +1 -1
- package/dist/mcp-server/tools/generatePubMedChart/registration.js +62 -27
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.d.ts +1 -1
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +3 -3
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/elinkHandler.d.ts +1 -1
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.d.ts +27 -4
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.js +59 -51
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/types.d.ts +1 -1
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.d.ts +1 -26
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.js +58 -62
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.d.ts +2 -5
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.js +7 -40
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.d.ts +1 -1
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.js +55 -19
- package/dist/mcp-server/tools/searchPubMedArticles/logic.d.ts +12 -10
- package/dist/mcp-server/tools/searchPubMedArticles/logic.js +68 -121
- package/dist/mcp-server/tools/searchPubMedArticles/registration.d.ts +1 -1
- package/dist/mcp-server/tools/searchPubMedArticles/registration.js +54 -21
- package/dist/mcp-server/transports/httpTransport.d.ts +0 -8
- package/dist/mcp-server/transports/httpTransport.js +57 -345
- package/dist/utils/security/rateLimiter.d.ts +4 -0
- package/dist/utils/security/rateLimiter.js +4 -0
- package/package.json +7 -15
- package/dist/mcp-server/resources/echoResource/echoResourceLogic.d.ts +0 -79
- package/dist/mcp-server/resources/echoResource/echoResourceLogic.js +0 -82
- package/dist/mcp-server/resources/echoResource/index.d.ts +0 -13
- package/dist/mcp-server/resources/echoResource/index.js +0 -13
- package/dist/mcp-server/resources/echoResource/registration.d.ts +0 -30
- package/dist/mcp-server/resources/echoResource/registration.js +0 -168
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic.d.ts +0 -6
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic.js +0 -6
|
@@ -19,75 +19,17 @@ import http from "http";
|
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { config } from "../../config/index.js";
|
|
21
21
|
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
|
|
22
|
-
import { logger, rateLimiter, requestContextService, } from "../../utils/index.js";
|
|
22
|
+
import { ErrorHandler, logger, rateLimiter, requestContextService, } from "../../utils/index.js";
|
|
23
23
|
import { initializeAuthMiddleware, mcpAuthMiddleware, } from "./authentication/authMiddleware.js";
|
|
24
24
|
import { oauthMiddleware } from "./authentication/oauthMiddleware.js";
|
|
25
|
-
/**
|
|
26
|
-
* The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
|
|
27
|
-
* Defaults to 3010 if not specified (default is managed by the config module).
|
|
28
|
-
* @constant {number} HTTP_PORT
|
|
29
|
-
* @private
|
|
30
|
-
*/
|
|
31
25
|
const HTTP_PORT = config.mcpHttpPort;
|
|
32
|
-
/**
|
|
33
|
-
* The host address for the HTTP transport, configured via `MCP_HTTP_HOST` environment variable.
|
|
34
|
-
* Defaults to '127.0.0.1' if not specified (default is managed by the config module).
|
|
35
|
-
* MCP Spec Security Note: Recommends binding to localhost for local servers to minimize exposure.
|
|
36
|
-
* @private
|
|
37
|
-
*/
|
|
38
26
|
const HTTP_HOST = config.mcpHttpHost;
|
|
39
|
-
/**
|
|
40
|
-
* The single HTTP endpoint path for all MCP communication, as required by the MCP specification.
|
|
41
|
-
* This endpoint supports POST, GET, DELETE, and OPTIONS methods.
|
|
42
|
-
* @constant {string} MCP_ENDPOINT_PATH
|
|
43
|
-
* @private
|
|
44
|
-
*/
|
|
45
27
|
const MCP_ENDPOINT_PATH = "/mcp";
|
|
46
|
-
/**
|
|
47
|
-
* Maximum number of attempts to find an available port if the initial `HTTP_PORT` is in use.
|
|
48
|
-
* The server will try ports sequentially: `HTTP_PORT`, `HTTP_PORT + 1`, ..., up to `MAX_PORT_RETRIES`.
|
|
49
|
-
* @constant {number} MAX_PORT_RETRIES
|
|
50
|
-
* @private
|
|
51
|
-
*/
|
|
52
28
|
const MAX_PORT_RETRIES = 15;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* This is essential for routing subsequent HTTP requests (GET, DELETE, non-initialize POST)
|
|
56
|
-
* to the correct stateful session transport instance.
|
|
57
|
-
* @type {Record<string, StreamableHTTPServerTransport>}
|
|
58
|
-
* @private
|
|
59
|
-
*/
|
|
29
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
30
|
+
const SESSION_GC_INTERVAL_MS = 60 * 1000;
|
|
60
31
|
const httpTransports = {};
|
|
61
|
-
/**
|
|
62
|
-
* Stores the last activity timestamp for each session, keyed by session ID.
|
|
63
|
-
* Used for garbage collecting stale/abandoned sessions.
|
|
64
|
-
* @type {Record<string, number>}
|
|
65
|
-
* @private
|
|
66
|
-
*/
|
|
67
32
|
const sessionActivity = {};
|
|
68
|
-
/**
|
|
69
|
-
* The timeout period in milliseconds for inactive sessions. If a session has no
|
|
70
|
-
* activity for this duration, it will be considered stale and garbage collected.
|
|
71
|
-
* Defaults to 30 minutes.
|
|
72
|
-
* @constant {number} SESSION_TIMEOUT_MS
|
|
73
|
-
* @private
|
|
74
|
-
*/
|
|
75
|
-
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
76
|
-
/**
|
|
77
|
-
* The interval in milliseconds at which the session garbage collector runs to
|
|
78
|
-
* clean up stale sessions. Defaults to 1 minute.
|
|
79
|
-
* @constant {number} SESSION_GC_INTERVAL_MS
|
|
80
|
-
* @private
|
|
81
|
-
*/
|
|
82
|
-
const SESSION_GC_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
83
|
-
/**
|
|
84
|
-
* Proactively checks if a specific network port is already in use.
|
|
85
|
-
* @param port - The port number to check.
|
|
86
|
-
* @param host - The host address to check the port on.
|
|
87
|
-
* @param parentContext - Logging context from the caller.
|
|
88
|
-
* @returns A promise that resolves to `true` if the port is in use, or `false` otherwise.
|
|
89
|
-
* @private
|
|
90
|
-
*/
|
|
91
33
|
async function isPortInUse(port, host, parentContext) {
|
|
92
34
|
const checkContext = requestContextService.createRequestContext({
|
|
93
35
|
...parentContext,
|
|
@@ -95,133 +37,66 @@ async function isPortInUse(port, host, parentContext) {
|
|
|
95
37
|
port,
|
|
96
38
|
host,
|
|
97
39
|
});
|
|
98
|
-
logger.debug(`Proactively checking port usability...`, checkContext);
|
|
99
40
|
return new Promise((resolve) => {
|
|
100
41
|
const tempServer = http.createServer();
|
|
101
42
|
tempServer
|
|
102
43
|
.once("error", (err) => {
|
|
103
|
-
|
|
104
|
-
logger.debug(`Proactive check: Port confirmed in use (EADDRINUSE).`, checkContext);
|
|
105
|
-
resolve(true);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code });
|
|
109
|
-
resolve(false);
|
|
110
|
-
}
|
|
44
|
+
resolve(err.code === "EADDRINUSE");
|
|
111
45
|
})
|
|
112
46
|
.once("listening", () => {
|
|
113
|
-
logger.debug(`Proactive check: Port is available.`, checkContext);
|
|
114
47
|
tempServer.close(() => resolve(false));
|
|
115
48
|
})
|
|
116
49
|
.listen(port, host);
|
|
117
50
|
});
|
|
118
51
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Attempts to start the HTTP server, retrying on incrementing ports if `EADDRINUSE` occurs.
|
|
121
|
-
*
|
|
122
|
-
* @param app - The Hono application instance.
|
|
123
|
-
* @param initialPort - The initial port number to try.
|
|
124
|
-
* @param host - The host address to bind to.
|
|
125
|
-
* @param maxRetries - Maximum number of additional ports to attempt.
|
|
126
|
-
* @param parentContext - Logging context from the caller.
|
|
127
|
-
* @returns A promise that resolves with the Node.js `http.Server` instance the server successfully bound to.
|
|
128
|
-
* @throws {Error} If binding fails after all retries or for a non-EADDRINUSE error.
|
|
129
|
-
* @private
|
|
130
|
-
*/
|
|
131
52
|
function startHttpServerWithRetry(app, initialPort, host, maxRetries, parentContext) {
|
|
132
53
|
const startContext = requestContextService.createRequestContext({
|
|
133
54
|
...parentContext,
|
|
134
55
|
operation: "startHttpServerWithRetry",
|
|
135
|
-
initialPort,
|
|
136
|
-
host,
|
|
137
|
-
maxRetries,
|
|
138
56
|
});
|
|
139
|
-
logger.debug(`Attempting to start HTTP server...`, startContext);
|
|
140
57
|
return new Promise(async (resolve, reject) => {
|
|
141
|
-
let lastError = null;
|
|
142
58
|
for (let i = 0; i <= maxRetries; i++) {
|
|
143
59
|
const currentPort = initialPort + i;
|
|
144
|
-
const attemptContext =
|
|
145
|
-
...startContext,
|
|
146
|
-
port: currentPort,
|
|
147
|
-
attempt: i + 1,
|
|
148
|
-
maxAttempts: maxRetries + 1,
|
|
149
|
-
});
|
|
150
|
-
logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext);
|
|
60
|
+
const attemptContext = { ...startContext, port: currentPort, attempt: i + 1 };
|
|
151
61
|
if (await isPortInUse(currentPort, host, attemptContext)) {
|
|
152
|
-
logger.warning(`
|
|
153
|
-
lastError = new Error(`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`);
|
|
154
|
-
await new Promise((res) => setTimeout(res, 100));
|
|
62
|
+
logger.warning(`Port ${currentPort} is in use, retrying...`, attemptContext);
|
|
155
63
|
continue;
|
|
156
64
|
}
|
|
157
65
|
try {
|
|
158
66
|
const serverInstance = serve({ fetch: app.fetch, port: currentPort, hostname: host }, (info) => {
|
|
159
67
|
const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
|
|
160
|
-
logger.info(`HTTP transport
|
|
161
|
-
// Display user-friendly startup message only after server is confirmed listening
|
|
162
|
-
let serverAddressLog = serverAddress;
|
|
163
|
-
let productionNote = "";
|
|
164
|
-
if (config.environment === "production") {
|
|
165
|
-
serverAddressLog = `https://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
|
|
166
|
-
productionNote = ` (via HTTPS, ensure reverse proxy is configured)`;
|
|
167
|
-
}
|
|
68
|
+
logger.info(`HTTP transport listening at ${serverAddress}`, { ...attemptContext, address: serverAddress });
|
|
168
69
|
if (process.stdout.isTTY) {
|
|
169
|
-
console.log(`\n🚀 MCP Server running
|
|
70
|
+
console.log(`\n🚀 MCP Server running at: ${serverAddress}\n`);
|
|
170
71
|
}
|
|
171
72
|
});
|
|
172
73
|
resolve(serverInstance);
|
|
173
74
|
return;
|
|
174
75
|
}
|
|
175
76
|
catch (err) {
|
|
176
|
-
|
|
177
|
-
logger.debug(`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`, { ...attemptContext, errorCode: err.code, errorMessage: err.message });
|
|
178
|
-
if (err.code === "EADDRINUSE") {
|
|
179
|
-
logger.warning(`Port ${currentPort} already in use (EADDRINUSE), retrying...`, attemptContext);
|
|
180
|
-
await new Promise((res) => setTimeout(res, 100));
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message });
|
|
77
|
+
if (err.code !== "EADDRINUSE") {
|
|
184
78
|
reject(err);
|
|
185
79
|
return;
|
|
186
80
|
}
|
|
187
81
|
}
|
|
188
82
|
}
|
|
189
|
-
|
|
190
|
-
reject(lastError ||
|
|
191
|
-
new Error("Failed to bind to any port after multiple retries."));
|
|
83
|
+
reject(new Error("Failed to bind to any port after multiple retries."));
|
|
192
84
|
});
|
|
193
85
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Sets up and starts the Streamable HTTP transport layer for the MCP server.
|
|
196
|
-
*
|
|
197
|
-
* @param createServerInstanceFn - An asynchronous factory function that returns a new `McpServer` instance.
|
|
198
|
-
* @param parentContext - Logging context from the main server startup process.
|
|
199
|
-
* @returns A promise that resolves with the Node.js `http.Server` instance when the HTTP server is successfully listening.
|
|
200
|
-
* @throws {Error} If the server fails to start after all port retries.
|
|
201
|
-
*/
|
|
202
86
|
export async function startHttpTransport(createServerInstanceFn, parentContext) {
|
|
203
|
-
initializeAuthMiddleware();
|
|
87
|
+
initializeAuthMiddleware();
|
|
204
88
|
const app = new Hono();
|
|
205
89
|
const transportContext = requestContextService.createRequestContext({
|
|
206
90
|
...parentContext,
|
|
207
|
-
transportType: "HTTP",
|
|
208
91
|
component: "HttpTransportSetup",
|
|
209
92
|
});
|
|
210
|
-
logger.debug("Setting up Hono app for HTTP transport...", transportContext);
|
|
211
|
-
// Start the session garbage collector
|
|
212
93
|
setInterval(() => {
|
|
213
94
|
const now = Date.now();
|
|
214
|
-
const gcContext = requestContextService.createRequestContext({
|
|
215
|
-
operation: "SessionGarbageCollector",
|
|
216
|
-
});
|
|
217
|
-
logger.debug("Running session garbage collector...", gcContext);
|
|
95
|
+
const gcContext = requestContextService.createRequestContext({ operation: "SessionGarbageCollector" });
|
|
218
96
|
for (const sessionId in sessionActivity) {
|
|
219
97
|
if (now - sessionActivity[sessionId] > SESSION_TIMEOUT_MS) {
|
|
220
|
-
logger.info(`Session ${sessionId} timed out
|
|
221
|
-
|
|
222
|
-
if (transport) {
|
|
223
|
-
transport.close(); // This will trigger the onclose handler to delete it from httpTransports
|
|
224
|
-
}
|
|
98
|
+
logger.info(`Session ${sessionId} timed out. Cleaning up.`, { ...gcContext, sessionId });
|
|
99
|
+
httpTransports[sessionId]?.close();
|
|
225
100
|
delete sessionActivity[sessionId];
|
|
226
101
|
}
|
|
227
102
|
}
|
|
@@ -229,68 +104,29 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
|
|
|
229
104
|
app.use("*", cors({
|
|
230
105
|
origin: config.mcpAllowedOrigins || [],
|
|
231
106
|
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
232
|
-
allowHeaders: [
|
|
233
|
-
"Content-Type",
|
|
234
|
-
"Mcp-Session-Id",
|
|
235
|
-
"Last-Event-ID",
|
|
236
|
-
"Authorization",
|
|
237
|
-
],
|
|
107
|
+
allowHeaders: ["Content-Type", "Mcp-Session-Id", "Last-Event-ID", "Authorization"],
|
|
238
108
|
credentials: true,
|
|
239
109
|
}));
|
|
240
110
|
app.use("*", async (c, next) => {
|
|
241
|
-
const securityContext = requestContextService.createRequestContext({
|
|
242
|
-
...transportContext,
|
|
243
|
-
operation: "securityMiddleware",
|
|
244
|
-
path: c.req.path,
|
|
245
|
-
method: c.req.method,
|
|
246
|
-
origin: c.req.header("origin"),
|
|
247
|
-
});
|
|
248
|
-
logger.debug(`Applying security middleware...`, securityContext);
|
|
249
111
|
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
250
|
-
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
251
|
-
c.res.headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self'; media-src 'self'; frame-src 'none'; font-src 'self'; connect-src 'self'");
|
|
252
|
-
logger.debug("Security middleware passed.", securityContext);
|
|
253
112
|
await next();
|
|
254
113
|
});
|
|
255
114
|
app.use(MCP_ENDPOINT_PATH, async (c, next) => {
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const rateLimitKey = clientIp || c.req.header("host") || "unknown_ip_for_rate_limit";
|
|
259
|
-
const context = requestContextService.createRequestContext({
|
|
260
|
-
operation: "httpRateLimitCheck",
|
|
261
|
-
ipAddress: rateLimitKey,
|
|
262
|
-
method: c.req.method,
|
|
263
|
-
path: c.req.path,
|
|
264
|
-
});
|
|
115
|
+
const clientIp = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown_ip";
|
|
116
|
+
const context = requestContextService.createRequestContext({ operation: "httpRateLimitCheck", ipAddress: clientIp });
|
|
265
117
|
try {
|
|
266
|
-
rateLimiter.check(
|
|
267
|
-
logger.debug("Rate limit check passed.", context);
|
|
118
|
+
rateLimiter.check(clientIp, context);
|
|
268
119
|
await next();
|
|
269
120
|
}
|
|
270
121
|
catch (error) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
});
|
|
278
|
-
return c.json({
|
|
279
|
-
jsonrpc: "2.0",
|
|
280
|
-
error: { code: -32000, message: "Too Many Requests" },
|
|
281
|
-
id: (await c.req.json().catch(() => ({})))?.id || null,
|
|
282
|
-
}, 429);
|
|
283
|
-
}
|
|
284
|
-
else {
|
|
285
|
-
logger.error("Unexpected error in rate limit middleware", {
|
|
286
|
-
...context,
|
|
287
|
-
error: error instanceof Error ? error.message : String(error),
|
|
288
|
-
});
|
|
289
|
-
throw error;
|
|
290
|
-
}
|
|
122
|
+
const handledError = ErrorHandler.handleError(error, { operation: "rateLimitMiddleware", context });
|
|
123
|
+
return c.json({
|
|
124
|
+
jsonrpc: "2.0",
|
|
125
|
+
error: { code: -32000, message: handledError.message },
|
|
126
|
+
id: (await c.req.json().catch(() => ({})))?.id || null,
|
|
127
|
+
}, 429);
|
|
291
128
|
}
|
|
292
129
|
});
|
|
293
|
-
// Use the appropriate authentication middleware based on config
|
|
294
130
|
if (config.mcpAuthMode === "oauth") {
|
|
295
131
|
app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
|
|
296
132
|
}
|
|
@@ -298,200 +134,76 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
|
|
|
298
134
|
app.use(MCP_ENDPOINT_PATH, mcpAuthMiddleware);
|
|
299
135
|
}
|
|
300
136
|
app.post(MCP_ENDPOINT_PATH, async (c) => {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
operation: "handlePost",
|
|
304
|
-
method: "POST",
|
|
305
|
-
path: c.req.path,
|
|
306
|
-
origin: c.req.header("origin"),
|
|
307
|
-
});
|
|
308
|
-
const body = await c.req.json();
|
|
309
|
-
logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, {
|
|
310
|
-
...basePostContext,
|
|
311
|
-
headers: c.req.header(),
|
|
312
|
-
bodyPreview: JSON.stringify(body).substring(0, 100),
|
|
313
|
-
});
|
|
314
|
-
const sessionId = c.req.header("mcp-session-id");
|
|
315
|
-
logger.debug(`Extracted session ID: ${sessionId}`, {
|
|
316
|
-
...basePostContext,
|
|
317
|
-
sessionId,
|
|
318
|
-
});
|
|
319
|
-
let transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
320
|
-
if (transport && sessionId) {
|
|
321
|
-
sessionActivity[sessionId] = Date.now(); // Update activity timestamp
|
|
322
|
-
}
|
|
323
|
-
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
|
|
324
|
-
...basePostContext,
|
|
325
|
-
sessionId,
|
|
326
|
-
});
|
|
327
|
-
const isInitReq = isInitializeRequest(body);
|
|
328
|
-
logger.debug(`Is InitializeRequest: ${isInitReq}`, {
|
|
329
|
-
...basePostContext,
|
|
330
|
-
sessionId,
|
|
331
|
-
});
|
|
332
|
-
const requestId = body?.id || null;
|
|
137
|
+
const postContext = requestContextService.createRequestContext({ ...transportContext, operation: "handlePost" });
|
|
138
|
+
let transport;
|
|
333
139
|
try {
|
|
334
|
-
|
|
140
|
+
const body = await c.req.json();
|
|
141
|
+
const sessionId = c.req.header("mcp-session-id");
|
|
142
|
+
transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
143
|
+
if (transport && sessionId)
|
|
144
|
+
sessionActivity[sessionId] = Date.now();
|
|
145
|
+
if (isInitializeRequest(body)) {
|
|
335
146
|
if (transport) {
|
|
336
|
-
logger.warning("
|
|
147
|
+
logger.warning("Re-initializing existing session.", { ...postContext, sessionId });
|
|
337
148
|
await transport.close();
|
|
338
|
-
// onclose handler will delete from httpTransports and sessionActivity
|
|
339
149
|
}
|
|
340
|
-
logger.info("Handling Initialize Request: Creating new session...", {
|
|
341
|
-
...basePostContext,
|
|
342
|
-
sessionId,
|
|
343
|
-
});
|
|
344
150
|
transport = new StreamableHTTPServerTransport({
|
|
345
|
-
sessionIdGenerator: () =>
|
|
346
|
-
const newId = randomUUID();
|
|
347
|
-
logger.debug(`Generated new session ID: ${newId}`, basePostContext);
|
|
348
|
-
return newId;
|
|
349
|
-
},
|
|
151
|
+
sessionIdGenerator: () => randomUUID(),
|
|
350
152
|
onsessioninitialized: (newId) => {
|
|
351
|
-
logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId });
|
|
352
153
|
httpTransports[newId] = transport;
|
|
353
|
-
sessionActivity[newId] = Date.now();
|
|
354
|
-
logger.info(`HTTP Session created: ${newId}`, {
|
|
355
|
-
...basePostContext,
|
|
356
|
-
newSessionId: newId,
|
|
357
|
-
});
|
|
154
|
+
sessionActivity[newId] = Date.now();
|
|
155
|
+
logger.info(`HTTP Session created: ${newId}`, { ...postContext, newSessionId: newId });
|
|
358
156
|
},
|
|
359
157
|
});
|
|
360
158
|
transport.onclose = () => {
|
|
361
159
|
const closedSessionId = transport.sessionId;
|
|
362
160
|
if (closedSessionId) {
|
|
363
|
-
logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId });
|
|
364
161
|
delete httpTransports[closedSessionId];
|
|
365
|
-
delete sessionActivity[closedSessionId];
|
|
366
|
-
logger.info(`HTTP Session closed: ${closedSessionId}`, {
|
|
367
|
-
...basePostContext,
|
|
368
|
-
closedSessionId,
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
logger.debug("onclose handler triggered for transport without session ID (likely init failure).", basePostContext);
|
|
162
|
+
delete sessionActivity[closedSessionId];
|
|
163
|
+
logger.info(`HTTP Session closed: ${closedSessionId}`, { ...postContext, closedSessionId });
|
|
373
164
|
}
|
|
374
165
|
};
|
|
375
|
-
logger.debug("Creating McpServer instance for new session...", basePostContext);
|
|
376
166
|
const server = await createServerInstanceFn();
|
|
377
|
-
logger.debug("Connecting McpServer to new transport...", basePostContext);
|
|
378
167
|
await server.connect(transport);
|
|
379
|
-
logger.debug("McpServer connected to transport.", basePostContext);
|
|
380
168
|
}
|
|
381
169
|
else if (!transport) {
|
|
382
|
-
|
|
383
|
-
return c.json({
|
|
384
|
-
jsonrpc: "2.0",
|
|
385
|
-
error: { code: -32004, message: "Invalid or expired session ID" },
|
|
386
|
-
id: requestId,
|
|
387
|
-
}, 404);
|
|
170
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, "Invalid or expired session ID.");
|
|
388
171
|
}
|
|
389
|
-
|
|
390
|
-
logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
|
|
391
|
-
const response = await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
|
|
392
|
-
logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
|
|
393
|
-
return response;
|
|
172
|
+
return await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
|
|
394
173
|
}
|
|
395
174
|
catch (err) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
...basePostContext,
|
|
399
|
-
sessionId: errorSessionId,
|
|
400
|
-
isInitReq,
|
|
401
|
-
error: err instanceof Error ? err.message : String(err),
|
|
402
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
403
|
-
});
|
|
404
|
-
if (isInitReq && transport && !transport.sessionId) {
|
|
405
|
-
logger.debug("Cleaning up transport after initialization failure.", {
|
|
406
|
-
...basePostContext,
|
|
407
|
-
sessionId: errorSessionId,
|
|
408
|
-
});
|
|
409
|
-
await transport.close().catch((closeErr) => logger.error("Error closing transport after init failure", {
|
|
410
|
-
...basePostContext,
|
|
411
|
-
sessionId: errorSessionId,
|
|
412
|
-
closeError: closeErr,
|
|
413
|
-
}));
|
|
414
|
-
}
|
|
175
|
+
const handledError = ErrorHandler.handleError(err, { operation: "handlePost", context: postContext });
|
|
176
|
+
const requestId = (await c.req.json().catch(() => ({})))?.id || null;
|
|
415
177
|
return c.json({
|
|
416
178
|
jsonrpc: "2.0",
|
|
417
|
-
error: {
|
|
418
|
-
code: -32603,
|
|
419
|
-
message: "Internal server error during POST handling",
|
|
420
|
-
},
|
|
179
|
+
error: { code: -32603, message: handledError.message },
|
|
421
180
|
id: requestId,
|
|
422
|
-
}, 500);
|
|
181
|
+
}, handledError instanceof McpError && handledError.code === BaseErrorCode.NOT_FOUND ? 404 : 500);
|
|
423
182
|
}
|
|
424
183
|
});
|
|
425
184
|
const handleSessionReq = async (c) => {
|
|
426
185
|
const method = c.req.method;
|
|
427
|
-
const
|
|
428
|
-
...transportContext,
|
|
429
|
-
operation: `handle${method}`,
|
|
430
|
-
method,
|
|
431
|
-
path: c.req.path,
|
|
432
|
-
origin: c.req.header("origin"),
|
|
433
|
-
});
|
|
434
|
-
logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, {
|
|
435
|
-
...baseSessionReqContext,
|
|
436
|
-
headers: c.req.header(),
|
|
437
|
-
});
|
|
438
|
-
const sessionId = c.req.header("mcp-session-id");
|
|
439
|
-
logger.debug(`Extracted session ID: ${sessionId}`, {
|
|
440
|
-
...baseSessionReqContext,
|
|
441
|
-
sessionId,
|
|
442
|
-
});
|
|
443
|
-
const transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
444
|
-
if (transport && sessionId) {
|
|
445
|
-
sessionActivity[sessionId] = Date.now(); // Update activity timestamp
|
|
446
|
-
}
|
|
447
|
-
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
|
|
448
|
-
...baseSessionReqContext,
|
|
449
|
-
sessionId,
|
|
450
|
-
});
|
|
451
|
-
if (!transport) {
|
|
452
|
-
logger.warning(`Session not found for ${method} request`, {
|
|
453
|
-
...baseSessionReqContext,
|
|
454
|
-
sessionId,
|
|
455
|
-
});
|
|
456
|
-
return c.json({
|
|
457
|
-
jsonrpc: "2.0",
|
|
458
|
-
error: { code: -32004, message: "Session not found or expired" },
|
|
459
|
-
id: null,
|
|
460
|
-
}, 404);
|
|
461
|
-
}
|
|
186
|
+
const sessionReqContext = requestContextService.createRequestContext({ ...transportContext, operation: `handle${method}` });
|
|
462
187
|
try {
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
188
|
+
const sessionId = c.req.header("mcp-session-id");
|
|
189
|
+
const transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
190
|
+
if (!transport) {
|
|
191
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, "Session not found or expired.");
|
|
192
|
+
}
|
|
193
|
+
if (sessionId)
|
|
194
|
+
sessionActivity[sessionId] = Date.now();
|
|
195
|
+
return await transport.handleRequest(c.env.incoming, c.env.outgoing);
|
|
467
196
|
}
|
|
468
197
|
catch (err) {
|
|
469
|
-
|
|
470
|
-
...baseSessionReqContext,
|
|
471
|
-
sessionId,
|
|
472
|
-
error: err instanceof Error ? err.message : String(err),
|
|
473
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
474
|
-
});
|
|
198
|
+
const handledError = ErrorHandler.handleError(err, { operation: `handle${method}`, context: sessionReqContext });
|
|
475
199
|
return c.json({
|
|
476
200
|
jsonrpc: "2.0",
|
|
477
|
-
error: { code: -32603, message:
|
|
201
|
+
error: { code: -32603, message: handledError.message },
|
|
478
202
|
id: null,
|
|
479
|
-
}, 500);
|
|
203
|
+
}, handledError instanceof McpError && handledError.code === BaseErrorCode.NOT_FOUND ? 404 : 500);
|
|
480
204
|
}
|
|
481
205
|
};
|
|
482
206
|
app.get(MCP_ENDPOINT_PATH, handleSessionReq);
|
|
483
207
|
app.delete(MCP_ENDPOINT_PATH, handleSessionReq);
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
logger.debug("Attempting to start HTTP server with retry logic...", transportContext);
|
|
487
|
-
const serverInstance = await startHttpServerWithRetry(app, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext);
|
|
488
|
-
return serverInstance;
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
logger.fatal("HTTP server failed to start after multiple port retries.", {
|
|
492
|
-
...transportContext,
|
|
493
|
-
error: err instanceof Error ? err.message : String(err),
|
|
494
|
-
});
|
|
495
|
-
throw err;
|
|
496
|
-
}
|
|
208
|
+
return startHttpServerWithRetry(app, HTTP_PORT, HTTP_HOST, MAX_PORT_RETRIES, transportContext);
|
|
497
209
|
}
|
|
@@ -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
|
|
3
|
+
"version": "1.2.1",
|
|
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": [
|
|
@@ -33,22 +33,16 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@hono/node-server": "^1.14.4",
|
|
36
|
-
"@modelcontextprotocol/sdk": "^1.12.
|
|
37
|
-
"@node-oauth/oauth2-server": "^5.2.0",
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.12.3",
|
|
38
37
|
"@types/jsonwebtoken": "^9.0.9",
|
|
39
38
|
"@types/node": "^24.0.1",
|
|
40
39
|
"@types/sanitize-html": "^2.16.0",
|
|
41
40
|
"@types/validator": "13.15.1",
|
|
42
|
-
"axios": "^1.
|
|
43
|
-
"
|
|
44
|
-
"chalk": "^5.4.1",
|
|
45
|
-
"chrono-node": "^2.8.2",
|
|
46
|
-
"cli-table3": "^0.6.5",
|
|
41
|
+
"axios": "^1.10.0",
|
|
42
|
+
"chrono-node": "^2.8.3",
|
|
47
43
|
"dotenv": "^16.5.0",
|
|
48
|
-
"express": "^5.1.0",
|
|
49
44
|
"fast-xml-parser": "^5.2.5",
|
|
50
45
|
"hono": "^4.7.11",
|
|
51
|
-
"ignore": "^7.0.5",
|
|
52
46
|
"jose": "^6.0.11",
|
|
53
47
|
"jsonwebtoken": "^9.0.2",
|
|
54
48
|
"openai": "^5.3.0",
|
|
@@ -58,11 +52,10 @@
|
|
|
58
52
|
"ts-node": "^10.9.2",
|
|
59
53
|
"typescript": "^5.8.3",
|
|
60
54
|
"validator": "13.15.15",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
55
|
+
"chart.js": "^4.5.0",
|
|
56
|
+
"chartjs-node-canvas": "^5.0.0",
|
|
63
57
|
"winston": "^3.17.0",
|
|
64
|
-
"winston-
|
|
65
|
-
"yargs": "^18.0.0",
|
|
58
|
+
"winston-transport": "^4.9.0",
|
|
66
59
|
"zod": "^3.25.64"
|
|
67
60
|
},
|
|
68
61
|
"keywords": [
|
|
@@ -103,7 +96,6 @@
|
|
|
103
96
|
"node": ">=16.0.0"
|
|
104
97
|
},
|
|
105
98
|
"devDependencies": {
|
|
106
|
-
"@types/express": "^5.0.3",
|
|
107
99
|
"@types/js-yaml": "^4.0.9",
|
|
108
100
|
"js-yaml": "^4.1.0",
|
|
109
101
|
"prettier": "^3.5.3",
|