@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.
- package/README.md +92 -72
- package/dist/config/index.js +10 -2
- package/dist/mcp-server/server.js +33 -32
- package/dist/mcp-server/transports/auth/core/authContext.js +24 -0
- package/dist/mcp-server/transports/auth/core/authTypes.js +5 -0
- package/dist/mcp-server/transports/auth/core/authUtils.js +45 -0
- package/dist/mcp-server/transports/auth/index.js +9 -0
- package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.js +149 -0
- package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.js +127 -0
- package/dist/mcp-server/transports/httpErrorHandler.js +73 -0
- package/dist/mcp-server/transports/httpTransport.js +149 -495
- package/dist/mcp-server/transports/stdioTransport.js +18 -48
- package/package.json +10 -8
- package/dist/mcp-server/transports/authentication/authMiddleware.js +0 -167
|
@@ -1,554 +1,208 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and
|
|
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";
|
|
13
|
-
import
|
|
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
|
-
|
|
17
|
-
import {
|
|
18
|
-
import { logger } from "../../utils/index.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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(`
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
202
|
-
return;
|
|
87
|
+
resolve(serverInstance);
|
|
88
|
+
return;
|
|
203
89
|
}
|
|
204
90
|
catch (err) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
//
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
app.
|
|
533
|
-
app
|
|
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
|
}
|