This is page 2 of 2. Use http://codebase.md/cyanheads/filesystem-mcp-server?page={x} to view the full context.
# Directory Structure
```
├── .clinerules
├── .dockerignore
├── .github
│ └── workflows
│ └── publish.yml
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── docs
│ └── tree.md
├── LICENSE
├── mcp.json
├── package-lock.json
├── package.json
├── README.md
├── repomix.config.json
├── scripts
│ ├── clean.ts
│ └── tree.ts
├── smithery.yaml
├── src
│ ├── config
│ │ └── index.ts
│ ├── index.ts
│ ├── mcp-server
│ │ ├── server.ts
│ │ ├── state.ts
│ │ ├── tools
│ │ │ ├── copyPath
│ │ │ │ ├── copyPathLogic.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── registration.ts
│ │ │ ├── createDirectory
│ │ │ │ ├── createDirectoryLogic.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── registration.ts
│ │ │ ├── deleteDirectory
│ │ │ │ ├── deleteDirectoryLogic.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── registration.ts
│ │ │ ├── deleteFile
│ │ │ │ ├── deleteFileLogic.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── registration.ts
│ │ │ ├── listFiles
│ │ │ │ ├── index.ts
│ │ │ │ ├── listFilesLogic.ts
│ │ │ │ └── registration.ts
│ │ │ ├── movePath
│ │ │ │ ├── index.ts
│ │ │ │ ├── movePathLogic.ts
│ │ │ │ └── registration.ts
│ │ │ ├── readFile
│ │ │ │ ├── index.ts
│ │ │ │ ├── readFileLogic.ts
│ │ │ │ └── registration.ts
│ │ │ ├── setFilesystemDefault
│ │ │ │ ├── index.ts
│ │ │ │ ├── registration.ts
│ │ │ │ └── setFilesystemDefaultLogic.ts
│ │ │ ├── updateFile
│ │ │ │ ├── index.ts
│ │ │ │ ├── registration.ts
│ │ │ │ └── updateFileLogic.ts
│ │ │ └── writeFile
│ │ │ ├── index.ts
│ │ │ ├── registration.ts
│ │ │ └── writeFileLogic.ts
│ │ └── transports
│ │ ├── authentication
│ │ │ └── authMiddleware.ts
│ │ ├── httpTransport.ts
│ │ └── stdioTransport.ts
│ ├── types-global
│ │ ├── errors.ts
│ │ ├── mcp.ts
│ │ └── tool.ts
│ └── utils
│ ├── index.ts
│ ├── internal
│ │ ├── errorHandler.ts
│ │ ├── index.ts
│ │ ├── logger.ts
│ │ └── requestContext.ts
│ ├── metrics
│ │ ├── index.ts
│ │ └── tokenCounter.ts
│ ├── parsing
│ │ ├── dateParser.ts
│ │ ├── index.ts
│ │ └── jsonParser.ts
│ └── security
│ ├── idGenerator.ts
│ ├── index.ts
│ ├── rateLimiter.ts
│ └── sanitization.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Loads, validates, and exports application configuration.
* This module centralizes configuration management, sourcing values from
* environment variables and `package.json`. It uses Zod for schema validation
* to ensure type safety and correctness of configuration parameters.
*
* Key responsibilities:
* - Load environment variables from a `.env` file.
* - Read `package.json` for default server name and version.
* - Define a Zod schema for all expected environment variables.
* - Validate environment variables against the schema.
* - Construct and export a comprehensive `config` object.
* - Export individual configuration values like `logLevel` and `environment` for convenience.
*
* @module src/config/index
*/
import dotenv from "dotenv";
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
import path, { dirname, join } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
dotenv.config();
// --- Determine Project Root ---
/**
* Finds the project root directory by searching upwards for package.json.
* @param startDir The directory to start searching from.
* @returns The absolute path to the project root, or throws an error if not found.
*/
const findProjectRoot = (startDir: string): string => {
let currentDir = startDir;
while (true) {
const packageJsonPath = join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
return currentDir;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached the root of the filesystem without finding package.json
throw new Error(
`Could not find project root (package.json) starting from ${startDir}`,
);
}
currentDir = parentDir;
}
};
let projectRoot: string;
try {
// For ESM, __dirname is not available directly.
// import.meta.url gives the URL of the current module.
const currentModuleDir = dirname(fileURLToPath(import.meta.url));
projectRoot = findProjectRoot(currentModuleDir);
} catch (error: any) {
console.error(`FATAL: Error determining project root: ${error.message}`);
// Fallback to process.cwd() if project root cannot be determined.
// This might happen in unusual execution environments.
projectRoot = process.cwd();
console.warn(
`Warning: Using process.cwd() (${projectRoot}) as fallback project root.`,
);
}
// --- End Determine Project Root ---
const pkgPath = join(projectRoot, "package.json"); // Use determined projectRoot
let pkg = { name: "mcp-ts-template", version: "0.0.0" };
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
} catch (error) {
if (process.stdout.isTTY) {
console.error(
"Warning: Could not read package.json for default config values. Using hardcoded defaults.",
error,
);
}
}
/**
* Zod schema for validating environment variables.
* Provides type safety, validation, defaults, and clear error messages.
* @private
*/
const EnvSchema = z.object({
/** Optional. The desired name for the MCP server. Defaults to `package.json` name. */
MCP_SERVER_NAME: z.string().optional(),
/** Optional. The version of the MCP server. Defaults to `package.json` version. */
MCP_SERVER_VERSION: z.string().optional(),
/** Minimum logging level. See `McpLogLevel` in logger utility. Default: "debug". */
MCP_LOG_LEVEL: z.string().default("debug"),
/** Directory for log files. Defaults to "logs" in project root. */
LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
/** Runtime environment (e.g., "development", "production"). Default: "development". */
NODE_ENV: z.string().default("development"),
/** MCP communication transport ("stdio" or "http"). Default: "stdio". */
MCP_TRANSPORT_TYPE: z.enum(["stdio", "http"]).default("stdio"),
/** HTTP server port (if MCP_TRANSPORT_TYPE is "http"). Default: 3010. */
MCP_HTTP_PORT: z.coerce.number().int().positive().default(3010),
/** HTTP server host (if MCP_TRANSPORT_TYPE is "http"). Default: "127.0.0.1". */
MCP_HTTP_HOST: z.string().default("127.0.0.1"),
/** Optional. Comma-separated allowed origins for CORS (HTTP transport). */
MCP_ALLOWED_ORIGINS: z.string().optional(),
/** Optional. Secret key (min 32 chars) for auth tokens (HTTP transport). CRITICAL for production. */
MCP_AUTH_SECRET_KEY: z
.string()
.min(
32,
"MCP_AUTH_SECRET_KEY must be at least 32 characters long for security reasons.",
)
.optional(),
/** Optional. Application URL for OpenRouter integration. */
OPENROUTER_APP_URL: z
.string()
.url("OPENROUTER_APP_URL must be a valid URL (e.g., http://localhost:3000)")
.optional(),
/** Optional. Application name for OpenRouter. Defaults to MCP_SERVER_NAME or package name. */
OPENROUTER_APP_NAME: z.string().optional(),
/** Optional. API key for OpenRouter services. */
OPENROUTER_API_KEY: z.string().optional(),
/** Default LLM model. Default: "google/gemini-2.5-flash-preview:thinking". */
LLM_DEFAULT_MODEL: z
.string()
.default("google/gemini-2.5-flash-preview-05-20"),
/** Optional. Default LLM temperature (0.0-2.0). */
LLM_DEFAULT_TEMPERATURE: z.coerce.number().min(0).max(2).optional(),
/** Optional. Default LLM top_p (0.0-1.0). */
LLM_DEFAULT_TOP_P: z.coerce.number().min(0).max(1).optional(),
/** Optional. Default LLM max tokens (positive integer). */
LLM_DEFAULT_MAX_TOKENS: z.coerce.number().int().positive().optional(),
/** Optional. Default LLM top_k (non-negative integer). */
LLM_DEFAULT_TOP_K: z.coerce.number().int().nonnegative().optional(),
/** Optional. Default LLM min_p (0.0-1.0). */
LLM_DEFAULT_MIN_P: z.coerce.number().min(0).max(1).optional(),
/** Optional. API key for Google Gemini services. */
GEMINI_API_KEY: z.string().optional(),
/** Optional. OAuth provider authorization endpoint URL. */
OAUTH_PROXY_AUTHORIZATION_URL: z
.string()
.url("OAUTH_PROXY_AUTHORIZATION_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider token endpoint URL. */
OAUTH_PROXY_TOKEN_URL: z
.string()
.url("OAUTH_PROXY_TOKEN_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider revocation endpoint URL. */
OAUTH_PROXY_REVOCATION_URL: z
.string()
.url("OAUTH_PROXY_REVOCATION_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider issuer URL. */
OAUTH_PROXY_ISSUER_URL: z
.string()
.url("OAUTH_PROXY_ISSUER_URL must be a valid URL.")
.optional(),
/** Optional. OAuth service documentation URL. */
OAUTH_PROXY_SERVICE_DOCUMENTATION_URL: z
.string()
.url("OAUTH_PROXY_SERVICE_DOCUMENTATION_URL must be a valid URL.")
.optional(),
/** Optional. Comma-separated default OAuth client redirect URIs. */
OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS: z.string().optional(),
/** Optional. Base directory for all filesystem operations. If set, tools cannot access paths outside this directory. Can be an absolute path or relative to the project root. */
FS_BASE_DIRECTORY: z.string().optional(),
});
const parsedEnv = EnvSchema.safeParse(process.env);
if (!parsedEnv.success) {
if (process.stdout.isTTY) {
console.error(
"❌ Invalid environment variables found:",
parsedEnv.error.flatten().fieldErrors,
);
}
// Consider throwing an error in production for critical misconfigurations.
}
let env = parsedEnv.success ? parsedEnv.data : EnvSchema.parse({});
// Resolve FS_BASE_DIRECTORY if it's relative
let resolvedFsBaseDirectory: string | undefined = env.FS_BASE_DIRECTORY;
if (env.FS_BASE_DIRECTORY && !path.isAbsolute(env.FS_BASE_DIRECTORY)) {
resolvedFsBaseDirectory = path.resolve(projectRoot, env.FS_BASE_DIRECTORY);
if (process.stdout.isTTY) {
console.log(
`Info: Relative FS_BASE_DIRECTORY "${env.FS_BASE_DIRECTORY}" resolved to "${resolvedFsBaseDirectory}".`
);
}
}
if (process.stdout.isTTY) {
if (resolvedFsBaseDirectory) {
// Ensure the resolved directory exists, or attempt to create it.
// This is a good place to also check if it's a directory.
try {
if (!existsSync(resolvedFsBaseDirectory)) {
mkdirSync(resolvedFsBaseDirectory, { recursive: true });
console.log(`Info: Created FS_BASE_DIRECTORY at "${resolvedFsBaseDirectory}".`);
} else {
const stats = statSync(resolvedFsBaseDirectory);
if (!stats.isDirectory()) {
console.error(`Error: FS_BASE_DIRECTORY "${resolvedFsBaseDirectory}" exists but is not a directory. Restriction will not be applied.`);
resolvedFsBaseDirectory = undefined; // Disable restriction if path is invalid
}
}
if (resolvedFsBaseDirectory) {
console.log(
`Info: Filesystem operations will be restricted to base directory: ${resolvedFsBaseDirectory}`
);
}
} catch (error: any) {
console.error(`Error processing FS_BASE_DIRECTORY "${resolvedFsBaseDirectory}": ${error.message}. Restriction will not be applied.`);
resolvedFsBaseDirectory = undefined; // Disable restriction on error
}
} else {
console.warn(
"Warning: FS_BASE_DIRECTORY is not set. Filesystem operations will not be restricted to a base directory. This is a potential security risk."
);
}
}
// --- Directory Ensurance Function ---
/**
* Ensures a directory exists and is within the project root.
* @param dirPath The desired path for the directory (can be relative or absolute).
* @param rootDir The root directory of the project to contain the directory.
* @param dirName The name of the directory type for logging (e.g., "logs").
* @returns The validated, absolute path to the directory, or null if invalid.
*/
const ensureDirectory = (
dirPath: string,
rootDir: string,
dirName: string,
): string | null => {
const resolvedDirPath = path.isAbsolute(dirPath)
? dirPath
: path.resolve(rootDir, dirPath);
// Ensure the resolved path is within the project root boundary
if (
!resolvedDirPath.startsWith(rootDir + path.sep) &&
resolvedDirPath !== rootDir
) {
if (process.stdout.isTTY) {
console.error(
`Error: ${dirName} path "${dirPath}" resolves to "${resolvedDirPath}", which is outside the project boundary "${rootDir}".`,
);
}
return null;
}
if (!existsSync(resolvedDirPath)) {
try {
mkdirSync(resolvedDirPath, { recursive: true });
if (process.stdout.isTTY) {
console.log(`Created ${dirName} directory: ${resolvedDirPath}`);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (process.stdout.isTTY) {
console.error(
`Error creating ${dirName} directory at ${resolvedDirPath}: ${errorMessage}`,
);
}
return null;
}
} else {
try {
const stats = statSync(resolvedDirPath);
if (!stats.isDirectory()) {
if (process.stdout.isTTY) {
console.error(
`Error: ${dirName} path ${resolvedDirPath} exists but is not a directory.`,
);
}
return null;
}
} catch (statError: any) {
if (process.stdout.isTTY) {
console.error(
`Error accessing ${dirName} path ${resolvedDirPath}: ${statError.message}`,
);
}
return null;
}
}
return resolvedDirPath;
};
// --- End Directory Ensurance Function ---
// --- Logs Directory Handling ---
const validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
if (!validatedLogsPath) {
if (process.stdout.isTTY) {
console.error(
"FATAL: Logs directory configuration is invalid or could not be created. Please check permissions and path. Exiting.",
);
}
process.exit(1); // Exit if logs directory is not usable
}
// --- End Logs Directory Handling ---
/**
* Main application configuration object.
* Aggregates settings from validated environment variables and `package.json`.
*/
export const config = {
/** MCP server name. Env `MCP_SERVER_NAME` > `package.json` name > "mcp-ts-template". */
mcpServerName: env.MCP_SERVER_NAME || pkg.name,
/** MCP server version. Env `MCP_SERVER_VERSION` > `package.json` version > "0.0.0". */
mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
/** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
logLevel: env.MCP_LOG_LEVEL,
/** Absolute path to the logs directory. From `LOGS_DIR` env var. */
logsPath: validatedLogsPath,
/** Runtime environment. From `NODE_ENV` env var. Default: "development". */
environment: env.NODE_ENV,
/** MCP transport type ('stdio' or 'http'). From `MCP_TRANSPORT_TYPE` env var. Default: "stdio". */
mcpTransportType: env.MCP_TRANSPORT_TYPE,
/** HTTP server port (if http transport). From `MCP_HTTP_PORT` env var. Default: 3010. */
mcpHttpPort: env.MCP_HTTP_PORT,
/** HTTP server host (if http transport). From `MCP_HTTP_HOST` env var. Default: "127.0.0.1". */
mcpHttpHost: env.MCP_HTTP_HOST,
/** Array of allowed CORS origins (http transport). From `MCP_ALLOWED_ORIGINS` (comma-separated). */
mcpAllowedOrigins: env.MCP_ALLOWED_ORIGINS?.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
/** Auth secret key (JWTs, http transport). From `MCP_AUTH_SECRET_KEY`. CRITICAL. */
mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
/** OpenRouter App URL. From `OPENROUTER_APP_URL`. Default: "http://localhost:3000". */
openrouterAppUrl: env.OPENROUTER_APP_URL || "http://localhost:3000",
/** OpenRouter App Name. From `OPENROUTER_APP_NAME`. Defaults to `mcpServerName`. */
openrouterAppName: env.OPENROUTER_APP_NAME || pkg.name || "MCP TS App",
/** OpenRouter API Key. From `OPENROUTER_API_KEY`. */
openrouterApiKey: env.OPENROUTER_API_KEY,
/** Default LLM model. From `LLM_DEFAULT_MODEL`. */
llmDefaultModel: env.LLM_DEFAULT_MODEL,
/** Default LLM temperature. From `LLM_DEFAULT_TEMPERATURE`. */
llmDefaultTemperature: env.LLM_DEFAULT_TEMPERATURE,
/** Default LLM top_p. From `LLM_DEFAULT_TOP_P`. */
llmDefaultTopP: env.LLM_DEFAULT_TOP_P,
/** Default LLM max tokens. From `LLM_DEFAULT_MAX_TOKENS`. */
llmDefaultMaxTokens: env.LLM_DEFAULT_MAX_TOKENS,
/** Default LLM top_k. From `LLM_DEFAULT_TOP_K`. */
llmDefaultTopK: env.LLM_DEFAULT_TOP_K,
/** Default LLM min_p. From `LLM_DEFAULT_MIN_P`. */
llmDefaultMinP: env.LLM_DEFAULT_MIN_P,
/** Gemini API Key. From `GEMINI_API_KEY`. */
geminiApiKey: env.GEMINI_API_KEY,
/** OAuth Proxy configurations. Undefined if no related env vars are set. */
oauthProxy:
env.OAUTH_PROXY_AUTHORIZATION_URL ||
env.OAUTH_PROXY_TOKEN_URL ||
env.OAUTH_PROXY_REVOCATION_URL ||
env.OAUTH_PROXY_ISSUER_URL ||
env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL ||
env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS
? {
authorizationUrl: env.OAUTH_PROXY_AUTHORIZATION_URL,
tokenUrl: env.OAUTH_PROXY_TOKEN_URL,
revocationUrl: env.OAUTH_PROXY_REVOCATION_URL,
issuerUrl: env.OAUTH_PROXY_ISSUER_URL,
serviceDocumentationUrl: env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL,
defaultClientRedirectUris:
env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS?.split(",")
.map((uri) => uri.trim())
.filter(Boolean),
}
: undefined,
/** Base directory for filesystem operations. From `FS_BASE_DIRECTORY`. If set, operations are restricted to this path. Will be an absolute path. */
fsBaseDirectory: resolvedFsBaseDirectory,
};
/**
* Configured logging level for the application.
* Exported for convenience.
*/
export const logLevel: string = config.logLevel;
/**
* Configured runtime environment ("development", "production", etc.).
* Exported for convenience.
*/
export const environment: string = config.environment;
```
--------------------------------------------------------------------------------
/src/utils/internal/errorHandler.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview This module provides utilities for robust error handling.
* It defines structures for error context, options for handling errors,
* and mappings for classifying errors. The main `ErrorHandler` class
* offers static methods for consistent error processing, logging, and transformation.
* @module src/utils/internal/errorHandler
*/
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { generateUUID, sanitizeInputForLogging } from "../index.js";
import { logger } from "./logger.js";
import { RequestContext } from "./requestContext.js";
/**
* Defines a generic structure for providing context with errors.
* This context can include identifiers like `requestId` or any other relevant
* key-value pairs that aid in debugging or understanding the error's circumstances.
*/
export interface ErrorContext {
/**
* A unique identifier for the request or operation during which the error occurred.
* Useful for tracing errors through logs and distributed systems.
*/
requestId?: string;
/**
* Allows for arbitrary additional context information.
* Keys are strings, and values can be of any type.
*/
[key: string]: unknown;
}
/**
* Configuration options for the `ErrorHandler.handleError` method.
* These options control how an error is processed, logged, and whether it's rethrown.
*/
export interface ErrorHandlerOptions {
/**
* The context of the operation that caused the error.
* This can include `requestId` and other relevant debugging information.
*/
context?: ErrorContext;
/**
* A descriptive name of the operation being performed when the error occurred.
* This helps in identifying the source or nature of the error in logs.
* Example: "UserLogin", "ProcessPayment", "FetchUserProfile".
*/
operation: string;
/**
* The input data or parameters that were being processed when the error occurred.
* This input will be sanitized before logging to prevent sensitive data exposure.
*/
input?: unknown;
/**
* If true, the (potentially transformed) error will be rethrown after handling.
* Defaults to `false`.
*/
rethrow?: boolean;
/**
* A specific `BaseErrorCode` to assign to the error, overriding any
* automatically determined error code.
*/
errorCode?: BaseErrorCode;
/**
* A custom function to map or transform the original error into a new `Error` instance.
* If provided, this function is used instead of the default `McpError` creation.
* @param error - The original error that occurred.
* @returns The transformed error.
*/
errorMapper?: (error: unknown) => Error;
/**
* If true, stack traces will be included in the logs.
* Defaults to `true`.
*/
includeStack?: boolean;
/**
* If true, indicates that the error is critical and might require immediate attention
* or could lead to system instability. This is primarily for logging and alerting.
* Defaults to `false`.
*/
critical?: boolean;
}
/**
* Defines a basic rule for mapping errors based on patterns.
* Used internally by `COMMON_ERROR_PATTERNS` and as a base for `ErrorMapping`.
*/
export interface BaseErrorMapping {
/**
* A string or regular expression to match against the error message.
* If a string is provided, it's typically used for substring matching (case-insensitive).
*/
pattern: string | RegExp;
/**
* The `BaseErrorCode` to assign if the pattern matches.
*/
errorCode: BaseErrorCode;
/**
* An optional custom message template for the mapped error.
* (Note: This property is defined but not directly used by `ErrorHandler.determineErrorCode`
* which focuses on `errorCode`. It's more relevant for custom mapping logic.)
*/
messageTemplate?: string;
}
/**
* Extends `BaseErrorMapping` to include a factory function for creating
* specific error instances and additional context for the mapping.
* Used by `ErrorHandler.mapError`.
* @template T The type of `Error` this mapping will produce, defaults to `Error`.
*/
export interface ErrorMapping<T extends Error = Error>
extends BaseErrorMapping {
/**
* A factory function that creates and returns an instance of the mapped error type `T`.
* @param error - The original error that occurred.
* @param context - Optional additional context provided in the mapping rule.
* @returns The newly created error instance.
*/
factory: (error: unknown, context?: Record<string, unknown>) => T;
/**
* Additional static context to be merged or passed to the `factory` function
* when this mapping rule is applied.
*/
additionalContext?: Record<string, unknown>;
}
/**
* Maps standard JavaScript error constructor names to `BaseErrorCode` values.
* @private
*/
const ERROR_TYPE_MAPPINGS: Readonly<Record<string, BaseErrorCode>> = {
SyntaxError: BaseErrorCode.VALIDATION_ERROR,
TypeError: BaseErrorCode.VALIDATION_ERROR,
ReferenceError: BaseErrorCode.INTERNAL_ERROR,
RangeError: BaseErrorCode.VALIDATION_ERROR,
URIError: BaseErrorCode.VALIDATION_ERROR,
EvalError: BaseErrorCode.INTERNAL_ERROR,
};
/**
* Array of `BaseErrorMapping` rules to classify errors by message/name patterns.
* Order matters: more specific patterns should precede generic ones.
* @private
*/
const COMMON_ERROR_PATTERNS: ReadonlyArray<Readonly<BaseErrorMapping>> = [
{
pattern:
/auth|unauthorized|unauthenticated|not.*logged.*in|invalid.*token|expired.*token/i,
errorCode: BaseErrorCode.UNAUTHORIZED,
},
{
pattern: /permission|forbidden|access.*denied|not.*allowed/i,
errorCode: BaseErrorCode.FORBIDDEN,
},
{
pattern: /not found|missing|no such|doesn't exist|couldn't find/i,
errorCode: BaseErrorCode.NOT_FOUND,
},
{
pattern:
/invalid|validation|malformed|bad request|wrong format|missing required/i,
errorCode: BaseErrorCode.VALIDATION_ERROR,
},
{
pattern: /conflict|already exists|duplicate|unique constraint/i,
errorCode: BaseErrorCode.CONFLICT,
},
{
pattern: /rate limit|too many requests|throttled/i,
errorCode: BaseErrorCode.RATE_LIMITED,
},
{
pattern: /timeout|timed out|deadline exceeded/i,
errorCode: BaseErrorCode.TIMEOUT,
},
{
pattern: /service unavailable|bad gateway|gateway timeout|upstream error/i,
errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
},
];
/**
* Creates a "safe" RegExp for testing error messages.
* Ensures case-insensitivity and removes the global flag.
* @param pattern - The string or RegExp pattern.
* @returns A new RegExp instance.
* @private
*/
function createSafeRegex(pattern: string | RegExp): RegExp {
if (pattern instanceof RegExp) {
let flags = pattern.flags.replace("g", "");
if (!flags.includes("i")) {
flags += "i";
}
return new RegExp(pattern.source, flags);
}
return new RegExp(pattern, "i");
}
/**
* Retrieves a descriptive name for an error object or value.
* @param error - The error object or value.
* @returns A string representing the error's name or type.
* @private
*/
function getErrorName(error: unknown): string {
if (error instanceof Error) {
return error.name || "Error";
}
if (error === null) {
return "NullValueEncountered";
}
if (error === undefined) {
return "UndefinedValueEncountered";
}
if (
typeof error === "object" &&
error !== null &&
error.constructor &&
typeof error.constructor.name === "string" &&
error.constructor.name !== "Object"
) {
return `${error.constructor.name}Encountered`;
}
return `${typeof error}Encountered`;
}
/**
* Extracts a message string from an error object or value.
* @param error - The error object or value.
* @returns The error message string.
* @private
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (error === null) {
return "Null value encountered as error";
}
if (error === undefined) {
return "Undefined value encountered as error";
}
if (typeof error === "string") {
return error;
}
try {
const str = String(error);
if (str === "[object Object]" && error !== null) {
try {
return `Non-Error object encountered: ${JSON.stringify(error)}`;
} catch (stringifyError) {
return `Unstringifyable non-Error object encountered (constructor: ${error.constructor?.name || "Unknown"})`;
}
}
return str;
} catch (e) {
return `Error converting error to string: ${e instanceof Error ? e.message : "Unknown conversion error"}`;
}
}
/**
* A utility class providing static methods for comprehensive error handling.
*/
export class ErrorHandler {
/**
* Determines an appropriate `BaseErrorCode` for a given error.
* Checks `McpError` instances, `ERROR_TYPE_MAPPINGS`, and `COMMON_ERROR_PATTERNS`.
* Defaults to `BaseErrorCode.INTERNAL_ERROR`.
* @param error - The error instance or value to classify.
* @returns The determined error code.
*/
public static determineErrorCode(error: unknown): BaseErrorCode {
if (error instanceof McpError) {
return error.code;
}
const errorName = getErrorName(error);
const errorMessage = getErrorMessage(error);
if (errorName in ERROR_TYPE_MAPPINGS) {
return ERROR_TYPE_MAPPINGS[errorName as keyof typeof ERROR_TYPE_MAPPINGS];
}
for (const mapping of COMMON_ERROR_PATTERNS) {
const regex = createSafeRegex(mapping.pattern);
if (regex.test(errorMessage) || regex.test(errorName)) {
return mapping.errorCode;
}
}
return BaseErrorCode.INTERNAL_ERROR;
}
/**
* Handles an error with consistent logging and optional transformation.
* Sanitizes input, determines error code, logs details, and can rethrow.
* @param error - The error instance or value that occurred.
* @param options - Configuration for handling the error.
* @returns The handled (and potentially transformed) error instance.
*/
public static handleError(
error: unknown,
options: ErrorHandlerOptions,
): Error {
const {
context = {},
operation,
input,
rethrow = false,
errorCode: explicitErrorCode,
includeStack = true,
critical = false,
errorMapper,
} = options;
const sanitizedInput =
input !== undefined ? sanitizeInputForLogging(input) : undefined;
const originalErrorName = getErrorName(error);
const originalErrorMessage = getErrorMessage(error);
const originalStack = error instanceof Error ? error.stack : undefined;
let finalError: Error;
let loggedErrorCode: BaseErrorCode;
const errorDetailsSeed =
error instanceof McpError &&
typeof error.details === "object" &&
error.details !== null
? { ...error.details }
: {};
const consolidatedDetails: Record<string, unknown> = {
...errorDetailsSeed,
...context,
originalErrorName,
originalMessage: originalErrorMessage,
};
if (
originalStack &&
!(error instanceof McpError && error.details?.originalStack)
) {
consolidatedDetails.originalStack = originalStack;
}
if (error instanceof McpError) {
loggedErrorCode = error.code;
finalError = errorMapper
? errorMapper(error)
: new McpError(error.code, error.message, consolidatedDetails);
} else {
loggedErrorCode =
explicitErrorCode || ErrorHandler.determineErrorCode(error);
const message = `Error in ${operation}: ${originalErrorMessage}`;
finalError = errorMapper
? errorMapper(error)
: new McpError(loggedErrorCode, message, consolidatedDetails);
}
if (
finalError !== error &&
error instanceof Error &&
finalError instanceof Error &&
!finalError.stack &&
error.stack
) {
finalError.stack = error.stack;
}
const logRequestId =
typeof context.requestId === "string" && context.requestId
? context.requestId
: generateUUID();
const logTimestamp =
typeof context.timestamp === "string" && context.timestamp
? context.timestamp
: new Date().toISOString();
const logPayload: Record<string, unknown> = {
requestId: logRequestId,
timestamp: logTimestamp,
operation,
input: sanitizedInput,
critical,
errorCode: loggedErrorCode,
originalErrorType: originalErrorName,
finalErrorType: getErrorName(finalError),
...Object.fromEntries(
Object.entries(context).filter(
([key]) => key !== "requestId" && key !== "timestamp",
),
),
};
if (finalError instanceof McpError && finalError.details) {
logPayload.errorDetails = finalError.details;
} else {
logPayload.errorDetails = consolidatedDetails;
}
if (includeStack) {
const stack =
finalError instanceof Error ? finalError.stack : originalStack;
if (stack) {
logPayload.stack = stack;
}
}
logger.error(
`Error in ${operation}: ${finalError.message || originalErrorMessage}`,
logPayload as unknown as RequestContext, // Cast to RequestContext for logger compatibility
);
if (rethrow) {
throw finalError;
}
return finalError;
}
/**
* Maps an error to a specific error type `T` based on `ErrorMapping` rules.
* Returns original/default error if no mapping matches.
* @template T The target error type, extending `Error`.
* @param error - The error instance or value to map.
* @param mappings - An array of mapping rules to apply.
* @param defaultFactory - Optional factory for a default error if no mapping matches.
* @returns The mapped error of type `T`, or the original/defaulted error.
*/
public static mapError<T extends Error>(
error: unknown,
mappings: ReadonlyArray<ErrorMapping<T>>,
defaultFactory?: (error: unknown, context?: Record<string, unknown>) => T,
): T | Error {
const errorMessage = getErrorMessage(error);
const errorName = getErrorName(error);
for (const mapping of mappings) {
const regex = createSafeRegex(mapping.pattern);
if (regex.test(errorMessage) || regex.test(errorName)) {
return mapping.factory(error, mapping.additionalContext);
}
}
if (defaultFactory) {
return defaultFactory(error);
}
return error instanceof Error ? error : new Error(String(error));
}
/**
* Formats an error into a consistent object structure for API responses or structured logging.
* @param error - The error instance or value to format.
* @returns A structured representation of the error.
*/
public static formatError(error: unknown): Record<string, unknown> {
if (error instanceof McpError) {
return {
code: error.code,
message: error.message,
details:
typeof error.details === "object" && error.details !== null
? error.details
: {},
};
}
if (error instanceof Error) {
return {
code: ErrorHandler.determineErrorCode(error),
message: error.message,
details: { errorType: error.name || "Error" },
};
}
return {
code: BaseErrorCode.UNKNOWN_ERROR,
message: getErrorMessage(error),
details: { errorType: getErrorName(error) },
};
}
/**
* Safely executes a function (sync or async) and handles errors using `ErrorHandler.handleError`.
* The error is always rethrown.
* @template T The expected return type of the function `fn`.
* @param fn - The function to execute.
* @param options - Error handling options (excluding `rethrow`).
* @returns A promise resolving with the result of `fn` if successful.
* @throws {McpError | Error} The error processed by `ErrorHandler.handleError`.
* @example
* ```typescript
* async function fetchData(userId: string, context: RequestContext) {
* return ErrorHandler.tryCatch(
* async () => {
* const response = await fetch(`/api/users/${userId}`);
* if (!response.ok) throw new Error(`Failed to fetch user: ${response.status}`);
* return response.json();
* },
* { operation: 'fetchUserData', context, input: { userId } }
* );
* }
* ```
*/
public static async tryCatch<T>(
fn: () => Promise<T> | T,
options: Omit<ErrorHandlerOptions, "rethrow">,
): Promise<T> {
try {
return await Promise.resolve(fn());
} catch (error) {
// ErrorHandler.handleError will return the error to be thrown.
throw ErrorHandler.handleError(error, { ...options, rethrow: true });
}
}
}
```
--------------------------------------------------------------------------------
/scripts/tree.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
/**
* Directory Tree Generation Operation
* ==================================
*
* A utility for generating visual tree representations of the project's directory
* structure with configurable depth control and gitignore integration.
*
* This operation creates a formatted markdown file containing a hierarchical
* representation of directories and files, respecting ignore patterns and
* applying configurable filtering.
*
* Features:
* - Respects .gitignore patterns and common exclusions
* - Configurable maximum depth traversal
* - Customizable output location
* - Sorting with directories first
* - Cross-platform compatibility
*
* @module utilities/generate.directory.tree.operation
*
* Usage examples:
* - Add to package.json: "tree": "ts-node scripts/tree.ts"
* - Run directly: npm run tree
* - Custom output: ts-node scripts/tree.ts ./documentation/structure.md
* - Limit depth: ts-node scripts/tree.ts --depth=3
* - Show help: ts-node scripts/tree.ts --help
*/
import fs from 'fs/promises';
import path from 'path';
// -----------------------------------
// Type Definitions
// -----------------------------------
/**
* Standardized error category classification (using type alias instead of enum)
*/
type ErrorCategoryType =
| 'VALIDATION'
| 'FILESYSTEM'
| 'SYSTEM'
| 'UNKNOWN';
const ErrorCategory = {
VALIDATION: 'VALIDATION' as ErrorCategoryType,
FILESYSTEM: 'FILESYSTEM' as ErrorCategoryType,
SYSTEM: 'SYSTEM' as ErrorCategoryType,
UNKNOWN: 'UNKNOWN' as ErrorCategoryType,
};
/**
* Error severity classification (using type alias instead of enum)
*/
type ErrorSeverityLevel = 0 | 1 | 2 | 3 | 4;
const ErrorSeverity = {
DEBUG: 0 as ErrorSeverityLevel,
INFO: 1 as ErrorSeverityLevel,
WARN: 2 as ErrorSeverityLevel,
ERROR: 3 as ErrorSeverityLevel,
FATAL: 4 as ErrorSeverityLevel,
};
/**
* Standardized error structure for consistent error handling
*/
interface StandardizedApplicationErrorObject {
errorMessage: string; // Human-readable description
errorCode: string; // Machine-readable identifier
errorCategory: ErrorCategoryType; // System area affected (using type alias)
errorSeverity: ErrorSeverityLevel; // How critical the error is (using type alias)
errorTimestamp: string; // When the error occurred
errorContext: Record<string, unknown>; // Additional relevant data
errorStack?: string; // Stack trace if available
}
/**
* Successful result from an operation
*/
interface OperationResultSuccess<DataType> {
resultSuccessful: true;
resultData: DataType;
}
/**
* Failed result from an operation
*/
interface OperationResultFailure<ErrorType> {
resultSuccessful: false;
resultError: ErrorType;
}
/**
* Combined result type for operations
*/
type OperationResult<DataType, ErrorType = StandardizedApplicationErrorObject> =
| OperationResultSuccess<DataType>
| OperationResultFailure<ErrorType>;
/**
* Configuration options for the tree generation operation
*/
interface TreeGenerationConfiguration {
treeOutputFilePath: string;
maximumDirectoryDepth: number;
showHelpText: boolean;
}
/**
* Definition of a gitignore pattern with parsing metadata
*/
interface GitignorePatternDefinition {
patternText: string;
isNegatedPattern: boolean;
regexPattern: string;
}
/**
* Result from the tree generation operation
*/
interface TreeGenerationResult {
projectName: string;
treeOutputFilePath: string;
treeContentLength: number;
maximumDepthApplied: number;
generationTimestamp: string;
}
// -----------------------------------
// Constants
// -----------------------------------
/**
* Default patterns to always ignore regardless of gitignore contents
*/
const DEFAULT_IGNORE_PATTERNS: string[] = [
'.git',
'node_modules',
'.DS_Store',
'dist',
'build'
];
/**
* Default output path for the generated tree
*/
const DEFAULT_OUTPUT_PATH = 'docs/tree.md';
/**
* Help text displayed when requested
*/
const HELP_TEXT = `
Directory Tree Generator - Project structure visualization tool
Usage:
node dist/utilities/generate.directory.tree.operation.js [output-path] [--depth=<number>] [--help]
Options:
output-path Custom file path for the tree output (default: docs/tree.md)
--depth=<number> Maximum directory depth to display (default: unlimited)
--help Show this help message
`;
// -----------------------------------
// Utility Functions
// -----------------------------------
/**
* Creates a standardized success result
*
* @param data - The data to include in the success result
* @returns A standardized success result object
*/
function createSuccessResult<DataType>(data: DataType): OperationResultSuccess<DataType> {
return { resultSuccessful: true, resultData: data };
}
/**
* Creates a standardized failure result
*
* @param error - The error to include in the failure result
* @returns A standardized failure result object
*/
function createFailureResult<ErrorType>(error: ErrorType): OperationResultFailure<ErrorType> {
return { resultSuccessful: false, resultError: error };
}
/**
* Creates a standardized error object
*
* @param message - Human-readable error message
* @param code - Machine-readable error code
* @param category - Error category classification
* @param severity - Error severity level
* @param context - Additional context data
* @returns A standardized error object
*/
function createStandardizedError(
message: string,
code: string,
category: ErrorCategoryType, // Use the type alias
severity: ErrorSeverityLevel, // Use the type alias
context: Record<string, unknown> = {}
): StandardizedApplicationErrorObject {
return {
errorMessage: message,
errorCode: code,
errorCategory: category,
errorSeverity: severity,
errorTimestamp: new Date().toISOString(),
errorContext: context
};
}
/**
* Converts an exception to a standardized error object
*
* @param exception - The caught exception
* @param defaultMessage - Fallback message if exception is not an Error object
* @returns A standardized error object
*/
function wrapExceptionAsStandardizedError(
exception: unknown,
defaultMessage: string
): StandardizedApplicationErrorObject {
const errorMessage = exception instanceof Error ? exception.message : defaultMessage;
const errorStack = exception instanceof Error ? exception.stack : undefined;
return {
errorMessage,
errorCode: 'UNEXPECTED_ERROR',
errorCategory: ErrorCategory.UNKNOWN, // Use the constant object
errorSeverity: ErrorSeverity.ERROR, // Use the constant object
errorTimestamp: new Date().toISOString(),
errorContext: { originalException: exception },
errorStack
};
}
// -----------------------------------
// Implementation Functions
// -----------------------------------
/**
* Parses command line arguments to extract configuration options
*
* @param commandLineArguments - Array of arguments from process.argv
* @returns Configuration object for tree generation
*/
function parseCommandLineArguments(
commandLineArguments: string[]
): TreeGenerationConfiguration {
let treeOutputFilePath = DEFAULT_OUTPUT_PATH;
let maximumDirectoryDepth = Infinity;
let showHelpText = false;
for (const argumentValue of commandLineArguments) {
if (argumentValue === '--help') {
showHelpText = true;
} else if (argumentValue.startsWith('--depth=')) {
const depthValue = argumentValue.split('=')[1];
const parsedDepth = parseInt(depthValue, 10);
if (isNaN(parsedDepth) || parsedDepth < 1) {
console.error('Invalid depth value. Using unlimited depth.');
maximumDirectoryDepth = Infinity;
} else {
maximumDirectoryDepth = parsedDepth;
}
} else if (!argumentValue.startsWith('--')) {
// If it's not an option flag, assume it's the output path
treeOutputFilePath = argumentValue;
}
}
return {
treeOutputFilePath,
maximumDirectoryDepth,
showHelpText
};
}
/**
* Loads and parses patterns from the .gitignore file
*
* @returns Promise resolving to an array of parsed gitignore patterns
*/
async function loadGitignorePatternDefinitions(): Promise<OperationResult<GitignorePatternDefinition[]>> {
try {
const gitignoreContent = await fs.readFile('.gitignore', 'utf-8');
const patternDefinitions = gitignoreContent
.split('\n')
.map(line => line.trim())
// Remove comments, empty lines, and lines with just whitespace
.filter(line => line && !line.startsWith('#') && line.trim() !== '')
// Process each pattern
.map(pattern => ({
patternText: pattern.startsWith('!') ? pattern.slice(1) : pattern,
isNegatedPattern: pattern.startsWith('!'),
// Convert glob patterns to regex-compatible strings (simplified approach)
regexPattern: pattern
.replace(/\./g, '\\.') // Escape dots first
.replace(/\*/g, '.*') // Convert * to .*
.replace(/\?/g, '.') // Convert ? to .
.replace(/\/$/, '(/.*)?') // Handle directory indicators
}));
return createSuccessResult(patternDefinitions);
} catch (exceptionObject) {
console.warn('No .gitignore file found, using default patterns only');
return createSuccessResult([]);
}
}
/**
* Checks if a given file path should be ignored based on patterns
*
* @param entryPath - The relative path to check
* @param ignorePatternDefinitions - Array of parsed gitignore patterns
* @returns Boolean indicating if the path should be ignored
*/
function checkPathShouldBeIgnored(
entryPath: string,
ignorePatternDefinitions: GitignorePatternDefinition[]
): boolean {
// Always check default patterns first
if (DEFAULT_IGNORE_PATTERNS.some(pattern => entryPath.includes(pattern))) {
return true;
}
let shouldBeIgnored = false;
for (const { patternText, isNegatedPattern, regexPattern } of ignorePatternDefinitions) {
// Convert the pattern to a proper regex
const compiledRegexPattern = new RegExp(`^${regexPattern}$|/${regexPattern}$|/${regexPattern}/`);
if (compiledRegexPattern.test(entryPath)) {
// If it's a negation pattern (!pattern), this file should NOT be ignored
// Otherwise, it should be ignored
shouldBeIgnored = !isNegatedPattern;
}
}
return shouldBeIgnored;
}
/**
* Recursively generates a tree representation of the directory structure
*
* @param directoryPath - Path to the directory to process
* @param ignorePatternDefinitions - Array of gitignore pattern definitions
* @param prefixString - Prefix string for the current level (used for indentation)
* @param isLastEntry - Whether this is the last entry at the current level
* @param relativePathString - Relative path from the root directory
* @param currentDepthLevel - Current depth level in the traversal
* @returns Promise resolving to the string representation of the tree
*/
async function generateDirectoryTreeRepresentation(
directoryPath: string,
ignorePatternDefinitions: GitignorePatternDefinition[],
prefixString = '',
isLastEntry = true,
relativePathString = '',
currentDepthLevel = 0,
maximumDepthLevel = Infinity
): Promise<OperationResult<string>> {
try {
const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true });
let treeOutputContent = '';
// Filter and sort entries
const filteredEntries = directoryEntries
.filter(entry => {
const entryPath = path.join(relativePathString, entry.name);
return !checkPathShouldBeIgnored(entryPath, ignorePatternDefinitions);
})
.sort((a, b) => {
// Directories first, then files
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (let entryIndex = 0; entryIndex < filteredEntries.length; entryIndex++) {
const entryItem = filteredEntries[entryIndex];
const isLastItem = entryIndex === filteredEntries.length - 1;
const newPrefixString = prefixString + (isLastEntry ? ' ' : '│ ');
const newRelativePath = path.join(relativePathString, entryItem.name);
treeOutputContent += prefixString + (isLastItem ? '└── ' : '├── ') + entryItem.name + '\n';
// Only traverse deeper if we haven't reached maximumDepthLevel
if (entryItem.isDirectory() && currentDepthLevel < maximumDepthLevel) {
const subTreeResult = await generateDirectoryTreeRepresentation(
path.join(directoryPath, entryItem.name),
ignorePatternDefinitions,
newPrefixString,
isLastItem,
newRelativePath,
currentDepthLevel + 1,
maximumDepthLevel
);
if (subTreeResult.resultSuccessful) {
treeOutputContent += subTreeResult.resultData;
} else {
return subTreeResult; // Propagate error
}
}
}
return createSuccessResult(treeOutputContent);
} catch (exceptionObject) {
return createFailureResult(
wrapExceptionAsStandardizedError(
exceptionObject,
`Failed to generate tree for directory: ${directoryPath}`
)
);
}
}
/**
* Ensures the directory for the output file exists, creating it if needed
*
* @param directoryPath - Path to the directory to check/create
* @returns Promise resolving to operation result
*/
async function ensureDirectoryExists(
directoryPath: string
): Promise<OperationResult<boolean>> {
try {
await fs.access(directoryPath);
return createSuccessResult(true);
} catch {
try {
await fs.mkdir(directoryPath, { recursive: true });
console.log(`Creating directory: ${directoryPath}`);
return createSuccessResult(true);
} catch (exceptionObject) {
return createFailureResult(
wrapExceptionAsStandardizedError(
exceptionObject,
`Failed to create directory: ${directoryPath}`
)
);
}
}
}
/**
* Writes the generated tree content to a markdown file
*
* @param projectName - Name of the project
* @param treeContent - Generated tree content
* @param outputFilePath - Path where the output file should be written
* @param maximumDepthValue - Maximum depth value that was applied
* @returns Promise resolving to operation result
*/
async function writeTreeContentToFile(
projectName: string,
treeContent: string,
outputFilePath: string,
maximumDepthValue: number
): Promise<OperationResult<TreeGenerationResult>> {
try {
const rootDirectoryPath = process.cwd();
const outputDirectoryPath = path.dirname(path.resolve(rootDirectoryPath, outputFilePath));
// Ensure output directory exists
const directoryResult = await ensureDirectoryExists(outputDirectoryPath);
if (!directoryResult.resultSuccessful) {
return directoryResult;
}
// Format the timestamp
const timestamp = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
// Format the markdown content
const markdownContent = `# ${projectName} - Directory Structure
Generated on: ${timestamp}
${maximumDepthValue !== Infinity ? `_Depth limited to ${maximumDepthValue} levels_\n\n` : ''}
\`\`\`
${projectName}
${treeContent}
\`\`\`
_Note: This tree excludes files and directories matched by .gitignore and common patterns like node_modules._
`;
// Write the content to the file
await fs.writeFile(
path.resolve(rootDirectoryPath, outputFilePath),
markdownContent
);
return createSuccessResult({
projectName,
treeOutputFilePath: outputFilePath,
treeContentLength: treeContent.length,
maximumDepthApplied: maximumDepthValue,
generationTimestamp: timestamp
});
} catch (exceptionObject) {
return createFailureResult(
wrapExceptionAsStandardizedError(
exceptionObject,
`Failed to write tree to file: ${outputFilePath}`
)
);
}
}
/**
* Main operation function that orchestrates the tree generation process
*
* @returns Promise that resolves when the operation completes
*/
async function generateProjectDirectoryTree(): Promise<void> {
try {
// Parse command line arguments
const commandLineArguments = process.argv.slice(2);
const configurationSettings = parseCommandLineArguments(commandLineArguments);
// Display help if requested
if (configurationSettings.showHelpText) {
console.log(HELP_TEXT);
process.exit(0);
}
const rootDirectoryPath = process.cwd();
const projectName = path.basename(rootDirectoryPath);
// Load gitignore patterns
const ignorePatternResult = await loadGitignorePatternDefinitions();
if (!ignorePatternResult.resultSuccessful) {
throw new Error(`Failed to load gitignore patterns: ${ignorePatternResult.resultError.errorMessage}`);
}
const ignorePatternDefinitions = ignorePatternResult.resultData;
console.log(`Generating directory tree for: ${projectName}`);
console.log(`Output path: ${configurationSettings.treeOutputFilePath}`);
if (configurationSettings.maximumDirectoryDepth !== Infinity) {
console.log(`Maximum depth: ${configurationSettings.maximumDirectoryDepth}`);
}
// Generate the tree structure
const treeGenerationResult = await generateDirectoryTreeRepresentation(
rootDirectoryPath,
ignorePatternDefinitions,
'',
true,
'',
0,
configurationSettings.maximumDirectoryDepth
);
if (!treeGenerationResult.resultSuccessful) {
throw new Error(`Failed to generate tree: ${treeGenerationResult.resultError.errorMessage}`);
}
// Write the tree to a file
const writeResult = await writeTreeContentToFile(
projectName,
treeGenerationResult.resultData,
configurationSettings.treeOutputFilePath,
configurationSettings.maximumDirectoryDepth
);
if (!writeResult.resultSuccessful) {
throw new Error(`Failed to write tree: ${writeResult.resultError.errorMessage}`);
}
console.log(`✓ Successfully generated tree structure in ${configurationSettings.treeOutputFilePath}`);
} catch (exceptionObject) {
const standardizedError = wrapExceptionAsStandardizedError(
exceptionObject,
'Unhandled error during tree generation'
);
console.error(`× Error generating tree: ${standardizedError.errorMessage}`);
process.exit(1);
}
}
// -----------------------------------
// Script Execution
// -----------------------------------
// Execute the main operation function
generateProjectDirectoryTree();
```
--------------------------------------------------------------------------------
/src/utils/internal/logger.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Provides a singleton Logger class that wraps Winston for file logging
* and supports sending MCP (Model Context Protocol) `notifications/message`.
* It handles different log levels compliant with RFC 5424 and MCP specifications.
* @module src/utils/internal/logger
*/
import fs from "fs";
import path from "path";
import winston from "winston";
import TransportStream from "winston-transport";
import { config } from "../../config/index.js";
import { RequestContext } from "./requestContext.js";
/**
* Defines the supported logging levels based on RFC 5424 Syslog severity levels,
* as used by the Model Context Protocol (MCP).
* Levels are: 'debug'(7), 'info'(6), 'notice'(5), 'warning'(4), 'error'(3), 'crit'(2), 'alert'(1), 'emerg'(0).
* Lower numeric values indicate higher severity.
*/
export type McpLogLevel =
| "debug"
| "info"
| "notice"
| "warning"
| "error"
| "crit"
| "alert"
| "emerg";
/**
* Numeric severity mapping for MCP log levels (lower is more severe).
* @private
*/
const mcpLevelSeverity: Record<McpLogLevel, number> = {
emerg: 0,
alert: 1,
crit: 2,
error: 3,
warning: 4,
notice: 5,
info: 6,
debug: 7,
};
/**
* Maps MCP log levels to Winston's core levels for file logging.
* @private
*/
const mcpToWinstonLevel: Record<
McpLogLevel,
"debug" | "info" | "warn" | "error"
> = {
debug: "debug",
info: "info",
notice: "info",
warning: "warn",
error: "error",
crit: "error",
alert: "error",
emerg: "error",
};
/**
* Interface for a more structured error object, primarily for formatting console logs.
* @private
*/
interface ErrorWithMessageAndStack {
message?: string;
stack?: string;
[key: string]: any;
}
/**
* Interface for the payload of an MCP log notification.
* This structure is used when sending log data via MCP `notifications/message`.
*/
export interface McpLogPayload {
message: string;
context?: RequestContext;
error?: {
message: string;
stack?: string;
};
[key: string]: any;
}
/**
* Type for the `data` parameter of the `McpNotificationSender` function.
*/
export type McpNotificationData = McpLogPayload | Record<string, unknown>;
/**
* Defines the signature for a function that can send MCP log notifications.
* This function is typically provided by the MCP server instance.
* @param level - The severity level of the log message.
* @param data - The payload of the log notification.
* @param loggerName - An optional name or identifier for the logger/server.
*/
export type McpNotificationSender = (
level: McpLogLevel,
data: McpNotificationData,
loggerName?: string,
) => void;
// The logsPath from config is already resolved and validated by src/config/index.ts
const resolvedLogsDir = config.logsPath;
const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic.
/**
* Creates the Winston console log format.
* @returns The Winston log format for console output.
* @private
*/
function createWinstonConsoleFormat(): winston.Logform.Format {
return winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let metaString = "";
const metaCopy = { ...meta };
if (metaCopy.error && typeof metaCopy.error === "object") {
const errorObj = metaCopy.error as ErrorWithMessageAndStack;
if (errorObj.message) metaString += `\n Error: ${errorObj.message}`;
if (errorObj.stack)
metaString += `\n Stack: ${String(errorObj.stack)
.split("\n")
.map((l: string) => ` ${l}`)
.join("\n")}`;
delete metaCopy.error;
}
if (Object.keys(metaCopy).length > 0) {
try {
const remainingMetaJson = JSON.stringify(metaCopy, null, 2);
if (remainingMetaJson !== "{}")
metaString += `\n Meta: ${remainingMetaJson}`;
} catch (stringifyError: unknown) {
const errorMessage =
stringifyError instanceof Error
? stringifyError.message
: String(stringifyError);
metaString += `\n Meta: [Error stringifying metadata: ${errorMessage}]`;
}
}
return `${timestamp} ${level}: ${message}${metaString}`;
}),
);
}
/**
* Singleton Logger class that wraps Winston for robust logging.
* Supports file logging, conditional console logging, and MCP notifications.
*/
export class Logger {
private static instance: Logger;
private winstonLogger?: winston.Logger;
private initialized = false;
private mcpNotificationSender?: McpNotificationSender;
private currentMcpLevel: McpLogLevel = "info";
private currentWinstonLevel: "debug" | "info" | "warn" | "error" = "info";
private readonly MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH = 1024;
private readonly LOG_FILE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
private readonly LOG_MAX_FILES = 5;
/** @private */
private constructor() {}
/**
* Initializes the Winston logger instance.
* Should be called once at application startup.
* @param level - The initial minimum MCP log level.
*/
public async initialize(level: McpLogLevel = "info"): Promise<void> {
if (this.initialized) {
this.warning("Logger already initialized.", {
loggerSetup: true,
requestId: "logger-init",
timestamp: new Date().toISOString(),
});
return;
}
this.currentMcpLevel = level;
this.currentWinstonLevel = mcpToWinstonLevel[level];
let logsDirCreatedMessage: string | null = null; // This message is now informational as creation is handled by config
if (isLogsDirSafe) {
// Directory creation is handled by config/index.ts ensureDirectory.
// We can log if it was newly created by checking if it existed before config ran,
// but that's complex. For now, we assume config handled it.
// If resolvedLogsDir is set, config ensures it exists.
if (!fs.existsSync(resolvedLogsDir)) {
// This case should ideally not be hit if config.logsPath is correctly set up and validated.
// However, if it somehow occurs (e.g. dir deleted after config init but before logger init),
// we attempt to create it.
try {
await fs.promises.mkdir(resolvedLogsDir, { recursive: true });
logsDirCreatedMessage = `Re-created logs directory (should have been created by config): ${resolvedLogsDir}`;
} catch (err: unknown) {
if (process.stdout.isTTY) {
const errorMessage =
err instanceof Error ? err.message : String(err);
console.error(
`Error creating logs directory at ${resolvedLogsDir}: ${errorMessage}. File logging disabled.`,
);
}
throw err; // Critical if logs dir cannot be ensured
}
}
}
const fileFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
);
const transports: TransportStream[] = [];
const fileTransportOptions = {
format: fileFormat,
maxsize: this.LOG_FILE_MAX_SIZE,
maxFiles: this.LOG_MAX_FILES,
tailable: true,
};
if (isLogsDirSafe) {
transports.push(
new winston.transports.File({
filename: path.join(resolvedLogsDir, "error.log"),
level: "error",
...fileTransportOptions,
}),
new winston.transports.File({
filename: path.join(resolvedLogsDir, "warn.log"),
level: "warn",
...fileTransportOptions,
}),
new winston.transports.File({
filename: path.join(resolvedLogsDir, "info.log"),
level: "info",
...fileTransportOptions,
}),
new winston.transports.File({
filename: path.join(resolvedLogsDir, "debug.log"),
level: "debug",
...fileTransportOptions,
}),
new winston.transports.File({
filename: path.join(resolvedLogsDir, "combined.log"),
...fileTransportOptions,
}),
);
} else {
if (process.stdout.isTTY) {
console.warn(
"File logging disabled as logsPath is not configured or invalid.",
);
}
}
this.winstonLogger = winston.createLogger({
level: this.currentWinstonLevel,
transports,
exitOnError: false,
});
// Configure console transport after Winston logger is created
const consoleStatus = this._configureConsoleTransport();
const initialContext: RequestContext = {
loggerSetup: true,
requestId: "logger-init-deferred",
timestamp: new Date().toISOString(),
};
if (logsDirCreatedMessage) {
// Log if we had to re-create it
this.info(logsDirCreatedMessage, initialContext);
}
if (consoleStatus.message) {
this.info(consoleStatus.message, initialContext);
}
this.initialized = true;
this.info(
`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
{
loggerSetup: true,
requestId: "logger-post-init",
timestamp: new Date().toISOString(),
logsPathUsed: resolvedLogsDir,
},
);
}
/**
* Sets the function used to send MCP 'notifications/message'.
* @param sender - The function to call for sending notifications, or undefined to disable.
*/
public setMcpNotificationSender(
sender: McpNotificationSender | undefined,
): void {
this.mcpNotificationSender = sender;
const status = sender ? "enabled" : "disabled";
this.info(`MCP notification sending ${status}.`, {
loggerSetup: true,
requestId: "logger-set-sender",
timestamp: new Date().toISOString(),
});
}
/**
* Dynamically sets the minimum logging level.
* @param newLevel - The new minimum MCP log level to set.
*/
public setLevel(newLevel: McpLogLevel): void {
const setLevelContext: RequestContext = {
loggerSetup: true,
requestId: "logger-set-level",
timestamp: new Date().toISOString(),
};
if (!this.ensureInitialized()) {
if (process.stdout.isTTY) {
console.error("Cannot set level: Logger not initialized.");
}
return;
}
if (!(newLevel in mcpLevelSeverity)) {
this.warning(
`Invalid MCP log level provided: ${newLevel}. Level not changed.`,
setLevelContext,
);
return;
}
const oldLevel = this.currentMcpLevel;
this.currentMcpLevel = newLevel;
this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
if (this.winstonLogger) {
// Ensure winstonLogger is defined
this.winstonLogger.level = this.currentWinstonLevel;
}
const consoleStatus = this._configureConsoleTransport();
if (oldLevel !== newLevel) {
this.info(
`Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
setLevelContext,
);
if (
consoleStatus.message &&
consoleStatus.message !== "Console logging status unchanged."
) {
this.info(consoleStatus.message, setLevelContext);
}
}
}
/**
* Configures the console transport based on the current log level and TTY status.
* Adds or removes the console transport as needed.
* @returns {{ enabled: boolean, message: string | null }} Status of console logging.
* @private
*/
private _configureConsoleTransport(): {
enabled: boolean;
message: string | null;
} {
if (!this.winstonLogger) {
return {
enabled: false,
message: "Cannot configure console: Winston logger not initialized.",
};
}
const consoleTransport = this.winstonLogger.transports.find(
(t) => t instanceof winston.transports.Console,
);
const shouldHaveConsole =
this.currentMcpLevel === "debug" && process.stdout.isTTY;
let message: string | null = null;
if (shouldHaveConsole && !consoleTransport) {
const consoleFormat = createWinstonConsoleFormat();
this.winstonLogger.add(
new winston.transports.Console({
level: "debug", // Console always logs debug if enabled
format: consoleFormat,
}),
);
message = "Console logging enabled (level: debug, stdout is TTY).";
} else if (!shouldHaveConsole && consoleTransport) {
this.winstonLogger.remove(consoleTransport);
message = "Console logging disabled (level not debug or stdout not TTY).";
} else {
message = "Console logging status unchanged.";
}
return { enabled: shouldHaveConsole, message };
}
/**
* Gets the singleton instance of the Logger.
* @returns The singleton Logger instance.
*/
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
/**
* Ensures the logger has been initialized.
* @returns True if initialized, false otherwise.
* @private
*/
private ensureInitialized(): boolean {
if (!this.initialized || !this.winstonLogger) {
if (process.stdout.isTTY) {
console.warn("Logger not initialized; message dropped.");
}
return false;
}
return true;
}
/**
* Centralized log processing method.
* @param level - The MCP severity level of the message.
* @param msg - The main log message.
* @param context - Optional request context for the log.
* @param error - Optional error object associated with the log.
* @private
*/
private log(
level: McpLogLevel,
msg: string,
context?: RequestContext,
error?: Error,
): void {
if (!this.ensureInitialized()) return;
if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
return; // Do not log if message level is less severe than currentMcpLevel
}
const logData: Record<string, unknown> = { ...context };
const winstonLevel = mcpToWinstonLevel[level];
if (error) {
this.winstonLogger!.log(winstonLevel, msg, { ...logData, error });
} else {
this.winstonLogger!.log(winstonLevel, msg, logData);
}
if (this.mcpNotificationSender) {
const mcpDataPayload: McpLogPayload = { message: msg };
if (context && Object.keys(context).length > 0)
mcpDataPayload.context = context;
if (error) {
mcpDataPayload.error = { message: error.message };
// Include stack trace in debug mode for MCP notifications, truncated for brevity
if (this.currentMcpLevel === "debug" && error.stack) {
mcpDataPayload.error.stack = error.stack.substring(
0,
this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH,
);
}
}
try {
const serverName =
config?.mcpServerName ?? "MCP_SERVER_NAME_NOT_CONFIGURED";
this.mcpNotificationSender(level, mcpDataPayload, serverName);
} catch (sendError: unknown) {
const errorMessage =
sendError instanceof Error ? sendError.message : String(sendError);
const internalErrorContext: RequestContext = {
requestId: context?.requestId || "logger-internal-error",
timestamp: new Date().toISOString(),
originalLevel: level,
originalMessage: msg,
sendError: errorMessage,
mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
};
this.winstonLogger!.error(
"Failed to send MCP log notification",
internalErrorContext,
);
}
}
}
/** Logs a message at the 'debug' level. */
public debug(msg: string, context?: RequestContext): void {
this.log("debug", msg, context);
}
/** Logs a message at the 'info' level. */
public info(msg: string, context?: RequestContext): void {
this.log("info", msg, context);
}
/** Logs a message at the 'notice' level. */
public notice(msg: string, context?: RequestContext): void {
this.log("notice", msg, context);
}
/** Logs a message at the 'warning' level. */
public warning(msg: string, context?: RequestContext): void {
this.log("warning", msg, context);
}
/**
* Logs a message at the 'error' level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
public error(
msg: string,
err?: Error | RequestContext,
context?: RequestContext,
): void {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("error", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'crit' (critical) level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
public crit(
msg: string,
err?: Error | RequestContext,
context?: RequestContext,
): void {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("crit", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'alert' level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
public alert(
msg: string,
err?: Error | RequestContext,
context?: RequestContext,
): void {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("alert", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'emerg' (emergency) level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
public emerg(
msg: string,
err?: Error | RequestContext,
context?: RequestContext,
): void {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("emerg", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'emerg' (emergency) level, typically for fatal errors.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
public fatal(
msg: string,
err?: Error | RequestContext,
context?: RequestContext,
): void {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("emerg", msg, actualContext, errorObj);
}
}
/**
* The singleton instance of the Logger.
* Use this instance for all logging operations.
*/
export const logger = Logger.getInstance();
```
--------------------------------------------------------------------------------
/src/utils/security/sanitization.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Provides a comprehensive `Sanitization` class for various input cleaning and validation tasks.
* This module includes utilities for sanitizing HTML, strings, URLs, file paths, JSON, numbers,
* and for redacting sensitive information from data intended for logging.
* @module src/utils/security/sanitization
*/
import path from "path";
import sanitizeHtml from "sanitize-html";
import validator from "validator";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, requestContextService } from "../index.js";
/**
* Defines options for path sanitization to control how file paths are processed and validated.
*/
export interface PathSanitizeOptions {
/** If provided, restricts sanitized paths to be relative to this directory. */
rootDir?: string;
/** If true, normalizes Windows backslashes to POSIX forward slashes. */
toPosix?: boolean;
/** If true, absolute paths are permitted (subject to `rootDir`). Default: false. */
allowAbsolute?: boolean;
}
/**
* Contains information about a path sanitization operation.
*/
export interface SanitizedPathInfo {
/** The final sanitized and normalized path string. */
sanitizedPath: string;
/** The original path string before any processing. */
originalInput: string;
/** True if the input path was absolute after initial normalization. */
wasAbsolute: boolean;
/** True if an absolute path was converted to relative due to `allowAbsolute: false`. */
convertedToRelative: boolean;
/** The effective options used for sanitization, including defaults. */
optionsUsed: PathSanitizeOptions;
}
/**
* Defines options for context-specific string sanitization.
*/
export interface SanitizeStringOptions {
/** The context in which the string will be used. 'javascript' is disallowed. */
context?: "text" | "html" | "attribute" | "url" | "javascript";
/** Custom allowed HTML tags if `context` is 'html'. */
allowedTags?: string[];
/** Custom allowed HTML attributes if `context` is 'html'. */
allowedAttributes?: Record<string, string[]>;
}
/**
* Configuration options for HTML sanitization, mirroring `sanitize-html` library options.
*/
export interface HtmlSanitizeConfig {
/** An array of allowed HTML tag names. */
allowedTags?: string[];
/** Specifies allowed attributes, either globally or per tag. */
allowedAttributes?: sanitizeHtml.IOptions["allowedAttributes"];
/** If true, HTML comments are preserved. */
preserveComments?: boolean;
/** Custom functions to transform tags during sanitization. */
transformTags?: sanitizeHtml.IOptions["transformTags"];
}
/**
* A singleton class providing various methods for input sanitization.
* Aims to protect against common vulnerabilities like XSS and path traversal.
*/
export class Sanitization {
/** @private */
private static instance: Sanitization;
/**
* Default list of field names considered sensitive for log redaction.
* Case-insensitive matching is applied.
* @private
*/
private sensitiveFields: string[] = [
"password",
"token",
"secret",
"key",
"apiKey",
"auth",
"credential",
"jwt",
"ssn",
"credit",
"card",
"cvv",
"authorization",
];
/**
* Default configuration for HTML sanitization.
* @private
*/
private defaultHtmlSanitizeConfig: HtmlSanitizeConfig = {
allowedTags: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"a",
"ul",
"ol",
"li",
"b",
"i",
"strong",
"em",
"strike",
"code",
"hr",
"br",
"div",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"pre",
],
allowedAttributes: {
a: ["href", "name", "target"],
img: ["src", "alt", "title", "width", "height"],
"*": ["class", "id", "style"],
},
preserveComments: false,
};
/** @private */
private constructor() {}
/**
* Retrieves the singleton instance of the `Sanitization` class.
* @returns The singleton `Sanitization` instance.
*/
public static getInstance(): Sanitization {
if (!Sanitization.instance) {
Sanitization.instance = new Sanitization();
}
return Sanitization.instance;
}
/**
* Sets or extends the list of sensitive field names for log sanitization.
* @param fields - An array of field names to add to the sensitive list.
*/
public setSensitiveFields(fields: string[]): void {
this.sensitiveFields = [
...new Set([
...this.sensitiveFields,
...fields.map((f) => f.toLowerCase()),
]),
];
const logContext = requestContextService.createRequestContext({
operation: "Sanitization.setSensitiveFields",
newSensitiveFieldCount: this.sensitiveFields.length,
});
logger.debug(
"Updated sensitive fields list for log sanitization",
logContext,
);
}
/**
* Gets a copy of the current list of sensitive field names.
* @returns An array of sensitive field names.
*/
public getSensitiveFields(): string[] {
return [...this.sensitiveFields];
}
/**
* Sanitizes an HTML string by removing potentially malicious tags and attributes.
* @param input - The HTML string to sanitize.
* @param config - Optional custom configuration for `sanitize-html`.
* @returns The sanitized HTML string. Returns an empty string if input is falsy.
*/
public sanitizeHtml(input: string, config?: HtmlSanitizeConfig): string {
if (!input) return "";
const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
const options: sanitizeHtml.IOptions = {
allowedTags: effectiveConfig.allowedTags,
allowedAttributes: effectiveConfig.allowedAttributes,
transformTags: effectiveConfig.transformTags,
};
if (effectiveConfig.preserveComments) {
options.allowedTags = [...(options.allowedTags || []), "!--"];
}
return sanitizeHtml(input, options);
}
/**
* Sanitizes a string based on its intended context (e.g., HTML, URL, text).
* **Important:** `context: 'javascript'` is disallowed due to security risks.
*
* @param input - The string to sanitize.
* @param options - Options specifying the sanitization context.
* @returns The sanitized string. Returns an empty string if input is falsy.
* @throws {McpError} If `options.context` is 'javascript', or URL validation fails.
*/
public sanitizeString(
input: string,
options: SanitizeStringOptions = {},
): string {
if (!input) return "";
switch (options.context) {
case "html":
return this.sanitizeHtml(input, {
allowedTags: options.allowedTags,
allowedAttributes: options.allowedAttributes
? this.convertAttributesFormat(options.allowedAttributes)
: undefined,
});
case "attribute":
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
case "url":
if (
!validator.isURL(input, {
protocols: ["http", "https"],
require_protocol: true,
require_host: true,
})
) {
logger.warning(
"Potentially invalid URL detected during string sanitization (context: url)",
requestContextService.createRequestContext({
operation: "Sanitization.sanitizeString.urlWarning",
invalidUrlAttempt: input,
}),
);
return "";
}
return validator.trim(input);
case "javascript":
logger.error(
"Attempted JavaScript sanitization via sanitizeString, which is disallowed.",
requestContextService.createRequestContext({
operation: "Sanitization.sanitizeString.jsAttempt",
inputSnippet: input.substring(0, 50),
}),
);
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
"JavaScript sanitization is not supported through sanitizeString due to security risks.",
);
case "text":
default:
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
}
}
/**
* Converts attribute format for `sanitizeHtml`.
* @param attrs - Attributes in `{ tagName: ['attr1'] }` format.
* @returns Attributes in `sanitize-html` expected format.
* @private
*/
private convertAttributesFormat(
attrs: Record<string, string[]>,
): sanitizeHtml.IOptions["allowedAttributes"] {
return attrs;
}
/**
* Sanitizes a URL string by validating its format and protocol.
* @param input - The URL string to sanitize.
* @param allowedProtocols - Array of allowed URL protocols. Default: `['http', 'https']`.
* @returns The sanitized and trimmed URL string.
* @throws {McpError} If the URL is invalid or uses a disallowed protocol.
*/
public sanitizeUrl(
input: string,
allowedProtocols: string[] = ["http", "https"],
): string {
try {
const trimmedInput = input.trim();
if (
!validator.isURL(trimmedInput, {
protocols: allowedProtocols,
require_protocol: true,
require_host: true,
})
) {
throw new Error("Invalid URL format or protocol not in allowed list.");
}
if (trimmedInput.toLowerCase().startsWith("javascript:")) {
throw new Error("JavaScript pseudo-protocol is not allowed in URLs.");
}
return trimmedInput;
} catch (error) {
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
error instanceof Error
? error.message
: "Invalid or unsafe URL provided.",
{ input },
);
}
}
/**
* Sanitizes a file path to prevent path traversal and normalize format.
* @param input - The file path string to sanitize.
* @param options - Options to control sanitization behavior.
* @returns An object with the sanitized path and sanitization metadata.
* @throws {McpError} If the path is invalid or unsafe.
*/
public sanitizePath(
input: string,
options: PathSanitizeOptions = {},
): SanitizedPathInfo {
const originalInput = input;
const effectiveOptions: PathSanitizeOptions = {
toPosix: options.toPosix ?? false,
allowAbsolute: options.allowAbsolute ?? false,
rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined,
};
let wasAbsoluteInitially = false;
let convertedToRelative = false;
try {
if (!input || typeof input !== "string")
throw new Error("Invalid path input: must be a non-empty string.");
if (input.includes("\0"))
throw new Error("Path contains null byte, which is disallowed.");
let normalized = path.normalize(input);
wasAbsoluteInitially = path.isAbsolute(normalized);
if (effectiveOptions.toPosix) {
normalized = normalized.replace(/\\/g, "/");
}
let finalSanitizedPath: string;
if (effectiveOptions.rootDir) {
const fullPath = path.resolve(effectiveOptions.rootDir, normalized);
if (
!fullPath.startsWith(effectiveOptions.rootDir + path.sep) &&
fullPath !== effectiveOptions.rootDir
) {
throw new Error(
"Path traversal detected: attempts to escape the defined root directory.",
);
}
finalSanitizedPath = path.relative(effectiveOptions.rootDir, fullPath);
finalSanitizedPath =
finalSanitizedPath === "" ? "." : finalSanitizedPath;
if (
path.isAbsolute(finalSanitizedPath) &&
!effectiveOptions.allowAbsolute
) {
throw new Error(
"Path resolved to absolute outside root when absolute paths are disallowed.",
);
}
} else {
if (path.isAbsolute(normalized)) {
if (!effectiveOptions.allowAbsolute) {
finalSanitizedPath = normalized.replace(
/^(?:[A-Za-z]:)?[/\\]+/,
"",
);
convertedToRelative = true;
} else {
finalSanitizedPath = normalized;
}
} else {
const resolvedAgainstCwd = path.resolve(normalized);
const currentWorkingDir = path.resolve(".");
if (
!resolvedAgainstCwd.startsWith(currentWorkingDir + path.sep) &&
resolvedAgainstCwd !== currentWorkingDir
) {
throw new Error(
"Relative path traversal detected (escapes current working directory context).",
);
}
finalSanitizedPath = normalized;
}
}
return {
sanitizedPath: finalSanitizedPath,
originalInput,
wasAbsolute: wasAbsoluteInitially,
convertedToRelative:
wasAbsoluteInitially &&
!path.isAbsolute(finalSanitizedPath) &&
!effectiveOptions.allowAbsolute,
optionsUsed: effectiveOptions,
};
} catch (error) {
logger.warning(
"Path sanitization error",
requestContextService.createRequestContext({
operation: "Sanitization.sanitizePath.error",
originalPathInput: originalInput,
pathOptionsUsed: effectiveOptions,
errorMessage: error instanceof Error ? error.message : String(error),
}),
);
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
error instanceof Error
? error.message
: "Invalid or unsafe path provided.",
{ input: originalInput },
);
}
}
/**
* Sanitizes a JSON string by parsing it to validate its format.
* Optionally checks if the JSON string exceeds a maximum allowed size.
* @template T The expected type of the parsed JSON object. Defaults to `unknown`.
* @param input - The JSON string to sanitize/validate.
* @param maxSize - Optional maximum allowed size of the JSON string in bytes.
* @returns The parsed JavaScript object.
* @throws {McpError} If input is not a string, too large, or invalid JSON.
*/
public sanitizeJson<T = unknown>(input: string, maxSize?: number): T {
try {
if (typeof input !== "string")
throw new Error("Invalid input: expected a JSON string.");
if (maxSize !== undefined && Buffer.byteLength(input, "utf8") > maxSize) {
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
`JSON string exceeds maximum allowed size of ${maxSize} bytes.`,
{ actualSize: Buffer.byteLength(input, "utf8"), maxSize },
);
}
return JSON.parse(input) as T;
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
error instanceof Error ? error.message : "Invalid JSON format.",
{
inputPreview:
input.length > 100 ? `${input.substring(0, 100)}...` : input,
},
);
}
}
/**
* Validates and sanitizes a numeric input, converting strings to numbers.
* Clamps the number to `min`/`max` if provided.
* @param input - The number or string to validate and sanitize.
* @param min - Minimum allowed value (inclusive).
* @param max - Maximum allowed value (inclusive).
* @returns The sanitized (and potentially clamped) number.
* @throws {McpError} If input is not a valid number, NaN, or Infinity.
*/
public sanitizeNumber(
input: number | string,
min?: number,
max?: number,
): number {
let value: number;
if (typeof input === "string") {
const trimmedInput = input.trim();
if (trimmedInput === "" || !validator.isNumeric(trimmedInput)) {
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
"Invalid number format: input is empty or not numeric.",
{ input },
);
}
value = parseFloat(trimmedInput);
} else if (typeof input === "number") {
value = input;
} else {
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
"Invalid input type: expected number or string.",
{ input: String(input) },
);
}
if (isNaN(value) || !isFinite(value)) {
throw new McpError(
BaseErrorCode.VALIDATION_ERROR,
"Invalid number value (NaN or Infinity).",
{ input },
);
}
let clamped = false;
let originalValueForLog = value;
if (min !== undefined && value < min) {
value = min;
clamped = true;
}
if (max !== undefined && value > max) {
value = max;
clamped = true;
}
if (clamped) {
logger.debug(
"Number clamped to range.",
requestContextService.createRequestContext({
operation: "Sanitization.sanitizeNumber.clamped",
originalInput: String(input),
parsedValue: originalValueForLog,
minValue: min,
maxValue: max,
clampedValue: value,
}),
);
}
return value;
}
/**
* Sanitizes input for logging by redacting sensitive fields.
* Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
* (case-insensitive substring match) with "[REDACTED]".
* @param input - The input data to sanitize for logging.
* @returns A sanitized (deep cloned) version of the input, safe for logging.
* Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
*/
public sanitizeForLogging(input: unknown): unknown {
try {
if (!input || typeof input !== "object") return input;
const clonedInput =
typeof structuredClone === "function"
? structuredClone(input)
: JSON.parse(JSON.stringify(input));
this.redactSensitiveFields(clonedInput);
return clonedInput;
} catch (error) {
logger.error(
"Error during log sanitization, returning placeholder.",
requestContextService.createRequestContext({
operation: "Sanitization.sanitizeForLogging.error",
errorMessage: error instanceof Error ? error.message : String(error),
}),
);
return "[Log Sanitization Failed]";
}
}
/**
* Recursively redacts sensitive fields in an object or array in place.
* @param obj - The object or array to redact.
* @private
*/
private redactSensitiveFields(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
obj.forEach((item) => this.redactSensitiveFields(item));
return;
}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = (obj as Record<string, unknown>)[key];
const lowerKey = key.toLowerCase();
const isSensitive = this.sensitiveFields.some((field) =>
lowerKey.includes(field),
);
if (isSensitive) {
(obj as Record<string, unknown>)[key] = "[REDACTED]";
} else if (value && typeof value === "object") {
this.redactSensitiveFields(value);
}
}
}
}
}
/**
* Singleton instance of the `Sanitization` class.
* Use this for all input sanitization tasks.
*/
export const sanitization = Sanitization.getInstance();
/**
* Convenience function calling `sanitization.sanitizeForLogging`.
* @param input - The input data to sanitize.
* @returns A sanitized version of the input, safe for logging.
*/
export const sanitizeInputForLogging = (input: unknown): unknown =>
sanitization.sanitizeForLogging(input);
```
--------------------------------------------------------------------------------
/src/mcp-server/transports/httpTransport.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Handles the setup and management of the Streamable HTTP MCP transport.
* Implements the MCP Specification 2025-03-26 for Streamable HTTP.
* This includes creating an Express server, configuring middleware (CORS, Authentication),
* defining request routing for the single MCP endpoint (POST/GET/DELETE),
* managing server-side sessions, handling Server-Sent Events (SSE) for streaming,
* and binding to a network port with retry logic for port conflicts.
*
* Specification Reference:
* https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
* @module src/mcp-server/transports/httpTransport
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import express, { NextFunction, Request, Response } from "express";
import http from "http";
import { randomUUID } from "node:crypto";
import { config } from "../../config/index.js";
import {
logger,
requestContextService,
} from "../../utils/internal/index.js"; // Corrected path
import { RequestContext } from "../../utils/internal/requestContext.js"; // Explicit path for RequestContext
import { mcpAuthMiddleware } from "./authentication/authMiddleware.js";
/**
* The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
* Defaults to 3010 if not specified (default is managed by the config module).
* @constant {number} HTTP_PORT
* @private
*/
const HTTP_PORT = config.mcpHttpPort;
/**
* The host address for the HTTP transport, configured via `MCP_HTTP_HOST` environment variable.
* Defaults to '127.0.0.1' if not specified (default is managed by the config module).
* MCP Spec Security Note: Recommends binding to localhost for local servers to minimize exposure.
* @private
*/
const HTTP_HOST = config.mcpHttpHost;
/**
* The single HTTP endpoint path for all MCP communication, as required by the MCP specification.
* This endpoint supports POST, GET, DELETE, and OPTIONS methods.
* @constant {string} MCP_ENDPOINT_PATH
* @private
*/
const MCP_ENDPOINT_PATH = "/mcp";
/**
* Maximum number of attempts to find an available port if the initial `HTTP_PORT` is in use.
* The server will try ports sequentially: `HTTP_PORT`, `HTTP_PORT + 1`, ..., up to `MAX_PORT_RETRIES`.
* @constant {number} MAX_PORT_RETRIES
* @private
*/
const MAX_PORT_RETRIES = 15;
/**
* Stores active `StreamableHTTPServerTransport` instances from the SDK, keyed by their session ID.
* This is essential for routing subsequent HTTP requests (GET, DELETE, non-initialize POST)
* to the correct stateful session transport instance.
* @type {Record<string, StreamableHTTPServerTransport>}
* @private
*/
const httpTransports: Record<string, StreamableHTTPServerTransport> = {};
/**
* Checks if an incoming HTTP request's `Origin` header is permissible based on configuration.
* MCP Spec Security: Servers MUST validate the `Origin` header for cross-origin requests.
* This function checks the request's origin against the `config.mcpAllowedOrigins` list.
* If the server is bound to localhost, requests from localhost or with no/null origin are also permitted.
* Sets appropriate CORS headers (`Access-Control-Allow-Origin`, etc.) if the origin is allowed.
*
* @param req - The Express request object.
* @param res - The Express response object.
* @returns True if the origin is allowed, false otherwise.
* @private
*/
function isOriginAllowed(req: Request, res: Response): boolean {
const origin = req.headers.origin;
const host = req.hostname;
const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(host);
const allowedOrigins = config.mcpAllowedOrigins || [];
const context = requestContextService.createRequestContext({
operation: "isOriginAllowed",
origin,
host,
isLocalhostBinding,
allowedOrigins,
});
logger.debug("Checking origin allowance", context);
const allowed =
(origin && allowedOrigins.includes(origin)) ||
(isLocalhostBinding && (!origin || origin === "null"));
if (allowed && origin) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization",
);
res.setHeader("Access-Control-Allow-Credentials", "true");
} else if (!allowed && origin) {
logger.warning(`Origin denied: ${origin}`, context);
}
logger.debug(`Origin check result: ${allowed}`, { ...context, allowed });
return allowed;
}
/**
* Proactively checks if a specific network port is already in use.
* @param port - The port number to check.
* @param host - The host address to check the port on.
* @param parentContext - Logging context from the caller.
* @returns A promise that resolves to `true` if the port is in use, or `false` otherwise.
* @private
*/
async function isPortInUse(
port: number,
host: string,
parentContext: RequestContext,
): Promise<boolean> {
const checkContext = requestContextService.createRequestContext({
...parentContext,
operation: "isPortInUse",
port,
host,
});
logger.debug(`Proactively checking port usability...`, checkContext);
return new Promise((resolve) => {
const tempServer = http.createServer();
tempServer
.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.debug(
`Proactive check: Port confirmed in use (EADDRINUSE).`,
checkContext,
);
resolve(true);
} else {
logger.debug(
`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`,
{ ...checkContext, errorCode: err.code },
);
resolve(false);
}
})
.once("listening", () => {
logger.debug(`Proactive check: Port is available.`, checkContext);
tempServer.close(() => resolve(false));
})
.listen(port, host);
});
}
/**
* Attempts to start the HTTP server, retrying on incrementing ports if `EADDRINUSE` occurs.
*
* @param serverInstance - The Node.js HTTP server instance.
* @param initialPort - The initial port number to try.
* @param host - The host address to bind to.
* @param maxRetries - Maximum number of additional ports to attempt.
* @param parentContext - Logging context from the caller.
* @returns A promise that resolves with the port number the server successfully bound to.
* @throws {Error} If binding fails after all retries or for a non-EADDRINUSE error.
* @private
*/
function startHttpServerWithRetry(
serverInstance: http.Server,
initialPort: number,
host: string,
maxRetries: number,
parentContext: RequestContext,
): Promise<number> {
const startContext = requestContextService.createRequestContext({
...parentContext,
operation: "startHttpServerWithRetry",
initialPort,
host,
maxRetries,
});
logger.debug(`Attempting to start HTTP server...`, startContext);
return new Promise(async (resolve, reject) => {
let lastError: Error | null = null;
for (let i = 0; i <= maxRetries; i++) {
const currentPort = initialPort + i;
const attemptContext = requestContextService.createRequestContext({
...startContext,
port: currentPort,
attempt: i + 1,
maxAttempts: maxRetries + 1,
});
logger.debug(
`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`,
attemptContext,
);
if (await isPortInUse(currentPort, host, attemptContext)) {
logger.warning(
`Proactive check detected port ${currentPort} is in use, retrying...`,
attemptContext,
);
lastError = new Error(
`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`,
);
await new Promise((res) => setTimeout(res, 100));
continue;
}
try {
await new Promise<void>((listenResolve, listenReject) => {
serverInstance
.listen(currentPort, host, () => {
const serverAddress = `http://${host}:${currentPort}${MCP_ENDPOINT_PATH}`;
logger.info(
`HTTP transport successfully listening on host ${host} at ${serverAddress}`,
{ ...attemptContext, address: serverAddress },
);
listenResolve();
})
.on("error", (err: NodeJS.ErrnoException) => {
listenReject(err);
});
});
resolve(currentPort);
return;
} catch (err: any) {
lastError = err;
logger.debug(
`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`,
{ ...attemptContext, errorCode: err.code, errorMessage: err.message },
);
if (err.code === "EADDRINUSE") {
logger.warning(
`Port ${currentPort} already in use (EADDRINUSE), retrying...`,
attemptContext,
);
await new Promise((res) => setTimeout(res, 100));
} else {
logger.error(
`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`,
{ ...attemptContext, error: err.message },
);
reject(err);
return;
}
}
}
logger.error(
`Failed to bind to any port after ${maxRetries + 1} attempts. Last error: ${lastError?.message}`,
{ ...startContext, error: lastError?.message },
);
reject(
lastError ||
new Error("Failed to bind to any port after multiple retries."),
);
});
}
/**
* Sets up and starts the Streamable HTTP transport layer for the MCP server.
*
* @param createServerInstanceFn - An asynchronous factory function that returns a new `McpServer` instance.
* @param parentContext - Logging context from the main server startup process.
* @returns A promise that resolves when the HTTP server is successfully listening.
* @throws {Error} If the server fails to start after all port retries.
*/
export async function startHttpTransport(
createServerInstanceFn: () => Promise<McpServer>,
parentContext: RequestContext,
): Promise<void> {
const app = express();
const transportContext = requestContextService.createRequestContext({
...parentContext,
transportType: "HTTP",
component: "HttpTransportSetup",
});
logger.debug(
"Setting up Express app for HTTP transport...",
transportContext,
);
app.use(express.json());
app.options(MCP_ENDPOINT_PATH, (req, res) => {
const optionsContext = requestContextService.createRequestContext({
...transportContext,
operation: "handleOptions",
origin: req.headers.origin,
method: req.method,
path: req.path,
});
logger.debug(
`Received OPTIONS request for ${MCP_ENDPOINT_PATH}`,
optionsContext,
);
if (isOriginAllowed(req, res)) {
logger.debug(
"OPTIONS request origin allowed, sending 204.",
optionsContext,
);
res.sendStatus(204);
} else {
logger.debug(
"OPTIONS request origin denied, sending 403.",
optionsContext,
);
res.status(403).send("Forbidden: Invalid Origin");
}
});
app.use((req: Request, res: Response, next: NextFunction) => {
const securityContext = requestContextService.createRequestContext({
...transportContext,
operation: "securityMiddleware",
path: req.path,
method: req.method,
origin: req.headers.origin,
});
logger.debug(`Applying security middleware...`, securityContext);
if (!isOriginAllowed(req, res)) {
logger.debug("Origin check failed, sending 403.", securityContext);
res.status(403).send("Forbidden: Invalid Origin");
return;
}
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
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'",
);
logger.debug("Security middleware passed.", securityContext);
next();
});
app.use(mcpAuthMiddleware);
app.post(MCP_ENDPOINT_PATH, async (req, res) => {
const basePostContext = requestContextService.createRequestContext({
...transportContext,
operation: "handlePost",
method: "POST",
path: req.path,
origin: req.headers.origin,
});
logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, {
...basePostContext,
headers: req.headers,
bodyPreview: JSON.stringify(req.body).substring(0, 100),
});
const sessionId = req.headers["mcp-session-id"] as string | undefined;
logger.debug(`Extracted session ID: ${sessionId}`, {
...basePostContext,
sessionId,
});
let transport = sessionId ? httpTransports[sessionId] : undefined;
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
...basePostContext,
sessionId,
});
const isInitReq = isInitializeRequest(req.body);
logger.debug(`Is InitializeRequest: ${isInitReq}`, {
...basePostContext,
sessionId,
});
const requestId = (req.body as any)?.id || null;
try {
if (isInitReq) {
if (transport) {
logger.warning(
"Received InitializeRequest on an existing session ID. Closing old session and creating new.",
{ ...basePostContext, sessionId },
);
await transport.close();
delete httpTransports[sessionId!];
}
logger.info("Handling Initialize Request: Creating new session...", {
...basePostContext,
sessionId,
});
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => {
const newId = randomUUID();
logger.debug(`Generated new session ID: ${newId}`, basePostContext);
return newId;
},
onsessioninitialized: (newId) => {
logger.debug(
`Session initialized callback triggered for ID: ${newId}`,
{ ...basePostContext, newSessionId: newId },
);
httpTransports[newId] = transport!;
logger.info(`HTTP Session created: ${newId}`, {
...basePostContext,
newSessionId: newId,
});
},
});
transport.onclose = () => {
const closedSessionId = transport!.sessionId;
if (closedSessionId) {
logger.debug(
`onclose handler triggered for session ID: ${closedSessionId}`,
{ ...basePostContext, closedSessionId },
);
delete httpTransports[closedSessionId];
logger.info(`HTTP Session closed: ${closedSessionId}`, {
...basePostContext,
closedSessionId,
});
} else {
logger.debug(
"onclose handler triggered for transport without session ID (likely init failure).",
basePostContext,
);
}
};
logger.debug(
"Creating McpServer instance for new session...",
basePostContext,
);
const server = await createServerInstanceFn();
logger.debug(
"Connecting McpServer to new transport...",
basePostContext,
);
await server.connect(transport);
logger.debug("McpServer connected to transport.", basePostContext);
} else if (!transport) {
logger.warning(
"Invalid or missing session ID for non-initialize POST request.",
{ ...basePostContext, sessionId },
);
res.status(404).json({
jsonrpc: "2.0",
error: { code: -32004, message: "Invalid or expired session ID" },
id: requestId,
});
return;
}
const currentSessionId = transport.sessionId;
logger.debug(
`Processing POST request content for session ${currentSessionId}...`,
{ ...basePostContext, sessionId: currentSessionId, isInitReq },
);
await transport.handleRequest(req, res, req.body);
logger.debug(
`Finished processing POST request content for session ${currentSessionId}.`,
{ ...basePostContext, sessionId: currentSessionId },
);
} catch (err) {
const errorSessionId = transport?.sessionId || sessionId;
logger.error("Error handling POST request", {
...basePostContext,
sessionId: errorSessionId,
isInitReq,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error during POST handling",
},
id: requestId,
});
}
if (isInitReq && transport && !transport.sessionId) {
logger.debug("Cleaning up transport after initialization failure.", {
...basePostContext,
sessionId: errorSessionId,
});
await transport.close().catch((closeErr) =>
logger.error("Error closing transport after init failure", {
...basePostContext,
sessionId: errorSessionId,
closeError: closeErr,
}),
);
}
}
});
const handleSessionReq = async (req: Request, res: Response) => {
const method = req.method;
const baseSessionReqContext = requestContextService.createRequestContext({
...transportContext,
operation: `handle${method}`,
method,
path: req.path,
origin: req.headers.origin,
});
logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, {
...baseSessionReqContext,
headers: req.headers,
});
const sessionId = req.headers["mcp-session-id"] as string | undefined;
logger.debug(`Extracted session ID: ${sessionId}`, {
...baseSessionReqContext,
sessionId,
});
const transport = sessionId ? httpTransports[sessionId] : undefined;
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
...baseSessionReqContext,
sessionId,
});
if (!transport) {
logger.warning(`Session not found for ${method} request`, {
...baseSessionReqContext,
sessionId,
});
res.status(404).json({
jsonrpc: "2.0",
error: { code: -32004, message: "Session not found or expired" },
id: null, // Or a relevant request identifier if available from context
});
return;
}
try {
logger.debug(
`Delegating ${method} request to transport for session ${sessionId}...`,
{ ...baseSessionReqContext, sessionId },
);
await transport.handleRequest(req, res);
logger.info(
`Successfully handled ${method} request for session ${sessionId}`,
{ ...baseSessionReqContext, sessionId },
);
} catch (err) {
logger.error(
`Error handling ${method} request for session ${sessionId}`,
{
...baseSessionReqContext,
sessionId,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
},
);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal Server Error" },
id: null, // Or a relevant request identifier
});
}
}
};
app.get(MCP_ENDPOINT_PATH, handleSessionReq);
app.delete(MCP_ENDPOINT_PATH, handleSessionReq);
logger.debug("Creating HTTP server instance...", transportContext);
const serverInstance = http.createServer(app);
try {
logger.debug(
"Attempting to start HTTP server with retry logic...",
transportContext,
);
const actualPort = await startHttpServerWithRetry(
serverInstance,
config.mcpHttpPort,
config.mcpHttpHost,
MAX_PORT_RETRIES,
transportContext,
);
let serverAddressLog = `http://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
let productionNote = "";
if (config.environment === "production") {
// The server itself runs HTTP, but it's expected to be behind an HTTPS proxy in production.
// The log reflects the effective public-facing URL.
serverAddressLog = `https://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
productionNote = ` (via HTTPS, ensure reverse proxy is configured)`;
}
if (process.stdout.isTTY) {
console.log(
`\n🚀 MCP Server running in HTTP mode at: ${serverAddressLog}${productionNote}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`,
);
}
} catch (err) {
logger.fatal("HTTP server failed to start after multiple port retries.", {
...transportContext,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
```