@cyanheads/git-mcp-server 2.0.2 → 2.0.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 +45 -85
- package/dist/config/index.js +16 -18
- package/dist/index.js +80 -30
- package/dist/mcp-server/server.js +247 -523
- package/dist/mcp-server/tools/gitAdd/logic.js +9 -6
- package/dist/mcp-server/tools/gitAdd/registration.js +7 -4
- package/dist/mcp-server/tools/gitBranch/logic.js +23 -12
- package/dist/mcp-server/tools/gitBranch/registration.js +8 -5
- package/dist/mcp-server/tools/gitCheckout/logic.js +92 -44
- package/dist/mcp-server/tools/gitCheckout/registration.js +8 -5
- package/dist/mcp-server/tools/gitCherryPick/logic.js +10 -7
- package/dist/mcp-server/tools/gitCherryPick/registration.js +8 -5
- package/dist/mcp-server/tools/gitClean/logic.js +9 -6
- package/dist/mcp-server/tools/gitClean/registration.js +8 -5
- package/dist/mcp-server/tools/gitClearWorkingDir/logic.js +3 -2
- package/dist/mcp-server/tools/gitClearWorkingDir/registration.js +7 -4
- package/dist/mcp-server/tools/gitClone/logic.js +8 -5
- package/dist/mcp-server/tools/gitClone/registration.js +7 -4
- package/dist/mcp-server/tools/gitCommit/logic.js +98 -20
- package/dist/mcp-server/tools/gitCommit/registration.js +22 -15
- package/dist/mcp-server/tools/gitDiff/logic.js +9 -6
- package/dist/mcp-server/tools/gitDiff/registration.js +8 -5
- package/dist/mcp-server/tools/gitFetch/logic.js +10 -7
- package/dist/mcp-server/tools/gitFetch/registration.js +8 -5
- package/dist/mcp-server/tools/gitInit/index.js +2 -2
- package/dist/mcp-server/tools/gitInit/logic.js +9 -6
- package/dist/mcp-server/tools/gitInit/registration.js +66 -12
- package/dist/mcp-server/tools/gitLog/logic.js +53 -16
- package/dist/mcp-server/tools/gitLog/registration.js +8 -5
- package/dist/mcp-server/tools/gitMerge/logic.js +9 -6
- package/dist/mcp-server/tools/gitMerge/registration.js +8 -5
- package/dist/mcp-server/tools/gitPull/logic.js +11 -8
- package/dist/mcp-server/tools/gitPull/registration.js +7 -4
- package/dist/mcp-server/tools/gitPush/logic.js +12 -9
- package/dist/mcp-server/tools/gitPush/registration.js +7 -4
- package/dist/mcp-server/tools/gitRebase/logic.js +9 -6
- package/dist/mcp-server/tools/gitRebase/registration.js +8 -5
- package/dist/mcp-server/tools/gitRemote/logic.js +4 -5
- package/dist/mcp-server/tools/gitRemote/registration.js +2 -4
- package/dist/mcp-server/tools/gitReset/logic.js +5 -6
- package/dist/mcp-server/tools/gitReset/registration.js +2 -4
- package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +5 -6
- package/dist/mcp-server/tools/gitSetWorkingDir/registration.js +22 -13
- package/dist/mcp-server/tools/gitShow/logic.js +5 -6
- package/dist/mcp-server/tools/gitShow/registration.js +3 -5
- package/dist/mcp-server/tools/gitStash/logic.js +5 -6
- package/dist/mcp-server/tools/gitStash/registration.js +3 -5
- package/dist/mcp-server/tools/gitStatus/logic.js +5 -6
- package/dist/mcp-server/tools/gitStatus/registration.js +2 -4
- package/dist/mcp-server/tools/gitTag/logic.js +3 -4
- package/dist/mcp-server/tools/gitTag/registration.js +2 -4
- package/dist/mcp-server/transports/authentication/authMiddleware.js +145 -0
- package/dist/mcp-server/transports/httpTransport.js +432 -0
- package/dist/mcp-server/transports/stdioTransport.js +87 -0
- package/dist/types-global/errors.js +2 -2
- package/dist/utils/index.js +12 -11
- package/dist/utils/{errorHandler.js → internal/errorHandler.js} +18 -8
- package/dist/utils/internal/index.js +3 -0
- package/dist/utils/internal/logger.js +254 -0
- package/dist/utils/{requestContext.js → internal/requestContext.js} +2 -3
- package/dist/utils/metrics/index.js +1 -0
- package/dist/utils/{tokenCounter.js → metrics/tokenCounter.js} +3 -3
- package/dist/utils/parsing/dateParser.js +62 -0
- package/dist/utils/parsing/index.js +2 -0
- package/dist/utils/{jsonParser.js → parsing/jsonParser.js} +3 -2
- package/dist/utils/{idGenerator.js → security/idGenerator.js} +4 -5
- package/dist/utils/security/index.js +3 -0
- package/dist/utils/{rateLimiter.js → security/rateLimiter.js} +7 -10
- package/dist/utils/{sanitization.js → security/sanitization.js} +4 -3
- package/package.json +12 -9
- package/dist/types-global/mcp.js +0 -59
- package/dist/types-global/tool.js +0 -1
- package/dist/utils/logger.js +0 -266
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Authentication Middleware: Bearer Token Validation (JWT).
|
|
3
|
+
*
|
|
4
|
+
* This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
|
|
5
|
+
* using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
|
|
6
|
+
* It verifies the token's signature and expiration using the secret key defined
|
|
7
|
+
* in the configuration (MCP_AUTH_SECRET_KEY).
|
|
8
|
+
*
|
|
9
|
+
* If the token is valid, the decoded payload is attached to `req.auth` for potential
|
|
10
|
+
* use in downstream authorization logic (e.g., checking scopes or permissions).
|
|
11
|
+
* If the token is missing, invalid, or expired, it sends an HTTP 401 Unauthorized response.
|
|
12
|
+
*
|
|
13
|
+
* --- Scope and Relation to MCP Authorization Spec (2025-03-26) ---
|
|
14
|
+
* - This middleware handles the *validation* of an already obtained Bearer token,
|
|
15
|
+
* as required by Section 2.6 of the MCP Auth Spec.
|
|
16
|
+
* - It does *NOT* implement the full OAuth 2.1 authorization flows (e.g., Authorization
|
|
17
|
+
* Code Grant with PKCE), token endpoints (/token), authorization endpoints (/authorize),
|
|
18
|
+
* metadata discovery (/.well-known/oauth-authorization-server), or dynamic client
|
|
19
|
+
* registration (/register) described in the specification. It assumes the client
|
|
20
|
+
* obtained the JWT through an external process compliant with the spec or another
|
|
21
|
+
* agreed-upon mechanism.
|
|
22
|
+
* - It correctly returns HTTP 401 errors for invalid/missing tokens as per Section 2.8.
|
|
23
|
+
*
|
|
24
|
+
* --- Implementation Details & Requirements ---
|
|
25
|
+
* - Requires the 'jsonwebtoken' package (`npm install jsonwebtoken @types/jsonwebtoken`).
|
|
26
|
+
* - The `MCP_AUTH_SECRET_KEY` environment variable MUST be set to a strong, secret value
|
|
27
|
+
* in production. The middleware includes a startup check for this.
|
|
28
|
+
* - In non-production environments, if the secret key is missing, authentication checks
|
|
29
|
+
* are bypassed for development convenience (a warning is logged). THIS IS INSECURE FOR PRODUCTION.
|
|
30
|
+
* - The structure of the JWT payload (e.g., containing user ID, scopes) depends on the
|
|
31
|
+
* token issuer and is not dictated by this middleware itself, but the payload is made
|
|
32
|
+
* available on `req.auth`.
|
|
33
|
+
*
|
|
34
|
+
* @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
|
|
35
|
+
*/
|
|
36
|
+
import jwt from 'jsonwebtoken';
|
|
37
|
+
// Import config, environment constants, and logger
|
|
38
|
+
import { config, environment } from '../../../config/index.js';
|
|
39
|
+
import { logger } from '../../../utils/index.js';
|
|
40
|
+
// --- Startup Validation ---
|
|
41
|
+
// Validate secret key presence on module load (fail fast principle).
|
|
42
|
+
// This prevents the server starting insecurely in production without the key.
|
|
43
|
+
if (environment === 'production' && !config.security.mcpAuthSecretKey) {
|
|
44
|
+
logger.fatal('CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.');
|
|
45
|
+
// Throwing an error here will typically stop the Node.js process.
|
|
46
|
+
throw new Error('MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.');
|
|
47
|
+
}
|
|
48
|
+
else if (!config.security.mcpAuthSecretKey) {
|
|
49
|
+
// Log a clear warning if running without a key in non-production environments.
|
|
50
|
+
logger.warning('MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Express middleware function for verifying JWT Bearer token authentication.
|
|
54
|
+
* Checks the `Authorization` header, verifies the token, and attaches the payload to `req.auth`.
|
|
55
|
+
*
|
|
56
|
+
* @param {Request} req - Express request object.
|
|
57
|
+
* @param {Response} res - Express response object.
|
|
58
|
+
* @param {NextFunction} next - Express next middleware function.
|
|
59
|
+
*/
|
|
60
|
+
export function mcpAuthMiddleware(req, res, next) {
|
|
61
|
+
// Establish context for logging associated with this middleware execution.
|
|
62
|
+
const context = { operation: 'mcpAuthMiddleware', method: req.method, path: req.path };
|
|
63
|
+
logger.debug('Running MCP Authentication Middleware (Bearer Token Validation)...', context);
|
|
64
|
+
// --- Development Mode Bypass ---
|
|
65
|
+
// If the secret key is missing (and not in production), bypass authentication.
|
|
66
|
+
if (!config.security.mcpAuthSecretKey) {
|
|
67
|
+
// Double-check environment for safety, although the startup check should prevent this in prod.
|
|
68
|
+
if (environment !== 'production') {
|
|
69
|
+
logger.warning('Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).', context);
|
|
70
|
+
// Attach a dummy auth object to indicate bypass for potential downstream checks.
|
|
71
|
+
req.auth = { devMode: true, warning: 'Auth bypassed due to missing secret key' };
|
|
72
|
+
return next(); // Proceed without authentication.
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Defensive coding: Should be caught by startup check, but handle anyway.
|
|
76
|
+
logger.error('FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.', context);
|
|
77
|
+
// Send a server error response as this indicates a critical configuration issue.
|
|
78
|
+
res.status(500).json({ error: 'Server configuration error: Authentication key missing.' });
|
|
79
|
+
return; // Halt processing.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// --- Standard JWT Bearer Token Verification ---
|
|
83
|
+
const authHeader = req.headers.authorization;
|
|
84
|
+
logger.debug(`Authorization header present: ${!!authHeader}`, context);
|
|
85
|
+
// Check for the presence and correct format ('Bearer <token>') of the Authorization header.
|
|
86
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
87
|
+
logger.warning('Authentication failed: Missing or malformed Authorization header (Bearer scheme required).', context);
|
|
88
|
+
// Respond with 401 Unauthorized as per RFC 6750 (Bearer Token Usage).
|
|
89
|
+
res.status(401).json({ error: 'Unauthorized: Missing or invalid authentication token format.' });
|
|
90
|
+
return; // Halt processing.
|
|
91
|
+
}
|
|
92
|
+
// Extract the token part from the "Bearer <token>" string.
|
|
93
|
+
const token = authHeader.split(' ')[1];
|
|
94
|
+
// Avoid logging the token itself for security reasons.
|
|
95
|
+
logger.debug('Extracted token from Bearer header.', context);
|
|
96
|
+
// Check if a token was actually present after the split.
|
|
97
|
+
if (!token) {
|
|
98
|
+
logger.warning('Authentication failed: Token missing after Bearer split (Malformed header).', context);
|
|
99
|
+
res.status(401).json({ error: 'Unauthorized: Malformed authentication token.' });
|
|
100
|
+
return; // Halt processing.
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// Verify the token's signature and expiration using the configured secret key.
|
|
104
|
+
// `jwt.verify` throws errors for invalid signature, expiration, etc.
|
|
105
|
+
const decoded = jwt.verify(token, config.security.mcpAuthSecretKey);
|
|
106
|
+
// Avoid logging the decoded payload directly unless necessary for specific debugging,
|
|
107
|
+
// as it might contain sensitive information.
|
|
108
|
+
logger.debug('JWT verified successfully.', { ...context });
|
|
109
|
+
// Attach the decoded payload (which can be an object or string based on JWT content)
|
|
110
|
+
// to the request object (`req.auth`) for use in subsequent middleware or route handlers
|
|
111
|
+
// (e.g., for fine-grained authorization checks based on payload claims like user ID or scopes).
|
|
112
|
+
req.auth = decoded;
|
|
113
|
+
// Authentication successful, proceed to the next middleware or the main route handler.
|
|
114
|
+
next();
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// Handle errors thrown by `jwt.verify`.
|
|
118
|
+
let errorMessage = 'Invalid token'; // Default error message.
|
|
119
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
120
|
+
// Specific error for expired tokens.
|
|
121
|
+
errorMessage = 'Token expired';
|
|
122
|
+
// After instanceof check, 'error' is typed as TokenExpiredError
|
|
123
|
+
logger.warning('Authentication failed: Token expired.', { ...context, expiredAt: error.expiredAt }); // Log specific details here
|
|
124
|
+
}
|
|
125
|
+
else if (error instanceof jwt.JsonWebTokenError) {
|
|
126
|
+
// General JWT errors (e.g., invalid signature, malformed token).
|
|
127
|
+
// After instanceof check, 'error' is typed as JsonWebTokenError
|
|
128
|
+
errorMessage = `Invalid token: ${error.message}`; // Include specific JWT error message
|
|
129
|
+
logger.warning(`Authentication failed: ${errorMessage}`, { ...context }); // Log specific details here
|
|
130
|
+
}
|
|
131
|
+
else if (error instanceof Error) {
|
|
132
|
+
// Handle other standard JavaScript errors
|
|
133
|
+
errorMessage = `Verification error: ${error.message}`;
|
|
134
|
+
logger.error('Authentication failed: Unexpected error during token verification.', { ...context, error: error.message }); // Log specific details here
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Handle non-Error exceptions
|
|
138
|
+
errorMessage = 'Unknown verification error';
|
|
139
|
+
logger.error('Authentication failed: Unexpected non-error exception during token verification.', { ...context, error });
|
|
140
|
+
}
|
|
141
|
+
// Respond with 401 Unauthorized for any token validation failure.
|
|
142
|
+
res.status(401).json({ error: `Unauthorized: ${errorMessage}.` });
|
|
143
|
+
// Do not call next() - halt processing for this request.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Specification Reference:
|
|
9
|
+
* https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
|
|
10
|
+
*/
|
|
11
|
+
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';
|
|
14
|
+
import http from 'http';
|
|
15
|
+
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
|
+
*/
|
|
26
|
+
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
|
+
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
|
+
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
|
+
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}`, { operation: 'setHttpSessionWorkingDirectory', sessionId });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Checks if an incoming HTTP request's origin header is permissible.
|
|
73
|
+
* MCP Spec Security: Servers MUST validate the `Origin` header.
|
|
74
|
+
* This function checks against `MCP_ALLOWED_ORIGINS` and allows requests
|
|
75
|
+
* from localhost if the server is bound locally. Sets CORS headers if allowed.
|
|
76
|
+
*
|
|
77
|
+
* @param {Request} req - Express request object.
|
|
78
|
+
* @param {Response} res - Express response object.
|
|
79
|
+
* @returns {boolean} True if the origin is allowed, false otherwise.
|
|
80
|
+
*/
|
|
81
|
+
function isOriginAllowed(req, res) {
|
|
82
|
+
const origin = req.headers.origin;
|
|
83
|
+
const host = req.hostname; // Considers Host header
|
|
84
|
+
const isLocalhostBinding = ['127.0.0.1', '::1', 'localhost'].includes(host);
|
|
85
|
+
const allowedOrigins = config.mcpAllowedOrigins || []; // Use parsed array from config
|
|
86
|
+
const context = { operation: 'isOriginAllowed', origin, host, isLocalhostBinding, allowedOrigins };
|
|
87
|
+
logger.debug('Checking origin allowance', context);
|
|
88
|
+
// Determine if allowed based on config or localhost binding
|
|
89
|
+
const allowed = (origin && allowedOrigins.includes(origin)) || (isLocalhostBinding && (!origin || origin === 'null'));
|
|
90
|
+
if (allowed && origin) {
|
|
91
|
+
// Origin is allowed and present, set specific CORS headers.
|
|
92
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
93
|
+
// MCP Spec: Streamable HTTP uses POST, GET, DELETE. OPTIONS is for preflight.
|
|
94
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
95
|
+
// MCP Spec: Requires Mcp-Session-Id. Last-Event-ID for SSE resumption. Content-Type is standard. Authorization for security.
|
|
96
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization');
|
|
97
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true'); // Set based on whether auth/cookies are used
|
|
98
|
+
}
|
|
99
|
+
else if (allowed && !origin) {
|
|
100
|
+
// Allowed (e.g., localhost binding, file:// origin), but no origin header to echo back. No specific CORS needed.
|
|
101
|
+
}
|
|
102
|
+
else if (!allowed && origin) {
|
|
103
|
+
// Origin provided but not in allowed list. Log warning.
|
|
104
|
+
logger.warning(`Origin denied: ${origin}`, context);
|
|
105
|
+
}
|
|
106
|
+
logger.debug(`Origin check result: ${allowed}`, { ...context, allowed });
|
|
107
|
+
return allowed;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Proactively checks if a specific port is already in use. (Asynchronous)
|
|
111
|
+
* @param {number} port - Port to check.
|
|
112
|
+
* @param {string} host - Host address to check.
|
|
113
|
+
* @param {Record<string, any>} context - Logging context.
|
|
114
|
+
* @returns {Promise<boolean>} True if port is in use (EADDRINUSE), false otherwise.
|
|
115
|
+
*/
|
|
116
|
+
async function isPortInUse(port, host, context) {
|
|
117
|
+
const checkContext = { ...context, operation: 'isPortInUse', port, host };
|
|
118
|
+
logger.debug(`Proactively checking port usability...`, checkContext);
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const tempServer = http.createServer();
|
|
121
|
+
tempServer
|
|
122
|
+
.once('error', (err) => {
|
|
123
|
+
if (err.code === 'EADDRINUSE') {
|
|
124
|
+
logger.debug(`Proactive check: Port confirmed in use (EADDRINUSE).`, checkContext);
|
|
125
|
+
resolve(true); // Port is definitely in use
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code });
|
|
129
|
+
resolve(false); // Other error, let main listen attempt handle it
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
.once('listening', () => {
|
|
133
|
+
logger.debug(`Proactive check: Port is available.`, checkContext);
|
|
134
|
+
tempServer.close(() => resolve(false)); // Port is free
|
|
135
|
+
})
|
|
136
|
+
.listen(port, host);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Attempts to start the HTTP server, retrying on incrementing ports if EADDRINUSE occurs. (Asynchronous)
|
|
141
|
+
* Uses proactive checks before attempting to bind the main server instance.
|
|
142
|
+
*
|
|
143
|
+
* @param {http.Server} serverInstance - The Node.js HTTP server instance.
|
|
144
|
+
* @param {number} initialPort - The starting port number.
|
|
145
|
+
* @param {string} host - The host address to bind to.
|
|
146
|
+
* @param {number} maxRetries - Maximum number of additional ports to try.
|
|
147
|
+
* @param {Record<string, any>} context - Logging context.
|
|
148
|
+
* @returns {Promise<number>} Resolves with the port number successfully bound to.
|
|
149
|
+
* @throws {Error} Rejects if binding fails after all retries or for non-EADDRINUSE errors.
|
|
150
|
+
*/
|
|
151
|
+
function startHttpServerWithRetry(serverInstance, initialPort, host, maxRetries, context) {
|
|
152
|
+
const startContext = { ...context, operation: 'startHttpServerWithRetry', initialPort, host, maxRetries };
|
|
153
|
+
logger.debug(`Attempting to start HTTP server...`, startContext);
|
|
154
|
+
return new Promise(async (resolve, reject) => {
|
|
155
|
+
let lastError = null;
|
|
156
|
+
for (let i = 0; i <= maxRetries; i++) {
|
|
157
|
+
const currentPort = initialPort + i;
|
|
158
|
+
const attemptContext = { ...startContext, port: currentPort, attempt: i + 1, maxAttempts: maxRetries + 1 };
|
|
159
|
+
logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext);
|
|
160
|
+
// 1. Proactive Check
|
|
161
|
+
if (await isPortInUse(currentPort, host, attemptContext)) {
|
|
162
|
+
logger.warning(`Proactive check detected port ${currentPort} is in use, retrying...`, attemptContext);
|
|
163
|
+
lastError = new Error(`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`);
|
|
164
|
+
await new Promise(res => setTimeout(res, 100)); // Short delay
|
|
165
|
+
continue; // Try next port
|
|
166
|
+
}
|
|
167
|
+
// 2. Attempt Main Server Bind
|
|
168
|
+
try {
|
|
169
|
+
await new Promise((listenResolve, listenReject) => {
|
|
170
|
+
serverInstance.listen(currentPort, host, () => {
|
|
171
|
+
const serverAddress = `http://${host}:${currentPort}${MCP_ENDPOINT_PATH}`;
|
|
172
|
+
logger.info(`HTTP transport successfully listening on host ${host} at ${serverAddress}`, { ...attemptContext, address: serverAddress });
|
|
173
|
+
listenResolve(); // Success
|
|
174
|
+
}).on('error', (err) => {
|
|
175
|
+
listenReject(err); // Forward error
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
resolve(currentPort); // Listen succeeded
|
|
179
|
+
return; // Exit function
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
lastError = err;
|
|
183
|
+
logger.debug(`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`, { ...attemptContext, errorCode: err.code, errorMessage: err.message });
|
|
184
|
+
if (err.code === 'EADDRINUSE') {
|
|
185
|
+
logger.warning(`Port ${currentPort} already in use (EADDRINUSE), retrying...`, attemptContext);
|
|
186
|
+
await new Promise(res => setTimeout(res, 100)); // Short delay before retry
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message });
|
|
190
|
+
reject(err); // Non-recoverable error for this port
|
|
191
|
+
return; // Exit function
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Loop finished without success
|
|
196
|
+
logger.error(`Failed to bind to any port after ${maxRetries + 1} attempts. Last error: ${lastError?.message}`, { ...startContext, error: lastError?.message });
|
|
197
|
+
reject(lastError || new Error('Failed to bind to any port after multiple retries.'));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Sets up and starts the Streamable HTTP transport layer for MCP. (Asynchronous)
|
|
202
|
+
* Creates Express app, configures middleware (CORS, Auth, Security Headers),
|
|
203
|
+
* defines the single MCP endpoint handler for POST/GET/DELETE, manages sessions,
|
|
204
|
+
* and starts the HTTP server with retry logic.
|
|
205
|
+
*
|
|
206
|
+
* @param {() => Promise<McpServer>} createServerInstanceFn - Async factory function to create a new McpServer instance per session.
|
|
207
|
+
* @param {Record<string, any>} context - Logging context.
|
|
208
|
+
* @returns {Promise<void>} Resolves when the server is listening, or rejects on failure.
|
|
209
|
+
* @throws {Error} If the server fails to start after retries.
|
|
210
|
+
*/
|
|
211
|
+
export async function startHttpTransport(createServerInstanceFn, context) {
|
|
212
|
+
const app = express();
|
|
213
|
+
const transportContext = { ...context, transportType: 'HTTP' };
|
|
214
|
+
logger.debug('Setting up Express app for HTTP transport...', transportContext);
|
|
215
|
+
// Middleware to parse JSON request bodies. Required for MCP messages.
|
|
216
|
+
app.use(express.json());
|
|
217
|
+
// --- Security Middleware Pipeline ---
|
|
218
|
+
// 1. CORS Preflight (OPTIONS) Handler
|
|
219
|
+
// Handles OPTIONS requests sent by browsers before actual GET/POST/DELETE.
|
|
220
|
+
app.options(MCP_ENDPOINT_PATH, (req, res) => {
|
|
221
|
+
const optionsContext = { ...transportContext, operation: 'handleOptions', origin: req.headers.origin };
|
|
222
|
+
logger.debug(`Received OPTIONS request for ${MCP_ENDPOINT_PATH}`, optionsContext);
|
|
223
|
+
if (isOriginAllowed(req, res)) {
|
|
224
|
+
// isOriginAllowed sets necessary Access-Control-* headers.
|
|
225
|
+
logger.debug('OPTIONS request origin allowed, sending 204.', optionsContext);
|
|
226
|
+
res.sendStatus(204); // OK, No Content
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// isOriginAllowed logs the warning.
|
|
230
|
+
logger.debug('OPTIONS request origin denied, sending 403.', optionsContext);
|
|
231
|
+
res.status(403).send('Forbidden: Invalid Origin');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
// 2. General Security Headers & Origin Check Middleware (for non-OPTIONS)
|
|
235
|
+
app.use((req, res, next) => {
|
|
236
|
+
const securityContext = { ...transportContext, operation: 'securityMiddleware', path: req.path, method: req.method, origin: req.headers.origin };
|
|
237
|
+
logger.debug(`Applying security middleware...`, securityContext);
|
|
238
|
+
// Check origin again for non-OPTIONS requests and set CORS headers if allowed.
|
|
239
|
+
if (!isOriginAllowed(req, res)) {
|
|
240
|
+
// isOriginAllowed logs the warning.
|
|
241
|
+
logger.debug('Origin check failed, sending 403.', securityContext);
|
|
242
|
+
res.status(403).send('Forbidden: Invalid Origin');
|
|
243
|
+
return; // Block request
|
|
244
|
+
}
|
|
245
|
+
// Apply standard security headers to all allowed responses.
|
|
246
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
247
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
248
|
+
// Basic Content Security Policy (CSP). Adjust if server needs external connections.
|
|
249
|
+
// 'connect-src 'self'' allows connections back to the server's own origin (needed for SSE).
|
|
250
|
+
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'");
|
|
251
|
+
// Strict-Transport-Security (HSTS) - IMPORTANT: Enable only if server is *always* served over HTTPS.
|
|
252
|
+
// if (config.environment === 'production') {
|
|
253
|
+
// res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); // 1 year
|
|
254
|
+
// }
|
|
255
|
+
logger.debug('Security middleware passed.', securityContext);
|
|
256
|
+
next(); // Proceed to next middleware/handler
|
|
257
|
+
});
|
|
258
|
+
// 3. MCP Authentication Middleware (Optional, based on config)
|
|
259
|
+
// Verifies Authorization header (e.g., Bearer token) if enabled.
|
|
260
|
+
app.use(mcpAuthMiddleware);
|
|
261
|
+
// --- MCP Route Handlers ---
|
|
262
|
+
// Handle POST requests: Used for Initialize and all subsequent client->server messages.
|
|
263
|
+
// MCP Spec: Client MUST use POST. Body is single message or batch.
|
|
264
|
+
// MCP Spec: Server responds 202 for notification/response-only, or JSON/SSE for requests.
|
|
265
|
+
app.post(MCP_ENDPOINT_PATH, async (req, res) => {
|
|
266
|
+
// Define base context for this request
|
|
267
|
+
const basePostContext = { ...transportContext, operation: 'handlePost', method: 'POST' };
|
|
268
|
+
logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, { ...basePostContext, headers: req.headers, bodyPreview: JSON.stringify(req.body).substring(0, 100) });
|
|
269
|
+
// MCP Spec: Session ID MUST be included by client after initialization.
|
|
270
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
271
|
+
// Log extracted session ID, adding it to the context for this specific log message
|
|
272
|
+
logger.debug(`Extracted session ID: ${sessionId}`, { ...basePostContext, sessionId });
|
|
273
|
+
let transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
274
|
+
// Log transport lookup result, adding sessionId to context
|
|
275
|
+
logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...basePostContext, sessionId });
|
|
276
|
+
// Check if it's an InitializeRequest using SDK helper.
|
|
277
|
+
const isInitReq = isInitializeRequest(req.body);
|
|
278
|
+
logger.debug(`Is InitializeRequest: ${isInitReq}`, { ...basePostContext, sessionId });
|
|
279
|
+
const requestId = req.body?.id || null; // For potential error responses
|
|
280
|
+
try {
|
|
281
|
+
// --- Handle Initialization Request ---
|
|
282
|
+
if (isInitReq) {
|
|
283
|
+
if (transport) {
|
|
284
|
+
// Client sent Initialize on an existing session - likely an error or recovery attempt.
|
|
285
|
+
// Close the old session cleanly before creating a new one.
|
|
286
|
+
logger.warning('Received InitializeRequest on an existing session ID. Closing old session and creating new.', { ...basePostContext, sessionId });
|
|
287
|
+
await transport.close(); // Ensure cleanup
|
|
288
|
+
delete httpTransports[sessionId];
|
|
289
|
+
}
|
|
290
|
+
logger.info('Handling Initialize Request: Creating new session...', { ...basePostContext, sessionId });
|
|
291
|
+
// Create new SDK transport instance for this session.
|
|
292
|
+
transport = new StreamableHTTPServerTransport({
|
|
293
|
+
// MCP Spec: Server MAY assign session ID on InitializeResponse via Mcp-Session-Id header.
|
|
294
|
+
sessionIdGenerator: () => {
|
|
295
|
+
const newId = randomUUID(); // Secure UUID generation
|
|
296
|
+
logger.debug(`Generated new session ID: ${newId}`, basePostContext); // Use base context here
|
|
297
|
+
return newId;
|
|
298
|
+
},
|
|
299
|
+
onsessioninitialized: (newId) => {
|
|
300
|
+
// Store the transport instance once the session ID is confirmed and sent to client.
|
|
301
|
+
logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId });
|
|
302
|
+
httpTransports[newId] = transport; // Store by the generated ID
|
|
303
|
+
logger.info(`HTTP Session created: ${newId}`, { ...basePostContext, newSessionId: newId });
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
// Define cleanup logic when the transport closes (client disconnect, DELETE, error).
|
|
307
|
+
transport.onclose = () => {
|
|
308
|
+
const closedSessionId = transport.sessionId; // Get ID before potential deletion
|
|
309
|
+
// Removed duplicate declaration below
|
|
310
|
+
if (closedSessionId) {
|
|
311
|
+
logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId });
|
|
312
|
+
delete httpTransports[closedSessionId]; // Remove from active transports
|
|
313
|
+
sessionWorkingDirectories.delete(closedSessionId); // Clean up working directory state
|
|
314
|
+
logger.info(`HTTP Session closed and state cleaned: ${closedSessionId}`, { ...basePostContext, closedSessionId });
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
logger.debug('onclose handler triggered for transport without session ID (likely init failure).', basePostContext);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
// Create a dedicated McpServer instance for this new session.
|
|
321
|
+
logger.debug('Creating McpServer instance for new session...', basePostContext);
|
|
322
|
+
const server = await createServerInstanceFn();
|
|
323
|
+
// Connect the server logic to the transport layer.
|
|
324
|
+
logger.debug('Connecting McpServer to new transport...', basePostContext);
|
|
325
|
+
await server.connect(transport);
|
|
326
|
+
logger.debug('McpServer connected to transport.', basePostContext);
|
|
327
|
+
// NOTE: SDK's connect/handleRequest handles sending the InitializeResult.
|
|
328
|
+
}
|
|
329
|
+
else if (!transport) {
|
|
330
|
+
// --- Handle Non-Initialize Request without Valid Session ---
|
|
331
|
+
// MCP Spec: Server SHOULD respond 400/404 if session ID is missing/invalid for non-init requests.
|
|
332
|
+
logger.warning('Invalid or missing session ID for non-initialize POST request.', { ...basePostContext, sessionId });
|
|
333
|
+
res.status(404).json({ jsonrpc: '2.0', error: { code: -32004, message: 'Invalid or expired session ID' }, id: requestId });
|
|
334
|
+
return; // Stop processing
|
|
335
|
+
}
|
|
336
|
+
// --- Handle Request Content (Initialize or Subsequent Message) ---
|
|
337
|
+
// Use the extracted sessionId in the context for these logs
|
|
338
|
+
const currentSessionId = transport.sessionId; // Should be defined here
|
|
339
|
+
logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
|
|
340
|
+
// Delegate the actual handling (parsing, routing, response/SSE generation) to the SDK transport instance.
|
|
341
|
+
// The SDK transport handles returning 202 for notification/response-only POSTs internally.
|
|
342
|
+
await transport.handleRequest(req, res, req.body);
|
|
343
|
+
logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
// Catch-all for errors during POST handling.
|
|
347
|
+
// Include sessionId if available in the transport object at this point
|
|
348
|
+
const errorSessionId = transport?.sessionId || sessionId; // Use extracted or from transport if available
|
|
349
|
+
logger.error('Error handling POST request', {
|
|
350
|
+
...basePostContext,
|
|
351
|
+
sessionId: errorSessionId, // Add sessionId to error context
|
|
352
|
+
isInitReq, // Include isInitReq flag
|
|
353
|
+
error: err instanceof Error ? err.message : String(err),
|
|
354
|
+
stack: err instanceof Error ? err.stack : undefined
|
|
355
|
+
});
|
|
356
|
+
if (!res.headersSent) {
|
|
357
|
+
// Send generic JSON-RPC error if possible.
|
|
358
|
+
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error during POST handling' }, id: requestId });
|
|
359
|
+
}
|
|
360
|
+
// Ensure transport is cleaned up if an error occurred during initialization before session ID assigned.
|
|
361
|
+
if (isInitReq && transport && !transport.sessionId) {
|
|
362
|
+
logger.debug('Cleaning up transport after initialization failure.', { ...basePostContext, sessionId: errorSessionId });
|
|
363
|
+
await transport.close().catch(closeErr => logger.error('Error closing transport after init failure', { ...basePostContext, sessionId: errorSessionId, closeError: closeErr }));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// Unified handler for GET (SSE connection) and DELETE (session termination).
|
|
368
|
+
const handleSessionReq = async (req, res) => {
|
|
369
|
+
const method = req.method; // GET or DELETE
|
|
370
|
+
// Define base context for this request
|
|
371
|
+
const baseSessionReqContext = { ...transportContext, operation: `handle${method}`, method };
|
|
372
|
+
logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, { ...baseSessionReqContext, headers: req.headers });
|
|
373
|
+
// MCP Spec: Client MUST include Mcp-Session-Id header (after init).
|
|
374
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
375
|
+
// Log extracted session ID, adding it to the context for this specific log message
|
|
376
|
+
logger.debug(`Extracted session ID: ${sessionId}`, { ...baseSessionReqContext, sessionId });
|
|
377
|
+
const transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
378
|
+
// Log transport lookup result, adding sessionId to context
|
|
379
|
+
logger.debug(`Found existing transport for session ID: ${!!transport}`, { ...baseSessionReqContext, sessionId });
|
|
380
|
+
if (!transport) {
|
|
381
|
+
// MCP Spec: Server MUST respond 404 if session ID invalid/expired.
|
|
382
|
+
logger.warning(`Session not found for ${method} request`, { ...baseSessionReqContext, sessionId });
|
|
383
|
+
res.status(404).send('Session not found or expired');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
// Use the extracted sessionId in the context for these logs
|
|
388
|
+
logger.debug(`Delegating ${method} request to transport for session ${sessionId}...`, { ...baseSessionReqContext, sessionId });
|
|
389
|
+
// MCP Spec (GET): Client MAY issue GET to open SSE stream. Server MUST respond text/event-stream or 405.
|
|
390
|
+
// MCP Spec (GET): Client SHOULD include Last-Event-ID for resumption. Resumption handling depends on SDK transport.
|
|
391
|
+
// MCP Spec (DELETE): Client SHOULD send DELETE to terminate. Server MAY respond 405 if not supported.
|
|
392
|
+
// This implementation supports DELETE via the SDK transport's handleRequest.
|
|
393
|
+
await transport.handleRequest(req, res);
|
|
394
|
+
logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId });
|
|
395
|
+
// Note: For DELETE, the transport's handleRequest should trigger the 'onclose' handler for cleanup.
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
// Include sessionId in error context
|
|
399
|
+
logger.error(`Error handling ${method} request for session ${sessionId}`, {
|
|
400
|
+
...baseSessionReqContext,
|
|
401
|
+
sessionId, // Add sessionId here
|
|
402
|
+
error: err instanceof Error ? err.message : String(err),
|
|
403
|
+
stack: err instanceof Error ? err.stack : undefined
|
|
404
|
+
});
|
|
405
|
+
if (!res.headersSent) {
|
|
406
|
+
// Generic error if response hasn't started (e.g., error before SSE connection).
|
|
407
|
+
res.status(500).send('Internal Server Error');
|
|
408
|
+
}
|
|
409
|
+
// The SDK transport's handleRequest should manage errors occurring *during* an SSE stream.
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
// Route GET and DELETE requests to the unified handler.
|
|
413
|
+
app.get(MCP_ENDPOINT_PATH, handleSessionReq);
|
|
414
|
+
app.delete(MCP_ENDPOINT_PATH, handleSessionReq);
|
|
415
|
+
// --- Start HTTP Server ---
|
|
416
|
+
logger.debug('Creating HTTP server instance...', transportContext);
|
|
417
|
+
const serverInstance = http.createServer(app);
|
|
418
|
+
try {
|
|
419
|
+
logger.debug('Attempting to start HTTP server with retry logic...', transportContext);
|
|
420
|
+
// Use configured host and port, with retry logic.
|
|
421
|
+
const actualPort = await startHttpServerWithRetry(serverInstance, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext);
|
|
422
|
+
// Determine protocol for logging (basic assumption based on HSTS possibility)
|
|
423
|
+
const protocol = config.environment === 'production' ? 'https' : 'http';
|
|
424
|
+
const serverAddress = `${protocol}://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
|
|
425
|
+
// Use console.log for prominent startup message.
|
|
426
|
+
console.log(`\n🚀 MCP Server running in HTTP mode at: ${serverAddress}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
logger.fatal('HTTP server failed to start after multiple port retries.', { ...transportContext, error: err instanceof Error ? err.message : String(err) });
|
|
430
|
+
throw err; // Propagate error to stop the application
|
|
431
|
+
}
|
|
432
|
+
}
|