@cyanheads/git-mcp-server 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,554 +1,208 @@
1
1
  /**
2
- * Handles the setup and management of the Streamable HTTP MCP transport.
3
- * Implements the MCP Specification 2025-03-26 for Streamable HTTP.
4
- * Includes Express server creation, middleware (CORS, Auth), request routing
5
- * (POST/GET/DELETE on a single endpoint), session handling, SSE streaming,
6
- * and port binding with retry logic.
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.
7
13
  *
8
14
  * Specification Reference:
9
15
  * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
16
+ * @module src/mcp-server/transports/httpTransport
10
17
  */
18
+ import { serve } from "@hono/node-server";
11
19
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; // SDK type guard for InitializeRequest
13
- import express from "express";
20
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
21
+ import { Hono } from "hono";
22
+ import { cors } from "hono/cors";
14
23
  import http from "http";
15
24
  import { randomUUID } from "node:crypto";
16
- // Import config and utils
17
- import { config } from "../../config/index.js"; // Import the validated config object
18
- import { logger } from "../../utils/index.js";
19
- import { mcpAuthMiddleware } from "./authentication/authMiddleware.js"; // Import the auth middleware
20
- // --- Configuration Constants (Derived from imported config) ---
21
- /**
22
- * The port number for the HTTP transport, configured via MCP_HTTP_PORT.
23
- * Defaults to 3010 (defined in config/index.ts).
24
- * @constant {number} HTTP_PORT
25
- */
25
+ import { config } from "../../config/index.js";
26
+ import { BaseErrorCode, McpError } from "../../types-global/errors.js";
27
+ import { logger, rateLimiter, requestContextService, } from "../../utils/index.js";
28
+ import { jwtAuthMiddleware, oauthMiddleware, } from "./auth/index.js";
29
+ import { httpErrorHandler } from "./httpErrorHandler.js";
26
30
  const HTTP_PORT = config.mcpHttpPort;
27
- /**
28
- * The host address for the HTTP transport, configured via MCP_HTTP_HOST.
29
- * Defaults to '127.0.0.1' (defined in config/index.ts).
30
- * MCP Spec Security: Recommends binding to localhost for local servers.
31
- * @constant {string} HTTP_HOST
32
- */
33
31
  const HTTP_HOST = config.mcpHttpHost;
34
- /**
35
- * The single HTTP endpoint path for all MCP communication, as required by the spec.
36
- * Supports POST, GET, DELETE, OPTIONS methods.
37
- * @constant {string} MCP_ENDPOINT_PATH
38
- */
39
32
  const MCP_ENDPOINT_PATH = "/mcp";
40
- /**
41
- * Maximum number of attempts to find an available port if the initial HTTP_PORT is in use.
42
- * Tries ports sequentially: HTTP_PORT, HTTP_PORT + 1, ...
43
- * @constant {number} MAX_PORT_RETRIES
44
- */
45
33
  const MAX_PORT_RETRIES = 15;
46
- /**
47
- * Stores active StreamableHTTPServerTransport instances, keyed by their session ID.
48
- * Essential for routing subsequent requests to the correct stateful session.
49
- * @type {Record<string, StreamableHTTPServerTransport>}
50
- */
51
- const httpTransports = {};
52
- /** Stores the working directory for each active HTTP session. */
53
- const sessionWorkingDirectories = new Map();
54
- /**
55
- * Gets the current working directory set for a specific HTTP session.
56
- * @param {string} sessionId - The ID of the session.
57
- * @returns {string | undefined} The current working directory path or undefined if not set.
58
- */
59
- export function getHttpSessionWorkingDirectory(sessionId) {
60
- return sessionWorkingDirectories.get(sessionId);
61
- }
62
- /**
63
- * Sets the working directory for a specific HTTP session.
64
- * @param {string} sessionId - The ID of the session.
65
- * @param {string} dir - The new working directory path.
66
- */
67
- export function setHttpSessionWorkingDirectory(sessionId, dir) {
68
- sessionWorkingDirectories.set(sessionId, dir);
69
- logger.info(`HTTP session ${sessionId} working directory set to: ${dir}`, {
70
- operation: "setHttpSessionWorkingDirectory",
71
- sessionId,
72
- });
73
- }
74
- /**
75
- * Checks if an incoming HTTP request's origin header is permissible.
76
- * MCP Spec Security: Servers MUST validate the `Origin` header.
77
- * This function checks against `MCP_ALLOWED_ORIGINS` and allows requests
78
- * from localhost if the server is bound locally. Sets CORS headers if allowed.
79
- *
80
- * @param {Request} req - Express request object.
81
- * @param {Response} res - Express response object.
82
- * @returns {boolean} True if the origin is allowed, false otherwise.
83
- */
84
- function isOriginAllowed(req, res) {
85
- const origin = req.headers.origin;
86
- const host = req.hostname; // Considers Host header
87
- const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(host);
88
- const allowedOrigins = config.mcpAllowedOrigins || []; // Use parsed array from config
89
- const context = {
90
- operation: "isOriginAllowed",
91
- origin,
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 = {};
40
+ async function isPortInUse(port, host, parentContext) {
41
+ requestContextService.createRequestContext({
42
+ ...parentContext,
43
+ operation: "isPortInUse",
44
+ port,
92
45
  host,
93
- isLocalhostBinding,
94
- allowedOrigins,
95
- };
96
- logger.debug("Checking origin allowance", context);
97
- // Determine if allowed based on config or localhost binding
98
- const allowed = (origin && allowedOrigins.includes(origin)) ||
99
- (isLocalhostBinding && (!origin || origin === "null"));
100
- if (allowed && origin) {
101
- // Origin is allowed and present, set specific CORS headers.
102
- res.setHeader("Access-Control-Allow-Origin", origin);
103
- // MCP Spec: Streamable HTTP uses POST, GET, DELETE. OPTIONS is for preflight.
104
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
105
- // MCP Spec: Requires Mcp-Session-Id. Last-Event-ID for SSE resumption. Content-Type is standard. Authorization for security.
106
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization");
107
- res.setHeader("Access-Control-Allow-Credentials", "true"); // Set based on whether auth/cookies are used
108
- }
109
- else if (allowed && !origin) {
110
- // Allowed (e.g., localhost binding, file:// origin), but no origin header to echo back. No specific CORS needed.
111
- }
112
- else if (!allowed && origin) {
113
- // Origin provided but not in allowed list. Log warning.
114
- logger.warning(`Origin denied: ${origin}`, context);
115
- }
116
- logger.debug(`Origin check result: ${allowed}`, { ...context, allowed });
117
- return allowed;
118
- }
119
- /**
120
- * Proactively checks if a specific port is already in use. (Asynchronous)
121
- * @param {number} port - Port to check.
122
- * @param {string} host - Host address to check.
123
- * @param {Record<string, any>} context - Logging context.
124
- * @returns {Promise<boolean>} True if port is in use (EADDRINUSE), false otherwise.
125
- */
126
- async function isPortInUse(port, host, context) {
127
- const checkContext = { ...context, operation: "isPortInUse", port, host };
128
- logger.debug(`Proactively checking port usability...`, checkContext);
46
+ });
129
47
  return new Promise((resolve) => {
130
48
  const tempServer = http.createServer();
131
49
  tempServer
132
50
  .once("error", (err) => {
133
- if (err.code === "EADDRINUSE") {
134
- logger.debug(`Proactive check: Port confirmed in use (EADDRINUSE).`, checkContext);
135
- resolve(true); // Port is definitely in use
136
- }
137
- else {
138
- logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code });
139
- resolve(false); // Other error, let main listen attempt handle it
140
- }
51
+ resolve(err.code === "EADDRINUSE");
141
52
  })
142
53
  .once("listening", () => {
143
- logger.debug(`Proactive check: Port is available.`, checkContext);
144
- tempServer.close(() => resolve(false)); // Port is free
54
+ tempServer.close(() => resolve(false));
145
55
  })
146
56
  .listen(port, host);
147
57
  });
148
58
  }
149
- /**
150
- * Attempts to start the HTTP server, retrying on incrementing ports if EADDRINUSE occurs. (Asynchronous)
151
- * Uses proactive checks before attempting to bind the main server instance.
152
- *
153
- * @param {http.Server} serverInstance - The Node.js HTTP server instance.
154
- * @param {number} initialPort - The starting port number.
155
- * @param {string} host - The host address to bind to.
156
- * @param {number} maxRetries - Maximum number of additional ports to try.
157
- * @param {Record<string, any>} context - Logging context.
158
- * @returns {Promise<number>} Resolves with the port number successfully bound to.
159
- * @throws {Error} Rejects if binding fails after all retries or for non-EADDRINUSE errors.
160
- */
161
- function startHttpServerWithRetry(serverInstance, initialPort, host, maxRetries, context) {
162
- const startContext = {
163
- ...context,
59
+ function startHttpServerWithRetry(app, initialPort, host, maxRetries, parentContext) {
60
+ const startContext = requestContextService.createRequestContext({
61
+ ...parentContext,
164
62
  operation: "startHttpServerWithRetry",
165
- initialPort,
166
- host,
167
- maxRetries,
168
- };
169
- logger.debug(`Attempting to start HTTP server...`, startContext);
63
+ });
170
64
  return new Promise(async (resolve, reject) => {
171
- let lastError = null;
172
65
  for (let i = 0; i <= maxRetries; i++) {
173
66
  const currentPort = initialPort + i;
174
67
  const attemptContext = {
175
68
  ...startContext,
176
69
  port: currentPort,
177
70
  attempt: i + 1,
178
- maxAttempts: maxRetries + 1,
179
71
  };
180
- logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext);
181
- // 1. Proactive Check
182
72
  if (await isPortInUse(currentPort, host, attemptContext)) {
183
- logger.warning(`Proactive check detected port ${currentPort} is in use, retrying...`, attemptContext);
184
- lastError = new Error(`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`);
185
- await new Promise((res) => setTimeout(res, 100)); // Short delay
186
- continue; // Try next port
73
+ logger.warning(`Port ${currentPort} is in use, retrying...`, attemptContext);
74
+ continue;
187
75
  }
188
- // 2. Attempt Main Server Bind
189
76
  try {
190
- await new Promise((listenResolve, listenReject) => {
191
- serverInstance
192
- .listen(currentPort, host, () => {
193
- const serverAddress = `http://${host}:${currentPort}${MCP_ENDPOINT_PATH}`;
194
- logger.info(`HTTP transport successfully listening on host ${host} at ${serverAddress}`, { ...attemptContext, address: serverAddress });
195
- listenResolve(); // Success
196
- })
197
- .on("error", (err) => {
198
- listenReject(err); // Forward error
77
+ const serverInstance = serve({ fetch: app.fetch, port: currentPort, hostname: host }, (info) => {
78
+ const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
79
+ logger.info(`HTTP transport listening at ${serverAddress}`, {
80
+ ...attemptContext,
81
+ address: serverAddress,
199
82
  });
83
+ if (process.stdout.isTTY) {
84
+ console.log(`\nšŸš€ MCP Server running at: ${serverAddress}\n`);
85
+ }
200
86
  });
201
- resolve(currentPort); // Listen succeeded
202
- return; // Exit function
87
+ resolve(serverInstance);
88
+ return;
203
89
  }
204
90
  catch (err) {
205
- lastError = err;
206
- logger.debug(`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`, { ...attemptContext, errorCode: err.code, errorMessage: err.message });
207
- if (err.code === "EADDRINUSE") {
208
- logger.warning(`Port ${currentPort} already in use (EADDRINUSE), retrying...`, attemptContext);
209
- await new Promise((res) => setTimeout(res, 100)); // Short delay before retry
210
- }
211
- else {
212
- logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message });
213
- reject(err); // Non-recoverable error for this port
214
- return; // Exit function
91
+ if (err.code !== "EADDRINUSE") {
92
+ reject(err);
93
+ return;
215
94
  }
216
95
  }
217
96
  }
218
- // Loop finished without success
219
- logger.error(`Failed to bind to any port after ${maxRetries + 1} attempts. Last error: ${lastError?.message}`, { ...startContext, error: lastError?.message });
220
- reject(lastError ||
221
- new Error("Failed to bind to any port after multiple retries."));
97
+ reject(new Error("Failed to bind to any port after multiple retries."));
222
98
  });
223
99
  }
224
- /**
225
- * Sets up and starts the Streamable HTTP transport layer for MCP. (Asynchronous)
226
- * Creates Express app, configures middleware (CORS, Auth, Security Headers),
227
- * defines the single MCP endpoint handler for POST/GET/DELETE, manages sessions,
228
- * and starts the HTTP server with retry logic.
229
- *
230
- * @param {() => Promise<McpServer>} createServerInstanceFn - Async factory function to create a new McpServer instance per session.
231
- * @param {Record<string, any>} context - Logging context.
232
- * @returns {Promise<void>} Resolves when the server is listening, or rejects on failure.
233
- * @throws {Error} If the server fails to start after retries.
234
- */
235
- export async function startHttpTransport(createServerInstanceFn, context) {
236
- const app = express();
237
- const transportContext = { ...context, transportType: "HTTP" };
238
- logger.debug("Setting up Express app for HTTP transport...", transportContext);
239
- // Middleware to parse JSON request bodies. Required for MCP messages.
240
- app.use(express.json());
241
- // --- Security Middleware Pipeline ---
242
- // 1. CORS Preflight (OPTIONS) Handler
243
- // Handles OPTIONS requests sent by browsers before actual GET/POST/DELETE.
244
- app.options(MCP_ENDPOINT_PATH, (req, res) => {
245
- const optionsContext = {
246
- ...transportContext,
247
- operation: "handleOptions",
248
- origin: req.headers.origin,
249
- };
250
- logger.debug(`Received OPTIONS request for ${MCP_ENDPOINT_PATH}`, optionsContext);
251
- if (isOriginAllowed(req, res)) {
252
- // isOriginAllowed sets necessary Access-Control-* headers.
253
- logger.debug("OPTIONS request origin allowed, sending 204.", optionsContext);
254
- res.sendStatus(204); // OK, No Content
255
- }
256
- else {
257
- // isOriginAllowed logs the warning.
258
- logger.debug("OPTIONS request origin denied, sending 403.", optionsContext);
259
- res.status(403).send("Forbidden: Invalid Origin");
260
- }
100
+ export async function startHttpTransport(createServerInstanceFn, parentContext) {
101
+ const app = new Hono();
102
+ const transportContext = requestContextService.createRequestContext({
103
+ ...parentContext,
104
+ component: "HttpTransportSetup",
261
105
  });
262
- // 2. General Security Headers & Origin Check Middleware (for non-OPTIONS)
263
- app.use((req, res, next) => {
264
- const securityContext = {
265
- ...transportContext,
266
- operation: "securityMiddleware",
267
- path: req.path,
268
- method: req.method,
269
- origin: req.headers.origin,
270
- };
271
- logger.debug(`Applying security middleware...`, securityContext);
272
- // Check origin again for non-OPTIONS requests and set CORS headers if allowed.
273
- if (!isOriginAllowed(req, res)) {
274
- // isOriginAllowed logs the warning.
275
- logger.debug("Origin check failed, sending 403.", securityContext);
276
- res.status(403).send("Forbidden: Invalid Origin");
277
- return; // Block request
278
- }
279
- // Apply standard security headers to all allowed responses.
280
- res.setHeader("X-Content-Type-Options", "nosniff");
281
- res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
282
- // Basic Content Security Policy (CSP). Adjust if server needs external connections.
283
- // 'connect-src 'self'' allows connections back to the server's own origin (needed for SSE).
284
- res.setHeader("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'");
285
- // Strict-Transport-Security (HSTS) - IMPORTANT: Enable only if server is *always* served over HTTPS.
286
- // if (config.environment === 'production') {
287
- // res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); // 1 year
288
- // }
289
- logger.debug("Security middleware passed.", securityContext);
290
- next(); // Proceed to next middleware/handler
106
+ app.use("*", cors({
107
+ origin: config.mcpAllowedOrigins || [],
108
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
109
+ allowHeaders: [
110
+ "Content-Type",
111
+ "Mcp-Session-Id",
112
+ "Last-Event-ID",
113
+ "Authorization",
114
+ ],
115
+ credentials: true,
116
+ }));
117
+ app.use("*", async (c, next) => {
118
+ c.res.headers.set("X-Content-Type-Options", "nosniff");
119
+ await next();
291
120
  });
292
- // 3. MCP Authentication Middleware (Optional, based on config)
293
- // Verifies Authorization header (e.g., Bearer token) if enabled.
294
- app.use(mcpAuthMiddleware);
295
- // --- MCP Route Handlers ---
296
- // Handle POST requests: Used for Initialize and all subsequent client->server messages.
297
- // MCP Spec: Client MUST use POST. Body is single message or batch.
298
- // MCP Spec: Server responds 202 for notification/response-only, or JSON/SSE for requests.
299
- app.post(MCP_ENDPOINT_PATH, async (req, res) => {
300
- // Define base context for this request
301
- const basePostContext = {
121
+ app.use(MCP_ENDPOINT_PATH, async (c, next) => {
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";
126
+ const context = requestContextService.createRequestContext({
127
+ operation: "httpRateLimitCheck",
128
+ ipAddress: clientIp,
129
+ });
130
+ // Let the centralized error handler catch rate limit errors
131
+ rateLimiter.check(clientIp, context);
132
+ await next();
133
+ });
134
+ if (config.mcpAuthMode === "oauth") {
135
+ app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
136
+ }
137
+ else {
138
+ app.use(MCP_ENDPOINT_PATH, jwtAuthMiddleware);
139
+ }
140
+ // Centralized Error Handling
141
+ app.onError(httpErrorHandler);
142
+ app.post(MCP_ENDPOINT_PATH, async (c) => {
143
+ const postContext = requestContextService.createRequestContext({
302
144
  ...transportContext,
303
145
  operation: "handlePost",
304
- method: "POST",
305
- };
306
- logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, {
307
- ...basePostContext,
308
- headers: req.headers,
309
- bodyPreview: JSON.stringify(req.body).substring(0, 100),
310
- });
311
- // MCP Spec: Session ID MUST be included by client after initialization.
312
- const sessionId = req.headers["mcp-session-id"];
313
- // Log extracted session ID, adding it to the context for this specific log message
314
- logger.debug(`Extracted session ID: ${sessionId}`, {
315
- ...basePostContext,
316
- sessionId,
317
- });
318
- let transport = sessionId ? httpTransports[sessionId] : undefined;
319
- // Log transport lookup result, adding sessionId to context
320
- logger.debug(`Found existing transport for session ID: ${!!transport}`, {
321
- ...basePostContext,
322
- sessionId,
323
146
  });
324
- // Check if it's an InitializeRequest using SDK helper.
325
- const isInitReq = isInitializeRequest(req.body);
326
- logger.debug(`Is InitializeRequest: ${isInitReq}`, {
327
- ...basePostContext,
328
- sessionId,
329
- });
330
- const requestId = req.body?.id || null; // For potential error responses
331
- try {
332
- // --- Handle Initialization Request ---
333
- if (isInitReq) {
334
- if (transport) {
335
- // Client sent Initialize on an existing session - likely an error or recovery attempt.
336
- // Close the old session cleanly before creating a new one.
337
- logger.warning("Received InitializeRequest on an existing session ID. Closing old session and creating new.", { ...basePostContext, sessionId });
338
- await transport.close(); // Ensure cleanup
339
- delete httpTransports[sessionId];
340
- }
341
- logger.info("Handling Initialize Request: Creating new session...", {
342
- ...basePostContext,
147
+ const body = await c.req.json();
148
+ const sessionId = c.req.header("mcp-session-id");
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,
343
157
  sessionId,
344
158
  });
345
- // Create new SDK transport instance for this session.
346
- transport = new StreamableHTTPServerTransport({
347
- // MCP Spec: Server MAY assign session ID on InitializeResponse via Mcp-Session-Id header.
348
- sessionIdGenerator: () => {
349
- const newId = randomUUID(); // Secure UUID generation
350
- logger.debug(`Generated new session ID: ${newId}`, basePostContext); // Use base context here
351
- return newId;
352
- },
353
- onsessioninitialized: (newId) => {
354
- // Store the transport instance once the session ID is confirmed and sent to client.
355
- logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId });
356
- httpTransports[newId] = transport; // Store by the generated ID
357
- logger.info(`HTTP Session created: ${newId}`, {
358
- ...basePostContext,
359
- newSessionId: newId,
360
- });
361
- },
362
- });
363
- // Define cleanup logic when the transport closes (client disconnect, DELETE, error).
364
- transport.onclose = () => {
365
- const closedSessionId = transport.sessionId; // Get ID before potential deletion
366
- // Removed duplicate declaration below
367
- if (closedSessionId) {
368
- logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId });
369
- delete httpTransports[closedSessionId]; // Remove from active transports
370
- sessionWorkingDirectories.delete(closedSessionId); // Clean up working directory state
371
- logger.info(`HTTP Session closed and state cleaned: ${closedSessionId}`, { ...basePostContext, closedSessionId });
372
- }
373
- else {
374
- logger.debug("onclose handler triggered for transport without session ID (likely init failure).", basePostContext);
375
- }
376
- };
377
- // Create a dedicated McpServer instance for this new session.
378
- logger.debug("Creating McpServer instance for new session...", basePostContext);
379
- const server = await createServerInstanceFn();
380
- // Connect the server logic to the transport layer.
381
- logger.debug("Connecting McpServer to new transport...", basePostContext);
382
- await server.connect(transport);
383
- logger.debug("McpServer connected to transport.", basePostContext);
384
- // NOTE: SDK's connect/handleRequest handles sending the InitializeResult.
159
+ await transport.close(); // This will trigger the onclose handler.
385
160
  }
386
- else if (!transport) {
387
- // --- Handle Non-Initialize Request without Valid Session ---
388
- // MCP Spec: Server SHOULD respond 400/404 if session ID is missing/invalid for non-init requests.
389
- logger.warning("Invalid or missing session ID for non-initialize POST request.", { ...basePostContext, sessionId });
390
- res.status(404).json({
391
- jsonrpc: "2.0",
392
- error: { code: -32004, message: "Invalid or expired session ID" },
393
- id: requestId,
394
- });
395
- return; // Stop processing
396
- }
397
- // --- Handle Request Content (Initialize or Subsequent Message) ---
398
- // Use the extracted sessionId in the context for these logs
399
- const currentSessionId = transport.sessionId; // Should be defined here
400
- logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
401
- // Delegate the actual handling (parsing, routing, response/SSE generation) to the SDK transport instance.
402
- // The SDK transport handles returning 202 for notification/response-only POSTs internally.
403
- // --- Type modification for req.auth compatibility ---
404
- const tempReqPost = req; // Allow modification
405
- if (tempReqPost.auth &&
406
- (typeof tempReqPost.auth === "string" ||
407
- (typeof tempReqPost.auth === "object" &&
408
- "devMode" in tempReqPost.auth))) {
409
- logger.debug("Sanitizing req.auth for SDK compatibility (POST)", {
410
- ...basePostContext,
411
- sessionId: currentSessionId,
412
- originalAuthType: typeof tempReqPost.auth,
413
- });
414
- tempReqPost.auth = undefined;
415
- }
416
- // --- End modification ---
417
- await transport.handleRequest(req, res, req.body);
418
- logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
419
- }
420
- catch (err) {
421
- // Catch-all for errors during POST handling.
422
- // Include sessionId if available in the transport object at this point
423
- const errorSessionId = transport?.sessionId || sessionId; // Use extracted or from transport if available
424
- logger.error("Error handling POST request", {
425
- ...basePostContext,
426
- sessionId: errorSessionId, // Add sessionId to error context
427
- isInitReq, // Include isInitReq flag
428
- error: err instanceof Error ? err.message : String(err),
429
- stack: err instanceof Error ? err.stack : undefined,
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
+ });
170
+ },
430
171
  });
431
- if (!res.headersSent) {
432
- // Send generic JSON-RPC error if possible.
433
- res.status(500).json({
434
- jsonrpc: "2.0",
435
- error: {
436
- code: -32603,
437
- message: "Internal server error during POST handling",
438
- },
439
- id: requestId,
440
- });
441
- }
442
- // Ensure transport is cleaned up if an error occurred during initialization before session ID assigned.
443
- if (isInitReq && transport && !transport.sessionId) {
444
- logger.debug("Cleaning up transport after initialization failure.", {
445
- ...basePostContext,
446
- sessionId: errorSessionId,
447
- });
448
- await transport.close().catch((closeErr) => logger.error("Error closing transport after init failure", {
449
- ...basePostContext,
450
- sessionId: errorSessionId,
451
- closeError: closeErr,
452
- }));
453
- }
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.");
454
191
  }
192
+ // Pass the request to the transport to handle.
193
+ return await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
455
194
  });
456
- // Unified handler for GET (SSE connection) and DELETE (session termination).
457
- const handleSessionReq = async (req, res) => {
458
- const method = req.method; // GET or DELETE
459
- // Define base context for this request
460
- const baseSessionReqContext = {
461
- ...transportContext,
462
- operation: `handle${method}`,
463
- method,
464
- };
465
- logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, {
466
- ...baseSessionReqContext,
467
- headers: req.headers,
468
- });
469
- // MCP Spec: Client MUST include Mcp-Session-Id header (after init).
470
- const sessionId = req.headers["mcp-session-id"];
471
- // Log extracted session ID, adding it to the context for this specific log message
472
- logger.debug(`Extracted session ID: ${sessionId}`, {
473
- ...baseSessionReqContext,
474
- sessionId,
475
- });
476
- const transport = sessionId ? httpTransports[sessionId] : undefined;
477
- // Log transport lookup result, adding sessionId to context
478
- logger.debug(`Found existing transport for session ID: ${!!transport}`, {
479
- ...baseSessionReqContext,
480
- sessionId,
481
- });
195
+ // A reusable handler for GET and DELETE requests which operate on existing sessions.
196
+ const handleSessionRequest = async (c) => {
197
+ const sessionId = c.req.header("mcp-session-id");
198
+ const transport = sessionId ? transports[sessionId] : undefined;
482
199
  if (!transport) {
483
- // MCP Spec: Server MUST respond 404 if session ID invalid/expired.
484
- logger.warning(`Session not found for ${method} request`, {
485
- ...baseSessionReqContext,
486
- sessionId,
487
- });
488
- res.status(404).send("Session not found or expired");
489
- return;
490
- }
491
- try {
492
- // Use the extracted sessionId in the context for these logs
493
- logger.debug(`Delegating ${method} request to transport for session ${sessionId}...`, { ...baseSessionReqContext, sessionId });
494
- // MCP Spec (GET): Client MAY issue GET to open SSE stream. Server MUST respond text/event-stream or 405.
495
- // MCP Spec (GET): Client SHOULD include Last-Event-ID for resumption. Resumption handling depends on SDK transport.
496
- // MCP Spec (DELETE): Client SHOULD send DELETE to terminate. Server MAY respond 405 if not supported.
497
- // This implementation supports DELETE via the SDK transport's handleRequest.
498
- // --- Type modification for req.auth compatibility ---
499
- const tempReqSession = req; // Allow modification
500
- if (tempReqSession.auth &&
501
- (typeof tempReqSession.auth === "string" ||
502
- (typeof tempReqSession.auth === "object" &&
503
- "devMode" in tempReqSession.auth))) {
504
- logger.debug(`Sanitizing req.auth for SDK compatibility (${method})`, {
505
- ...baseSessionReqContext,
506
- sessionId,
507
- originalAuthType: typeof tempReqSession.auth,
508
- });
509
- tempReqSession.auth = undefined;
510
- }
511
- // --- End modification ---
512
- await transport.handleRequest(req, res);
513
- logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId });
514
- // Note: For DELETE, the transport's handleRequest should trigger the 'onclose' handler for cleanup.
515
- }
516
- catch (err) {
517
- // Include sessionId in error context
518
- logger.error(`Error handling ${method} request for session ${sessionId}`, {
519
- ...baseSessionReqContext,
520
- sessionId, // Add sessionId here
521
- error: err instanceof Error ? err.message : String(err),
522
- stack: err instanceof Error ? err.stack : undefined,
523
- });
524
- if (!res.headersSent) {
525
- // Generic error if response hasn't started (e.g., error before SSE connection).
526
- res.status(500).send("Internal Server Error");
527
- }
528
- // The SDK transport's handleRequest should manage errors occurring *during* an SSE stream.
200
+ throw new McpError(BaseErrorCode.NOT_FOUND, "Session not found or expired.");
529
201
  }
202
+ // Let the transport handle the streaming (GET) or termination (DELETE) request.
203
+ return await transport.handleRequest(c.env.incoming, c.env.outgoing);
530
204
  };
531
- // Route GET and DELETE requests to the unified handler.
532
- app.get(MCP_ENDPOINT_PATH, handleSessionReq);
533
- app.delete(MCP_ENDPOINT_PATH, handleSessionReq);
534
- // --- Start HTTP Server ---
535
- logger.debug("Creating HTTP server instance...", transportContext);
536
- const serverInstance = http.createServer(app);
537
- try {
538
- logger.debug("Attempting to start HTTP server with retry logic...", transportContext);
539
- // Use configured host and port, with retry logic.
540
- const actualPort = await startHttpServerWithRetry(serverInstance, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext);
541
- // Determine protocol for logging (basic assumption based on HSTS possibility)
542
- const protocol = config.environment === "production" ? "https" : "http";
543
- const serverAddress = `${protocol}://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
544
- // Use logger.notice for startup message to ensure MCP compliance and proper handling by clients.
545
- logger.notice(`\nšŸš€ MCP Server running in HTTP mode at: ${serverAddress}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`, transportContext);
546
- }
547
- catch (err) {
548
- logger.fatal("HTTP server failed to start after multiple port retries.", {
549
- ...transportContext,
550
- error: err instanceof Error ? err.message : String(err),
551
- });
552
- throw err; // Propagate error to stop the application
553
- }
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);
554
208
  }