@cyanheads/pubmed-mcp-server 1.1.4 → 1.2.2

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