This is page 2 of 2. Use http://codebase.md/cyanheads/filesystem-mcp-server?lines=true&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/mcp-server/transports/authentication/authMiddleware.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT).
3 | *
4 | * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
5 | * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
6 | * It verifies the token's signature and expiration using the secret key defined
7 | * in the configuration (`config.mcpAuthSecretKey`).
8 | *
9 | * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type
10 | * (expected to contain `token`, `clientId`, and `scopes`) is attached to `req.auth`.
11 | * If the token is missing, invalid, or expired, it sends an HTTP 401 Unauthorized response.
12 | *
13 | * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
14 | * @module src/mcp-server/transports/authentication/authMiddleware
15 | */
16 |
17 | import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; // Import from SDK
18 | import { NextFunction, Request, Response } from "express";
19 | import jwt from "jsonwebtoken";
20 | import { config, environment } from "../../../config/index.js";
21 | import { logger, requestContextService } from "../../../utils/index.js";
22 |
23 | // Extend the Express Request interface to include the optional 'auth' property
24 | // using the imported AuthInfo type from the SDK.
25 | declare global {
26 | // eslint-disable-next-line @typescript-eslint/no-namespace
27 | namespace Express {
28 | interface Request {
29 | /** Authentication information derived from the JWT, conforming to MCP SDK's AuthInfo. */
30 | auth?: AuthInfo;
31 | }
32 | }
33 | }
34 |
35 | // Startup Validation: Validate secret key presence on module load.
36 | if (environment === "production" && !config.mcpAuthSecretKey) {
37 | const error = new Error(
38 | "CRITICAL: MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication."
39 | );
40 | logger.fatal(
41 | "CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.",
42 | );
43 | // Force process exit in production to prevent insecure startup
44 | process.exit(1);
45 | } else if (!config.mcpAuthSecretKey) {
46 | logger.warning(
47 | "MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.",
48 | );
49 | }
50 |
51 | /**
52 | * Express middleware for verifying JWT Bearer token authentication.
53 | */
54 | export function mcpAuthMiddleware(
55 | req: Request,
56 | res: Response,
57 | next: NextFunction,
58 | ): void {
59 | const context = requestContextService.createRequestContext({
60 | operation: "mcpAuthMiddleware",
61 | method: req.method,
62 | path: req.path,
63 | });
64 | logger.debug(
65 | "Running MCP Authentication Middleware (Bearer Token Validation)...",
66 | context,
67 | );
68 |
69 | // Development Mode Bypass
70 | if (!config.mcpAuthSecretKey) {
71 | if (environment !== "production") {
72 | logger.warning(
73 | "Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).",
74 | context,
75 | );
76 | // Populate req.auth strictly according to SDK's AuthInfo
77 | req.auth = {
78 | token: "dev-mode-placeholder-token",
79 | clientId: "dev-client-id",
80 | scopes: ["dev-scope"],
81 | };
82 | // Log dev mode details separately, not attaching to req.auth if not part of AuthInfo
83 | logger.debug("Dev mode auth object created.", {
84 | ...context,
85 | authDetails: req.auth,
86 | });
87 | return next();
88 | } else {
89 | logger.error(
90 | "FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.",
91 | context,
92 | );
93 | res.status(500).json({
94 | error: "Server configuration error: Authentication key missing.",
95 | });
96 | return;
97 | }
98 | }
99 |
100 | const authHeader = req.headers.authorization;
101 | if (!authHeader || !authHeader.startsWith("Bearer ")) {
102 | logger.warning(
103 | "Authentication failed: Missing or malformed Authorization header (Bearer scheme required).",
104 | context,
105 | );
106 | res.status(401).json({
107 | error: "Unauthorized: Missing or invalid authentication token format.",
108 | });
109 | return;
110 | }
111 |
112 | const tokenParts = authHeader.split(" ");
113 | if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) {
114 | logger.warning("Authentication failed: Malformed Bearer token.", context);
115 | res
116 | .status(401)
117 | .json({ error: "Unauthorized: Malformed authentication token." });
118 | return;
119 | }
120 | const rawToken = tokenParts[1];
121 |
122 | try {
123 | const decoded = jwt.verify(rawToken, config.mcpAuthSecretKey);
124 |
125 | if (typeof decoded === "string") {
126 | logger.warning(
127 | "Authentication failed: JWT decoded to a string, expected an object payload.",
128 | context,
129 | );
130 | res
131 | .status(401)
132 | .json({ error: "Unauthorized: Invalid token payload format." });
133 | return;
134 | }
135 |
136 | // Extract and validate fields for SDK's AuthInfo
137 | const clientIdFromToken =
138 | typeof decoded.cid === "string"
139 | ? decoded.cid
140 | : typeof decoded.client_id === "string"
141 | ? decoded.client_id
142 | : undefined;
143 | if (!clientIdFromToken) {
144 | logger.warning(
145 | "Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.",
146 | { ...context, jwtPayloadKeys: Object.keys(decoded) },
147 | );
148 | res.status(401).json({
149 | error: "Unauthorized: Invalid token, missing client identifier.",
150 | });
151 | return;
152 | }
153 |
154 | let scopesFromToken: string[];
155 | if (
156 | Array.isArray(decoded.scp) &&
157 | decoded.scp.every((s: unknown) => typeof s === "string")
158 | ) {
159 | scopesFromToken = decoded.scp as string[];
160 | } else if (
161 | typeof decoded.scope === "string" &&
162 | decoded.scope.trim() !== ""
163 | ) {
164 | scopesFromToken = decoded.scope.split(" ").filter((s: string) => s);
165 | if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") {
166 | // handles case " " -> [""]
167 | scopesFromToken = [decoded.scope.trim()];
168 | } else if (scopesFromToken.length === 0 && decoded.scope.trim() === "") {
169 | // If scope is an empty string, treat as no scopes rather than erroring, or use a default.
170 | // Depending on strictness, could also error here. For now, allow empty array if scope was empty string.
171 | logger.debug(
172 | "JWT 'scope' claim was an empty string, resulting in empty scopes array.",
173 | context,
174 | );
175 | }
176 | } else {
177 | // If scopes are strictly mandatory and not found or invalid format
178 | logger.warning(
179 | "Authentication failed: JWT 'scp' or 'scope' claim is missing, not an array of strings, or not a valid space-separated string. Assigning default empty array.",
180 | { ...context, jwtPayloadKeys: Object.keys(decoded) },
181 | );
182 | scopesFromToken = []; // Default to empty array if scopes are mandatory but not found/invalid
183 | // Or, if truly mandatory and must be non-empty:
184 | // res.status(401).json({ error: "Unauthorized: Invalid token, missing or invalid scopes." });
185 | // return;
186 | }
187 |
188 | // Construct req.auth with only the properties defined in SDK's AuthInfo
189 | // All other claims from 'decoded' are not part of req.auth for type safety.
190 | req.auth = {
191 | token: rawToken,
192 | clientId: clientIdFromToken,
193 | scopes: scopesFromToken,
194 | };
195 |
196 | // Log separately if other JWT claims like 'sub' (sessionId) are needed for app logic
197 | const subClaimForLogging =
198 | typeof decoded.sub === "string" ? decoded.sub : undefined;
199 | logger.debug("JWT verified successfully. AuthInfo attached to request.", {
200 | ...context,
201 | mcpSessionIdContext: subClaimForLogging,
202 | clientId: req.auth.clientId,
203 | scopes: req.auth.scopes,
204 | });
205 | next();
206 | } catch (error: unknown) {
207 | let errorMessage = "Invalid token";
208 | if (error instanceof jwt.TokenExpiredError) {
209 | errorMessage = "Token expired";
210 | logger.warning("Authentication failed: Token expired.", {
211 | ...context,
212 | expiredAt: error.expiredAt, // Accessing error.expiredAt safely
213 | });
214 | } else if (error instanceof jwt.JsonWebTokenError) { // This already implies error is an instance of Error
215 | errorMessage = `Invalid token: ${error.message}`; // Accessing error.message safely
216 | logger.warning(`Authentication failed: ${errorMessage}`, { ...context });
217 | } else if (error instanceof Error) { // Catch other generic Errors
218 | errorMessage = `Verification error: ${error.message}`; // Accessing error.message safely
219 | logger.error(
220 | "Authentication failed: Unexpected error during token verification.",
221 | { ...context, error: error.message },
222 | );
223 | } else { // Handle truly unknown types
224 | errorMessage = "Unknown verification error";
225 | logger.error(
226 | "Authentication failed: Unexpected non-error exception during token verification.",
227 | { ...context, error: String(error) }, // Convert unknown error to string for logging
228 | );
229 | }
230 | res.status(401).json({ error: `Unauthorized: ${errorMessage}.` });
231 | }
232 | }
233 |
```
--------------------------------------------------------------------------------
/src/mcp-server/tools/updateFile/updateFileLogic.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs/promises';
2 | import { z } from 'zod';
3 | import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
4 | import { logger } from '../../../utils/internal/logger.js';
5 | import { RequestContext } from '../../../utils/internal/requestContext.js';
6 | import { serverState } from '../../state.js';
7 |
8 | // Define the structure for a single search/replace block
9 | const DiffBlockSchema = z.object({
10 | search: z.string().min(1, 'Search pattern cannot be empty'),
11 | replace: z.string(), // Allow empty replace string for deletions
12 | });
13 |
14 | // Define the input schema using Zod for validation
15 | export const UpdateFileInputSchema = z.object({
16 | path: z.string().min(1, 'Path cannot be empty')
17 | .describe('The path to the file to update. Can be relative or absolute (resolved like readFile). The file must exist.'),
18 | blocks: z.array(DiffBlockSchema).min(1, 'At least one search/replace block is required.')
19 | .describe('An array of objects, each with a `search` (string) and `replace` (string) property.'),
20 | useRegex: z.boolean().default(false)
21 | .describe('If true, treat the `search` field of each block as a JavaScript regular expression pattern. Defaults to false (exact string matching).'),
22 | replaceAll: z.boolean().default(false)
23 | .describe('If true, replace all occurrences matching the SEARCH criteria within the file. If false, only replace the first occurrence. Defaults to false.'),
24 | });
25 |
26 | // Define the TypeScript type for the input
27 | export type UpdateFileInput = z.infer<typeof UpdateFileInputSchema>;
28 |
29 | // Define the TypeScript type for a single block based on the schema, adding internal tracking
30 | export type DiffBlock = z.infer<typeof DiffBlockSchema> & { applied?: boolean };
31 |
32 | // Define the TypeScript type for the output
33 | export interface UpdateFileOutput {
34 | message: string;
35 | updatedPath: string;
36 | blocksApplied: number;
37 | blocksFailed: number; // Track blocks that didn't find a match
38 | }
39 |
40 | /**
41 | * Applies an array of search/replace blocks sequentially to the file content.
42 | *
43 | * @param {UpdateFileInput} input - The input object containing path, blocks, and options.
44 | * @param {RequestContext} context - The request context.
45 | * @returns {Promise<UpdateFileOutput>} A promise resolving with update status.
46 | * @throws {McpError} For path errors, file not found, I/O errors, or invalid regex patterns.
47 | */
48 | export const updateFileLogic = async (input: UpdateFileInput, context: RequestContext): Promise<UpdateFileOutput> => {
49 | // Destructure validated input
50 | const { path: requestedPath, blocks: inputBlocks, useRegex, replaceAll } = input;
51 | const logicContext = { ...context, useRegex, replaceAll };
52 | logger.debug(`updateFileLogic: Received request for path "${requestedPath}" with ${inputBlocks.length} blocks`, logicContext);
53 |
54 | // Resolve the path
55 | const absolutePath = serverState.resolvePath(requestedPath, context);
56 | logger.debug(`updateFileLogic: Resolved path to "${absolutePath}"`, { ...context, requestedPath });
57 |
58 | try {
59 | // 1. Read the existing file content
60 | let currentContent: string;
61 | try {
62 | currentContent = await fs.readFile(absolutePath, 'utf8');
63 | logger.debug(`updateFileLogic: Successfully read existing file "${absolutePath}"`, { ...context, requestedPath });
64 | } catch (readError: any) {
65 | if (readError.code === 'ENOENT') {
66 | logger.warning(`updateFileLogic: File not found at "${absolutePath}"`, { ...context, requestedPath });
67 | throw new McpError(BaseErrorCode.NOT_FOUND, `File not found at path: ${absolutePath}. Cannot update a non-existent file.`, { ...context, requestedPath, resolvedPath: absolutePath, originalError: readError });
68 | }
69 | throw readError; // Re-throw other read errors
70 | }
71 |
72 | // 2. Input blocks are already parsed and validated by Zod
73 | const diffBlocks: DiffBlock[] = inputBlocks.map(block => ({ ...block, applied: false })); // Add internal 'applied' flag
74 |
75 | // 3. Apply blocks sequentially
76 | let updatedContent = currentContent;
77 | let blocksApplied = 0;
78 | let blocksFailed = 0;
79 | let totalReplacementsMade = 0; // Track individual replacements if replaceAll is true
80 |
81 | for (let i = 0; i < diffBlocks.length; i++) {
82 | const block = diffBlocks[i];
83 | // Create context specific to this block's processing
84 | const blockContext = { ...logicContext, blockIndex: i, searchPreview: block.search.substring(0, 50) };
85 | let blockMadeChange = false;
86 | let replacementsInBlock = 0; // Count replacements made by *this specific block*
87 |
88 | try {
89 | if (useRegex) {
90 | // Treat search as regex pattern
91 | // Create the regex. Add 'g' flag if replaceAll is true.
92 | const regex = new RegExp(block.search, replaceAll ? 'g' : '');
93 | const matches = updatedContent.match(regex); // Find matches before replacing
94 |
95 | if (matches && matches.length > 0) {
96 | updatedContent = updatedContent.replace(regex, block.replace);
97 | replacementsInBlock = matches.length; // Count actual matches found
98 | blockMadeChange = true;
99 | logger.debug(`Applied regex block`, blockContext);
100 | }
101 | } else {
102 | // Treat search as exact string
103 | if (replaceAll) {
104 | let startIndex = 0;
105 | let index;
106 | let replaced = false;
107 | // Use split/join for robust replacement of all occurrences
108 | const parts = updatedContent.split(block.search);
109 | if (parts.length > 1) { // Check if the search string was found at all
110 | updatedContent = parts.join(block.replace);
111 | replacementsInBlock = parts.length - 1; // Number of replacements is one less than the number of parts
112 | replaced = true;
113 | }
114 |
115 | if (replaced) {
116 | blockMadeChange = true;
117 | logger.debug(`Applied string block (replaceAll=true)`, blockContext);
118 | }
119 | } else {
120 | // Replace only the first occurrence
121 | const index = updatedContent.indexOf(block.search);
122 | if (index !== -1) {
123 | updatedContent = updatedContent.substring(0, index) + block.replace + updatedContent.substring(index + block.search.length);
124 | replacementsInBlock = 1;
125 | blockMadeChange = true;
126 | logger.debug(`Applied string block (replaceAll=false)`, blockContext);
127 | }
128 | }
129 | }
130 | } catch (regexError: any) {
131 | if (regexError instanceof SyntaxError && useRegex) {
132 | logger.error('Invalid regex pattern provided in SEARCH block', { ...blockContext, error: regexError.message });
133 | throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid regular expression pattern in block ${i + 1}: "${block.search}". Error: ${regexError.message}`, blockContext);
134 | }
135 | // Re-throw other unexpected errors during replacement
136 | logger.error('Unexpected error during replacement operation', { ...blockContext, error: regexError.message });
137 | throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Error processing block ${i + 1}: ${regexError.message}`, blockContext);
138 | }
139 |
140 |
141 | if (blockMadeChange) {
142 | block.applied = true; // Mark the block as having made a change
143 | blocksApplied++;
144 | totalReplacementsMade += replacementsInBlock; // Add replacements from this block to total
145 | } else {
146 | blocksFailed++;
147 | logger.warning(`Diff block search criteria not found`, blockContext);
148 | }
149 | }
150 |
151 | // 4. Write the updated content back to the file only if changes were actually made
152 | if (totalReplacementsMade > 0) { // Check if any replacement occurred across all blocks
153 | logger.debug(`updateFileLogic: Writing updated content back to "${absolutePath}"`, logicContext);
154 | await fs.writeFile(absolutePath, updatedContent, 'utf8');
155 | logger.info(`updateFileLogic: Successfully updated file "${absolutePath}"`, { ...logicContext, requestedPath, blocksApplied, blocksFailed, totalReplacementsMade });
156 | const replaceMsg = `Made ${totalReplacementsMade} replacement(s) across ${blocksApplied} block(s).`;
157 | return {
158 | message: `Successfully updated file ${absolutePath}. ${replaceMsg} ${blocksFailed} block(s) failed (search criteria not found).`,
159 | updatedPath: absolutePath,
160 | blocksApplied,
161 | blocksFailed,
162 | };
163 | } else {
164 | // No replacements were made, even if blocks were provided
165 | logger.info(`updateFileLogic: No replacements made in file "${absolutePath}"`, { ...logicContext, requestedPath, blocksFailed });
166 | return {
167 | message: `No changes applied to file ${absolutePath}. ${blocksFailed} block(s) failed (search criteria not found).`,
168 | updatedPath: absolutePath,
169 | blocksApplied: 0, // No blocks resulted in a change
170 | blocksFailed,
171 | };
172 | }
173 |
174 | } catch (error: any) {
175 | logger.error(`updateFileLogic: Error updating file "${absolutePath}"`, { ...logicContext, requestedPath, error: error.message, code: error.code });
176 | if (error instanceof McpError) {
177 | throw error; // Re-throw known McpErrors
178 | }
179 | // Handle potential I/O errors during read or write
180 | throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to update file: ${error.message || 'Unknown I/O error'}`, { ...context, requestedPath, resolvedPath: absolutePath, originalError: error });
181 | }
182 | };
183 |
```
--------------------------------------------------------------------------------
/src/utils/security/idGenerator.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Provides a utility class `IdGenerator` for creating customizable, prefixed unique identifiers,
3 | * and a standalone `generateUUID` function for generating standard UUIDs.
4 | * The `IdGenerator` supports entity-specific prefixes, custom character sets, and lengths.
5 | *
6 | * Note: Logging has been removed from this module to prevent circular dependencies
7 | * with the `requestContextService`, which itself uses `generateUUID` from this module.
8 | * This was causing `ReferenceError: Cannot access 'generateUUID' before initialization`
9 | * during application startup.
10 | * @module src/utils/security/idGenerator
11 | */
12 | import { randomUUID as cryptoRandomUUID, randomBytes } from "crypto";
13 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
14 | // Removed: import { logger, requestContextService } from "../index.js";
15 |
16 | /**
17 | * Defines the structure for configuring entity prefixes.
18 | * Keys are entity type names (e.g., "project", "task"), and values are their corresponding ID prefixes (e.g., "PROJ", "TASK").
19 | */
20 | export interface EntityPrefixConfig {
21 | [key: string]: string;
22 | }
23 |
24 | /**
25 | * Defines options for customizing ID generation.
26 | */
27 | export interface IdGenerationOptions {
28 | length?: number;
29 | separator?: string;
30 | charset?: string;
31 | }
32 |
33 | /**
34 | * A generic ID Generator class for creating and managing unique, prefixed identifiers.
35 | * Allows defining custom prefixes, generating random strings, and validating/normalizing IDs.
36 | */
37 | export class IdGenerator {
38 | /**
39 | * Default character set for the random part of the ID.
40 | * @private
41 | */
42 | private static DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
43 | /**
44 | * Default separator character between prefix and random part.
45 | * @private
46 | */
47 | private static DEFAULT_SEPARATOR = "_";
48 | /**
49 | * Default length for the random part of the ID.
50 | * @private
51 | */
52 | private static DEFAULT_LENGTH = 6;
53 |
54 | /**
55 | * Stores the mapping of entity types to their prefixes.
56 | * @private
57 | */
58 | private entityPrefixes: EntityPrefixConfig = {};
59 | /**
60 | * Stores a reverse mapping from prefixes (case-insensitive) to entity types.
61 | * @private
62 | */
63 | private prefixToEntityType: Record<string, string> = {};
64 |
65 | /**
66 | * Constructs an `IdGenerator` instance.
67 | * @param entityPrefixes - An initial map of entity types to their prefixes.
68 | */
69 | constructor(entityPrefixes: EntityPrefixConfig = {}) {
70 | // Logging removed to prevent circular dependency with requestContextService.
71 | this.setEntityPrefixes(entityPrefixes);
72 | }
73 |
74 | /**
75 | * Sets or updates the entity prefix configuration and rebuilds the internal reverse lookup map.
76 | * @param entityPrefixes - A map where keys are entity type names and values are their desired ID prefixes.
77 | */
78 | public setEntityPrefixes(entityPrefixes: EntityPrefixConfig): void {
79 | // Logging removed.
80 | this.entityPrefixes = { ...entityPrefixes };
81 |
82 | this.prefixToEntityType = Object.entries(this.entityPrefixes).reduce(
83 | (acc, [type, prefix]) => {
84 | acc[prefix.toLowerCase()] = type; // Store lowercase for case-insensitive lookup
85 | return acc;
86 | },
87 | {} as Record<string, string>,
88 | );
89 | }
90 |
91 | /**
92 | * Retrieves a copy of the current entity prefix configuration.
93 | * @returns The current entity prefix configuration.
94 | */
95 | public getEntityPrefixes(): EntityPrefixConfig {
96 | return { ...this.entityPrefixes };
97 | }
98 |
99 | /**
100 | * Generates a cryptographically secure random string.
101 | * @param length - The desired length of the random string. Defaults to `IdGenerator.DEFAULT_LENGTH`.
102 | * @param charset - The character set to use. Defaults to `IdGenerator.DEFAULT_CHARSET`.
103 | * @returns The generated random string.
104 | */
105 | public generateRandomString(
106 | length: number = IdGenerator.DEFAULT_LENGTH,
107 | charset: string = IdGenerator.DEFAULT_CHARSET,
108 | ): string {
109 | const bytes = randomBytes(length);
110 | let result = "";
111 | for (let i = 0; i < length; i++) {
112 | result += charset[bytes[i] % charset.length];
113 | }
114 | return result;
115 | }
116 |
117 | /**
118 | * Generates a unique ID, optionally prepended with a prefix.
119 | * @param prefix - An optional prefix for the ID.
120 | * @param options - Optional parameters for ID generation (length, separator, charset).
121 | * @returns A unique identifier string.
122 | */
123 | public generate(prefix?: string, options: IdGenerationOptions = {}): string {
124 | // Logging removed.
125 | const {
126 | length = IdGenerator.DEFAULT_LENGTH,
127 | separator = IdGenerator.DEFAULT_SEPARATOR,
128 | charset = IdGenerator.DEFAULT_CHARSET,
129 | } = options;
130 |
131 | const randomPart = this.generateRandomString(length, charset);
132 | const generatedId = prefix
133 | ? `${prefix}${separator}${randomPart}`
134 | : randomPart;
135 | return generatedId;
136 | }
137 |
138 | /**
139 | * Generates a unique ID for a specified entity type, using its configured prefix.
140 | * @param entityType - The type of entity (must be registered).
141 | * @param options - Optional parameters for ID generation.
142 | * @returns A unique identifier string for the entity (e.g., "PROJ_A6B3J0").
143 | * @throws {McpError} If the `entityType` is not registered.
144 | */
145 | public generateForEntity(
146 | entityType: string,
147 | options: IdGenerationOptions = {},
148 | ): string {
149 | const prefix = this.entityPrefixes[entityType];
150 | if (!prefix) {
151 | throw new McpError(
152 | BaseErrorCode.VALIDATION_ERROR,
153 | `Unknown entity type: ${entityType}. No prefix registered.`,
154 | );
155 | }
156 | return this.generate(prefix, options);
157 | }
158 |
159 | /**
160 | * Validates if an ID conforms to the expected format for a specific entity type.
161 | * @param id - The ID string to validate.
162 | * @param entityType - The expected entity type of the ID.
163 | * @param options - Optional parameters used during generation for validation consistency.
164 | * @returns `true` if the ID is valid, `false` otherwise.
165 | */
166 | public isValid(
167 | id: string,
168 | entityType: string,
169 | options: IdGenerationOptions = {},
170 | ): boolean {
171 | const prefix = this.entityPrefixes[entityType];
172 | const {
173 | length = IdGenerator.DEFAULT_LENGTH,
174 | separator = IdGenerator.DEFAULT_SEPARATOR,
175 | } = options;
176 |
177 | if (!prefix) {
178 | return false;
179 | }
180 | // Assumes default charset characters (uppercase letters and digits) for regex.
181 | const pattern = new RegExp(
182 | `^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}[A-Z0-9]{${length}}$`,
183 | );
184 | return pattern.test(id);
185 | }
186 |
187 | /**
188 | * Escapes special characters in a string for use in a regular expression.
189 | * @param str - The string to escape.
190 | * @returns The escaped string.
191 | * @private
192 | */
193 | private escapeRegex(str: string): string {
194 | return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195 | }
196 |
197 | /**
198 | * Strips the prefix and separator from an ID string.
199 | * @param id - The ID string (e.g., "PROJ_A6B3J0").
200 | * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
201 | * @returns The ID part without the prefix, or the original ID if separator not found.
202 | */
203 | public stripPrefix(
204 | id: string,
205 | separator: string = IdGenerator.DEFAULT_SEPARATOR,
206 | ): string {
207 | const parts = id.split(separator);
208 | return parts.length > 1 ? parts.slice(1).join(separator) : id; // Handle separators in random part
209 | }
210 |
211 | /**
212 | * Determines the entity type from an ID string by its prefix (case-insensitive).
213 | * @param id - The ID string (e.g., "PROJ_A6B3J0").
214 | * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
215 | * @returns The determined entity type.
216 | * @throws {McpError} If ID format is invalid or prefix is unknown.
217 | */
218 | public getEntityType(
219 | id: string,
220 | separator: string = IdGenerator.DEFAULT_SEPARATOR,
221 | ): string {
222 | const parts = id.split(separator);
223 | if (parts.length < 2 || !parts[0]) {
224 | throw new McpError(
225 | BaseErrorCode.VALIDATION_ERROR,
226 | `Invalid ID format: ${id}. Expected format like: PREFIX${separator}RANDOMLPART`,
227 | );
228 | }
229 |
230 | const prefix = parts[0];
231 | const entityType = this.prefixToEntityType[prefix.toLowerCase()];
232 |
233 | if (!entityType) {
234 | throw new McpError(
235 | BaseErrorCode.VALIDATION_ERROR,
236 | `Unknown entity type for prefix: ${prefix}`,
237 | );
238 | }
239 | return entityType;
240 | }
241 |
242 | /**
243 | * Normalizes an entity ID to ensure the prefix matches the registered case
244 | * and the random part is uppercase.
245 | * @param id - The ID to normalize (e.g., "proj_a6b3j0").
246 | * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
247 | * @returns The normalized ID (e.g., "PROJ_A6B3J0").
248 | * @throws {McpError} If the entity type cannot be determined from the ID.
249 | */
250 | public normalize(
251 | id: string,
252 | separator: string = IdGenerator.DEFAULT_SEPARATOR,
253 | ): string {
254 | const entityType = this.getEntityType(id, separator);
255 | const registeredPrefix = this.entityPrefixes[entityType];
256 | const idParts = id.split(separator);
257 | const randomPart = idParts.slice(1).join(separator);
258 |
259 | return `${registeredPrefix}${separator}${randomPart.toUpperCase()}`;
260 | }
261 | }
262 |
263 | /**
264 | * Default singleton instance of the `IdGenerator`.
265 | * Initialize with `idGenerator.setEntityPrefixes({})` to configure.
266 | */
267 | export const idGenerator = new IdGenerator();
268 |
269 | /**
270 | * Generates a standard Version 4 UUID (Universally Unique Identifier).
271 | * Uses the Node.js `crypto` module. This function is independent of the IdGenerator instance
272 | * to prevent circular dependencies when used by other utilities like requestContextService.
273 | * @returns A new UUID string.
274 | */
275 | export const generateUUID = (): string => {
276 | return cryptoRandomUUID();
277 | };
278 |
```
--------------------------------------------------------------------------------
/src/mcp-server/tools/listFiles/listFilesLogic.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs/promises';
2 | import path from 'path';
3 | import { z } from 'zod';
4 | import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
5 | import { logger } from '../../../utils/internal/logger.js';
6 | import { RequestContext } from '../../../utils/internal/requestContext.js';
7 | import { serverState } from '../../state.js';
8 |
9 | // Define the input schema using Zod for validation
10 | export const ListFilesInputSchema = z.object({
11 | path: z.string().min(1, 'Path cannot be empty')
12 | .describe('The path to the directory to list. Can be relative or absolute (resolved like readFile).'),
13 | includeNested: z.boolean().default(false)
14 | .describe('If true, list files and directories recursively. Defaults to false (top-level only).'),
15 | maxEntries: z.number().int().positive().optional().default(50) // Updated default to 50
16 | .describe('Maximum number of directory entries (files + folders) to return. Defaults to 50. Helps prevent excessive output for large directories.'),
17 | });
18 |
19 | // Define the TypeScript type for the input
20 | export type ListFilesInput = z.infer<typeof ListFilesInputSchema>;
21 |
22 | // Define the TypeScript type for the output
23 | export interface ListFilesOutput {
24 | message: string;
25 | tree: string;
26 | requestedPath: string;
27 | resolvedPath: string;
28 | itemCount: number;
29 | truncated: boolean; // Added flag
30 | }
31 |
32 | interface DirectoryItem {
33 | name: string;
34 | isDirectory: boolean;
35 | children?: DirectoryItem[]; // Only populated if includeNested is true
36 | error?: string; // Added to indicate read errors for this directory
37 | }
38 |
39 | /**
40 | * Recursively reads directory contents and builds a tree structure.
41 | *
42 | * @param {string} dirPath - The absolute path to the directory.
43 | * @param {boolean} includeNested - Whether to recurse into subdirectories.
44 | * @param {RequestContext} context - The request context for logging.
45 | * @param {{ count: number, limit: number, truncated: boolean }} state - Mutable state to track count and limit across recursive calls.
46 | * @returns {Promise<DirectoryItem[]>} A promise resolving with the list of items.
47 | * @throws {McpError} If reading the directory fails.
48 | */
49 | const readDirectoryRecursive = async (
50 | dirPath: string,
51 | includeNested: boolean,
52 | context: RequestContext,
53 | state: { count: number; limit: number; truncated: boolean } // Pass state object
54 | ): Promise<DirectoryItem[]> => {
55 | if (state.truncated || state.count >= state.limit) {
56 | state.truncated = true; // Ensure truncated flag is set if limit reached before starting
57 | return []; // Stop processing if limit already reached
58 | }
59 |
60 | const items: DirectoryItem[] = [];
61 | let entries;
62 | try {
63 | entries = await fs.readdir(dirPath, { withFileTypes: true });
64 | } catch (error: any) {
65 | if (error.code === 'ENOENT') {
66 | logger.warning(`Directory not found: ${dirPath}`, context);
67 | throw new McpError(BaseErrorCode.NOT_FOUND, `Directory not found at path: ${dirPath}`, { ...context, dirPath, originalError: error });
68 | } else if (error.code === 'ENOTDIR') {
69 | logger.warning(`Path is not a directory: ${dirPath}`, context);
70 | throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Path is not a directory: ${dirPath}`, { ...context, dirPath, originalError: error });
71 | }
72 | logger.error(`Failed to read directory: ${dirPath}`, { ...context, error: error.message });
73 | throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to read directory: ${error.message}`, { ...context, dirPath, originalError: error });
74 | }
75 |
76 | for (const entry of entries) {
77 | if (state.count >= state.limit) {
78 | state.truncated = true;
79 | logger.debug(`Max entries limit (${state.limit}) reached while processing ${dirPath}`, context);
80 | break; // Stop processing entries in this directory
81 | }
82 |
83 | state.count++; // Increment count for this entry
84 |
85 | const itemPath = path.join(dirPath, entry.name);
86 | const item: DirectoryItem = {
87 | name: entry.name,
88 | isDirectory: entry.isDirectory(),
89 | };
90 |
91 | if (item.isDirectory && includeNested) {
92 | // Recursively read subdirectory, passing the shared state object
93 | try {
94 | // Pass the same state object down
95 | item.children = await readDirectoryRecursive(itemPath, includeNested, { ...context, parentPath: dirPath }, state);
96 | } catch (recursiveError) {
97 | // Log the error from the recursive call but continue processing other entries
98 | logger.error(`Error reading nested directory ${itemPath}`, { ...context, error: (recursiveError as Error).message, code: (recursiveError as McpError).code });
99 | // Log the error and mark the item
100 | const errorMessage = (recursiveError as McpError)?.message || (recursiveError as Error)?.message || 'Unknown error reading directory';
101 | logger.error(`Error reading nested directory ${itemPath}`, { ...context, error: errorMessage, code: (recursiveError as McpError)?.code });
102 | item.error = errorMessage; // Store the error message on the item
103 | item.children = undefined; // Ensure no children are processed or displayed for errored directories
104 | }
105 | }
106 | items.push(item);
107 |
108 | // Check limit again after potentially adding children (though count is incremented per item)
109 | if (state.truncated) {
110 | break; // Exit loop if limit was hit during recursive call
111 | }
112 | }
113 |
114 | // Sort items: directories first, then files, alphabetically
115 | items.sort((a, b) => {
116 | if (a.isDirectory !== b.isDirectory) {
117 | return a.isDirectory ? -1 : 1; // Directories first
118 | }
119 | return a.name.localeCompare(b.name); // Then sort alphabetically
120 | });
121 |
122 | return items;
123 | };
124 |
125 | /**
126 | * Formats the directory items into a tree-like string.
127 | *
128 | * @param {DirectoryItem[]} items - The items to format.
129 | * @param {string} prefix - The prefix string for indentation.
130 | * @param {boolean} truncated - Whether the listing was cut short due to limits.
131 | * @returns {string} The formatted tree string.
132 | */
133 | const formatTree = (items: DirectoryItem[], truncated: boolean, prefix = ''): string => {
134 | let treeString = '';
135 | items.forEach((item, index) => {
136 | const isLast = index === items.length - 1;
137 | const connector = isLast ? '└── ' : '├── ';
138 | const itemPrefix = item.isDirectory ? '📁 ' : '📄 ';
139 | const errorMarker = item.error ? ` [Error: ${item.error}]` : ''; // Add error marker if present
140 | treeString += `${prefix}${connector}${itemPrefix}${item.name}${errorMarker}\n`;
141 |
142 | // Only recurse if it's a directory, has children defined (not errored), and children exist
143 | if (item.isDirectory && !item.error && item.children && item.children.length > 0) {
144 | const childPrefix = prefix + (isLast ? ' ' : '│ ');
145 | // Pass truncated flag down, but don't add the message recursively
146 | treeString += formatTree(item.children, false, childPrefix);
147 | } else if (item.isDirectory && item.error) {
148 | // Optionally add a specific marker for children of errored directories,
149 | // but the error on the parent line is likely sufficient.
150 | }
151 | });
152 |
153 | // Add truncation message at the end of the current level if needed
154 | if (truncated && prefix === '') { // Only add at the top level formatting call
155 | treeString += `${prefix}...\n${prefix}[Listing truncated due to max entries limit]\n`;
156 | }
157 |
158 | return treeString;
159 | };
160 |
161 | /**
162 | * Lists files and directories at a given path, optionally recursively.
163 | *
164 | * @param {ListFilesInput} input - The input object containing path and options.
165 | * @param {RequestContext} context - The request context.
166 | * @returns {Promise<ListFilesOutput>} A promise resolving with the listing results.
167 | * @throws {McpError} For path errors, directory not found, or I/O errors.
168 | */
169 | export const listFilesLogic = async (input: ListFilesInput, context: RequestContext): Promise<ListFilesOutput> => {
170 | // Destructure validated input, including the new maxEntries
171 | const { path: requestedPath, includeNested, maxEntries } = input;
172 | const logicContext = { ...context, includeNested, maxEntries };
173 | logger.debug(`listFilesLogic: Received request for path "${requestedPath}" with limit ${maxEntries}`, logicContext);
174 |
175 | // Resolve the path
176 | const absolutePath = serverState.resolvePath(requestedPath, context);
177 | logger.debug(`listFilesLogic: Resolved path to "${absolutePath}"`, { ...logicContext, requestedPath });
178 |
179 | try {
180 | // Initialize state for tracking count and limit, using the potentially updated default
181 | const state = { count: 0, limit: maxEntries, truncated: false };
182 |
183 | // Read directory structure using the state object
184 | const items = await readDirectoryRecursive(absolutePath, includeNested, logicContext, state);
185 |
186 | // Format the tree, passing the final truncated state
187 | const rootName = path.basename(absolutePath);
188 | const tree = `📁 ${rootName}\n` + formatTree(items, state.truncated); // Pass truncated flag
189 |
190 | const message = state.truncated
191 | ? `Successfully listed ${state.count} items in ${absolutePath} (truncated at limit of ${maxEntries}).` // Use maxEntries from input for message
192 | : `Successfully listed ${state.count} items in ${absolutePath}.`;
193 |
194 | logger.info(`listFilesLogic: ${message}`, { ...logicContext, requestedPath, itemCount: state.count, truncated: state.truncated, limit: maxEntries });
195 |
196 | return {
197 | message: message,
198 | tree: tree,
199 | requestedPath: requestedPath,
200 | resolvedPath: absolutePath,
201 | itemCount: state.count, // Return the actual count processed
202 | truncated: state.truncated,
203 | };
204 |
205 | } catch (error: any) {
206 | // Errors during readDirectoryRecursive are already logged and potentially thrown as McpError
207 | logger.error(`listFilesLogic: Error listing files at "${absolutePath}"`, { ...logicContext, requestedPath, error: error.message, code: error.code });
208 | if (error instanceof McpError) {
209 | throw error; // Re-throw known McpErrors
210 | }
211 | // Catch any other unexpected errors
212 | throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to list files: ${error.message || 'Unknown I/O error'}`, { ...context, requestedPath, resolvedPath: absolutePath, originalError: error });
213 | }
214 | };
215 |
```
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Loads, validates, and exports application configuration.
3 | * This module centralizes configuration management, sourcing values from
4 | * environment variables and `package.json`. It uses Zod for schema validation
5 | * to ensure type safety and correctness of configuration parameters.
6 | *
7 | * Key responsibilities:
8 | * - Load environment variables from a `.env` file.
9 | * - Read `package.json` for default server name and version.
10 | * - Define a Zod schema for all expected environment variables.
11 | * - Validate environment variables against the schema.
12 | * - Construct and export a comprehensive `config` object.
13 | * - Export individual configuration values like `logLevel` and `environment` for convenience.
14 | *
15 | * @module src/config/index
16 | */
17 |
18 | import dotenv from "dotenv";
19 | import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
20 | import path, { dirname, join } from "path";
21 | import { fileURLToPath } from "url";
22 | import { z } from "zod";
23 |
24 | dotenv.config();
25 |
26 | // --- Determine Project Root ---
27 | /**
28 | * Finds the project root directory by searching upwards for package.json.
29 | * @param startDir The directory to start searching from.
30 | * @returns The absolute path to the project root, or throws an error if not found.
31 | */
32 | const findProjectRoot = (startDir: string): string => {
33 | let currentDir = startDir;
34 | while (true) {
35 | const packageJsonPath = join(currentDir, "package.json");
36 | if (existsSync(packageJsonPath)) {
37 | return currentDir;
38 | }
39 | const parentDir = dirname(currentDir);
40 | if (parentDir === currentDir) {
41 | // Reached the root of the filesystem without finding package.json
42 | throw new Error(
43 | `Could not find project root (package.json) starting from ${startDir}`,
44 | );
45 | }
46 | currentDir = parentDir;
47 | }
48 | };
49 |
50 | let projectRoot: string;
51 | try {
52 | // For ESM, __dirname is not available directly.
53 | // import.meta.url gives the URL of the current module.
54 | const currentModuleDir = dirname(fileURLToPath(import.meta.url));
55 | projectRoot = findProjectRoot(currentModuleDir);
56 | } catch (error: any) {
57 | console.error(`FATAL: Error determining project root: ${error.message}`);
58 | // Fallback to process.cwd() if project root cannot be determined.
59 | // This might happen in unusual execution environments.
60 | projectRoot = process.cwd();
61 | console.warn(
62 | `Warning: Using process.cwd() (${projectRoot}) as fallback project root.`,
63 | );
64 | }
65 | // --- End Determine Project Root ---
66 |
67 | const pkgPath = join(projectRoot, "package.json"); // Use determined projectRoot
68 | let pkg = { name: "mcp-ts-template", version: "0.0.0" };
69 |
70 | try {
71 | pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
72 | } catch (error) {
73 | if (process.stdout.isTTY) {
74 | console.error(
75 | "Warning: Could not read package.json for default config values. Using hardcoded defaults.",
76 | error,
77 | );
78 | }
79 | }
80 |
81 | /**
82 | * Zod schema for validating environment variables.
83 | * Provides type safety, validation, defaults, and clear error messages.
84 | * @private
85 | */
86 | const EnvSchema = z.object({
87 | /** Optional. The desired name for the MCP server. Defaults to `package.json` name. */
88 | MCP_SERVER_NAME: z.string().optional(),
89 | /** Optional. The version of the MCP server. Defaults to `package.json` version. */
90 | MCP_SERVER_VERSION: z.string().optional(),
91 | /** Minimum logging level. See `McpLogLevel` in logger utility. Default: "debug". */
92 | MCP_LOG_LEVEL: z.string().default("debug"),
93 | /** Directory for log files. Defaults to "logs" in project root. */
94 | LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
95 | /** Runtime environment (e.g., "development", "production"). Default: "development". */
96 | NODE_ENV: z.string().default("development"),
97 | /** MCP communication transport ("stdio" or "http"). Default: "stdio". */
98 | MCP_TRANSPORT_TYPE: z.enum(["stdio", "http"]).default("stdio"),
99 | /** HTTP server port (if MCP_TRANSPORT_TYPE is "http"). Default: 3010. */
100 | MCP_HTTP_PORT: z.coerce.number().int().positive().default(3010),
101 | /** HTTP server host (if MCP_TRANSPORT_TYPE is "http"). Default: "127.0.0.1". */
102 | MCP_HTTP_HOST: z.string().default("127.0.0.1"),
103 | /** Optional. Comma-separated allowed origins for CORS (HTTP transport). */
104 | MCP_ALLOWED_ORIGINS: z.string().optional(),
105 | /** Optional. Secret key (min 32 chars) for auth tokens (HTTP transport). CRITICAL for production. */
106 | MCP_AUTH_SECRET_KEY: z
107 | .string()
108 | .min(
109 | 32,
110 | "MCP_AUTH_SECRET_KEY must be at least 32 characters long for security reasons.",
111 | )
112 | .optional(),
113 |
114 | /** Optional. Application URL for OpenRouter integration. */
115 | OPENROUTER_APP_URL: z
116 | .string()
117 | .url("OPENROUTER_APP_URL must be a valid URL (e.g., http://localhost:3000)")
118 | .optional(),
119 | /** Optional. Application name for OpenRouter. Defaults to MCP_SERVER_NAME or package name. */
120 | OPENROUTER_APP_NAME: z.string().optional(),
121 | /** Optional. API key for OpenRouter services. */
122 | OPENROUTER_API_KEY: z.string().optional(),
123 | /** Default LLM model. Default: "google/gemini-2.5-flash-preview:thinking". */
124 | LLM_DEFAULT_MODEL: z
125 | .string()
126 | .default("google/gemini-2.5-flash-preview-05-20"),
127 | /** Optional. Default LLM temperature (0.0-2.0). */
128 | LLM_DEFAULT_TEMPERATURE: z.coerce.number().min(0).max(2).optional(),
129 | /** Optional. Default LLM top_p (0.0-1.0). */
130 | LLM_DEFAULT_TOP_P: z.coerce.number().min(0).max(1).optional(),
131 | /** Optional. Default LLM max tokens (positive integer). */
132 | LLM_DEFAULT_MAX_TOKENS: z.coerce.number().int().positive().optional(),
133 | /** Optional. Default LLM top_k (non-negative integer). */
134 | LLM_DEFAULT_TOP_K: z.coerce.number().int().nonnegative().optional(),
135 | /** Optional. Default LLM min_p (0.0-1.0). */
136 | LLM_DEFAULT_MIN_P: z.coerce.number().min(0).max(1).optional(),
137 | /** Optional. API key for Google Gemini services. */
138 | GEMINI_API_KEY: z.string().optional(),
139 |
140 | /** Optional. OAuth provider authorization endpoint URL. */
141 | OAUTH_PROXY_AUTHORIZATION_URL: z
142 | .string()
143 | .url("OAUTH_PROXY_AUTHORIZATION_URL must be a valid URL.")
144 | .optional(),
145 | /** Optional. OAuth provider token endpoint URL. */
146 | OAUTH_PROXY_TOKEN_URL: z
147 | .string()
148 | .url("OAUTH_PROXY_TOKEN_URL must be a valid URL.")
149 | .optional(),
150 | /** Optional. OAuth provider revocation endpoint URL. */
151 | OAUTH_PROXY_REVOCATION_URL: z
152 | .string()
153 | .url("OAUTH_PROXY_REVOCATION_URL must be a valid URL.")
154 | .optional(),
155 | /** Optional. OAuth provider issuer URL. */
156 | OAUTH_PROXY_ISSUER_URL: z
157 | .string()
158 | .url("OAUTH_PROXY_ISSUER_URL must be a valid URL.")
159 | .optional(),
160 | /** Optional. OAuth service documentation URL. */
161 | OAUTH_PROXY_SERVICE_DOCUMENTATION_URL: z
162 | .string()
163 | .url("OAUTH_PROXY_SERVICE_DOCUMENTATION_URL must be a valid URL.")
164 | .optional(),
165 | /** Optional. Comma-separated default OAuth client redirect URIs. */
166 | OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS: z.string().optional(),
167 | /** 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. */
168 | FS_BASE_DIRECTORY: z.string().optional(),
169 | });
170 |
171 | const parsedEnv = EnvSchema.safeParse(process.env);
172 |
173 | if (!parsedEnv.success) {
174 | if (process.stdout.isTTY) {
175 | console.error(
176 | "❌ Invalid environment variables found:",
177 | parsedEnv.error.flatten().fieldErrors,
178 | );
179 | }
180 | // Consider throwing an error in production for critical misconfigurations.
181 | }
182 |
183 | let env = parsedEnv.success ? parsedEnv.data : EnvSchema.parse({});
184 |
185 | // Resolve FS_BASE_DIRECTORY if it's relative
186 | let resolvedFsBaseDirectory: string | undefined = env.FS_BASE_DIRECTORY;
187 | if (env.FS_BASE_DIRECTORY && !path.isAbsolute(env.FS_BASE_DIRECTORY)) {
188 | resolvedFsBaseDirectory = path.resolve(projectRoot, env.FS_BASE_DIRECTORY);
189 | if (process.stdout.isTTY) {
190 | console.log(
191 | `Info: Relative FS_BASE_DIRECTORY "${env.FS_BASE_DIRECTORY}" resolved to "${resolvedFsBaseDirectory}".`
192 | );
193 | }
194 | }
195 |
196 | if (process.stdout.isTTY) {
197 | if (resolvedFsBaseDirectory) {
198 | // Ensure the resolved directory exists, or attempt to create it.
199 | // This is a good place to also check if it's a directory.
200 | try {
201 | if (!existsSync(resolvedFsBaseDirectory)) {
202 | mkdirSync(resolvedFsBaseDirectory, { recursive: true });
203 | console.log(`Info: Created FS_BASE_DIRECTORY at "${resolvedFsBaseDirectory}".`);
204 | } else {
205 | const stats = statSync(resolvedFsBaseDirectory);
206 | if (!stats.isDirectory()) {
207 | console.error(`Error: FS_BASE_DIRECTORY "${resolvedFsBaseDirectory}" exists but is not a directory. Restriction will not be applied.`);
208 | resolvedFsBaseDirectory = undefined; // Disable restriction if path is invalid
209 | }
210 | }
211 | if (resolvedFsBaseDirectory) {
212 | console.log(
213 | `Info: Filesystem operations will be restricted to base directory: ${resolvedFsBaseDirectory}`
214 | );
215 | }
216 | } catch (error: any) {
217 | console.error(`Error processing FS_BASE_DIRECTORY "${resolvedFsBaseDirectory}": ${error.message}. Restriction will not be applied.`);
218 | resolvedFsBaseDirectory = undefined; // Disable restriction on error
219 | }
220 | } else {
221 | console.warn(
222 | "Warning: FS_BASE_DIRECTORY is not set. Filesystem operations will not be restricted to a base directory. This is a potential security risk."
223 | );
224 | }
225 | }
226 |
227 |
228 | // --- Directory Ensurance Function ---
229 | /**
230 | * Ensures a directory exists and is within the project root.
231 | * @param dirPath The desired path for the directory (can be relative or absolute).
232 | * @param rootDir The root directory of the project to contain the directory.
233 | * @param dirName The name of the directory type for logging (e.g., "logs").
234 | * @returns The validated, absolute path to the directory, or null if invalid.
235 | */
236 | const ensureDirectory = (
237 | dirPath: string,
238 | rootDir: string,
239 | dirName: string,
240 | ): string | null => {
241 | const resolvedDirPath = path.isAbsolute(dirPath)
242 | ? dirPath
243 | : path.resolve(rootDir, dirPath);
244 |
245 | // Ensure the resolved path is within the project root boundary
246 | if (
247 | !resolvedDirPath.startsWith(rootDir + path.sep) &&
248 | resolvedDirPath !== rootDir
249 | ) {
250 | if (process.stdout.isTTY) {
251 | console.error(
252 | `Error: ${dirName} path "${dirPath}" resolves to "${resolvedDirPath}", which is outside the project boundary "${rootDir}".`,
253 | );
254 | }
255 | return null;
256 | }
257 |
258 | if (!existsSync(resolvedDirPath)) {
259 | try {
260 | mkdirSync(resolvedDirPath, { recursive: true });
261 | if (process.stdout.isTTY) {
262 | console.log(`Created ${dirName} directory: ${resolvedDirPath}`);
263 | }
264 | } catch (err: unknown) {
265 | const errorMessage = err instanceof Error ? err.message : String(err);
266 | if (process.stdout.isTTY) {
267 | console.error(
268 | `Error creating ${dirName} directory at ${resolvedDirPath}: ${errorMessage}`,
269 | );
270 | }
271 | return null;
272 | }
273 | } else {
274 | try {
275 | const stats = statSync(resolvedDirPath);
276 | if (!stats.isDirectory()) {
277 | if (process.stdout.isTTY) {
278 | console.error(
279 | `Error: ${dirName} path ${resolvedDirPath} exists but is not a directory.`,
280 | );
281 | }
282 | return null;
283 | }
284 | } catch (statError: any) {
285 | if (process.stdout.isTTY) {
286 | console.error(
287 | `Error accessing ${dirName} path ${resolvedDirPath}: ${statError.message}`,
288 | );
289 | }
290 | return null;
291 | }
292 | }
293 | return resolvedDirPath;
294 | };
295 | // --- End Directory Ensurance Function ---
296 |
297 | // --- Logs Directory Handling ---
298 | const validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
299 |
300 | if (!validatedLogsPath) {
301 | if (process.stdout.isTTY) {
302 | console.error(
303 | "FATAL: Logs directory configuration is invalid or could not be created. Please check permissions and path. Exiting.",
304 | );
305 | }
306 | process.exit(1); // Exit if logs directory is not usable
307 | }
308 | // --- End Logs Directory Handling ---
309 |
310 | /**
311 | * Main application configuration object.
312 | * Aggregates settings from validated environment variables and `package.json`.
313 | */
314 | export const config = {
315 | /** MCP server name. Env `MCP_SERVER_NAME` > `package.json` name > "mcp-ts-template". */
316 | mcpServerName: env.MCP_SERVER_NAME || pkg.name,
317 | /** MCP server version. Env `MCP_SERVER_VERSION` > `package.json` version > "0.0.0". */
318 | mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
319 | /** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
320 | logLevel: env.MCP_LOG_LEVEL,
321 | /** Absolute path to the logs directory. From `LOGS_DIR` env var. */
322 | logsPath: validatedLogsPath,
323 | /** Runtime environment. From `NODE_ENV` env var. Default: "development". */
324 | environment: env.NODE_ENV,
325 | /** MCP transport type ('stdio' or 'http'). From `MCP_TRANSPORT_TYPE` env var. Default: "stdio". */
326 | mcpTransportType: env.MCP_TRANSPORT_TYPE,
327 | /** HTTP server port (if http transport). From `MCP_HTTP_PORT` env var. Default: 3010. */
328 | mcpHttpPort: env.MCP_HTTP_PORT,
329 | /** HTTP server host (if http transport). From `MCP_HTTP_HOST` env var. Default: "127.0.0.1". */
330 | mcpHttpHost: env.MCP_HTTP_HOST,
331 | /** Array of allowed CORS origins (http transport). From `MCP_ALLOWED_ORIGINS` (comma-separated). */
332 | mcpAllowedOrigins: env.MCP_ALLOWED_ORIGINS?.split(",")
333 | .map((origin) => origin.trim())
334 | .filter(Boolean),
335 | /** Auth secret key (JWTs, http transport). From `MCP_AUTH_SECRET_KEY`. CRITICAL. */
336 | mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
337 |
338 | /** OpenRouter App URL. From `OPENROUTER_APP_URL`. Default: "http://localhost:3000". */
339 | openrouterAppUrl: env.OPENROUTER_APP_URL || "http://localhost:3000",
340 | /** OpenRouter App Name. From `OPENROUTER_APP_NAME`. Defaults to `mcpServerName`. */
341 | openrouterAppName: env.OPENROUTER_APP_NAME || pkg.name || "MCP TS App",
342 | /** OpenRouter API Key. From `OPENROUTER_API_KEY`. */
343 | openrouterApiKey: env.OPENROUTER_API_KEY,
344 | /** Default LLM model. From `LLM_DEFAULT_MODEL`. */
345 | llmDefaultModel: env.LLM_DEFAULT_MODEL,
346 | /** Default LLM temperature. From `LLM_DEFAULT_TEMPERATURE`. */
347 | llmDefaultTemperature: env.LLM_DEFAULT_TEMPERATURE,
348 | /** Default LLM top_p. From `LLM_DEFAULT_TOP_P`. */
349 | llmDefaultTopP: env.LLM_DEFAULT_TOP_P,
350 | /** Default LLM max tokens. From `LLM_DEFAULT_MAX_TOKENS`. */
351 | llmDefaultMaxTokens: env.LLM_DEFAULT_MAX_TOKENS,
352 | /** Default LLM top_k. From `LLM_DEFAULT_TOP_K`. */
353 | llmDefaultTopK: env.LLM_DEFAULT_TOP_K,
354 | /** Default LLM min_p. From `LLM_DEFAULT_MIN_P`. */
355 | llmDefaultMinP: env.LLM_DEFAULT_MIN_P,
356 | /** Gemini API Key. From `GEMINI_API_KEY`. */
357 | geminiApiKey: env.GEMINI_API_KEY,
358 |
359 | /** OAuth Proxy configurations. Undefined if no related env vars are set. */
360 | oauthProxy:
361 | env.OAUTH_PROXY_AUTHORIZATION_URL ||
362 | env.OAUTH_PROXY_TOKEN_URL ||
363 | env.OAUTH_PROXY_REVOCATION_URL ||
364 | env.OAUTH_PROXY_ISSUER_URL ||
365 | env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL ||
366 | env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS
367 | ? {
368 | authorizationUrl: env.OAUTH_PROXY_AUTHORIZATION_URL,
369 | tokenUrl: env.OAUTH_PROXY_TOKEN_URL,
370 | revocationUrl: env.OAUTH_PROXY_REVOCATION_URL,
371 | issuerUrl: env.OAUTH_PROXY_ISSUER_URL,
372 | serviceDocumentationUrl: env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL,
373 | defaultClientRedirectUris:
374 | env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS?.split(",")
375 | .map((uri) => uri.trim())
376 | .filter(Boolean),
377 | }
378 | : undefined,
379 | /** Base directory for filesystem operations. From `FS_BASE_DIRECTORY`. If set, operations are restricted to this path. Will be an absolute path. */
380 | fsBaseDirectory: resolvedFsBaseDirectory,
381 | };
382 |
383 | /**
384 | * Configured logging level for the application.
385 | * Exported for convenience.
386 | */
387 | export const logLevel: string = config.logLevel;
388 |
389 | /**
390 | * Configured runtime environment ("development", "production", etc.).
391 | * Exported for convenience.
392 | */
393 | export const environment: string = config.environment;
394 |
```
--------------------------------------------------------------------------------
/src/utils/internal/errorHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview This module provides utilities for robust error handling.
3 | * It defines structures for error context, options for handling errors,
4 | * and mappings for classifying errors. The main `ErrorHandler` class
5 | * offers static methods for consistent error processing, logging, and transformation.
6 | * @module src/utils/internal/errorHandler
7 | */
8 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
9 | import { generateUUID, sanitizeInputForLogging } from "../index.js";
10 | import { logger } from "./logger.js";
11 | import { RequestContext } from "./requestContext.js";
12 |
13 | /**
14 | * Defines a generic structure for providing context with errors.
15 | * This context can include identifiers like `requestId` or any other relevant
16 | * key-value pairs that aid in debugging or understanding the error's circumstances.
17 | */
18 | export interface ErrorContext {
19 | /**
20 | * A unique identifier for the request or operation during which the error occurred.
21 | * Useful for tracing errors through logs and distributed systems.
22 | */
23 | requestId?: string;
24 |
25 | /**
26 | * Allows for arbitrary additional context information.
27 | * Keys are strings, and values can be of any type.
28 | */
29 | [key: string]: unknown;
30 | }
31 |
32 | /**
33 | * Configuration options for the `ErrorHandler.handleError` method.
34 | * These options control how an error is processed, logged, and whether it's rethrown.
35 | */
36 | export interface ErrorHandlerOptions {
37 | /**
38 | * The context of the operation that caused the error.
39 | * This can include `requestId` and other relevant debugging information.
40 | */
41 | context?: ErrorContext;
42 |
43 | /**
44 | * A descriptive name of the operation being performed when the error occurred.
45 | * This helps in identifying the source or nature of the error in logs.
46 | * Example: "UserLogin", "ProcessPayment", "FetchUserProfile".
47 | */
48 | operation: string;
49 |
50 | /**
51 | * The input data or parameters that were being processed when the error occurred.
52 | * This input will be sanitized before logging to prevent sensitive data exposure.
53 | */
54 | input?: unknown;
55 |
56 | /**
57 | * If true, the (potentially transformed) error will be rethrown after handling.
58 | * Defaults to `false`.
59 | */
60 | rethrow?: boolean;
61 |
62 | /**
63 | * A specific `BaseErrorCode` to assign to the error, overriding any
64 | * automatically determined error code.
65 | */
66 | errorCode?: BaseErrorCode;
67 |
68 | /**
69 | * A custom function to map or transform the original error into a new `Error` instance.
70 | * If provided, this function is used instead of the default `McpError` creation.
71 | * @param error - The original error that occurred.
72 | * @returns The transformed error.
73 | */
74 | errorMapper?: (error: unknown) => Error;
75 |
76 | /**
77 | * If true, stack traces will be included in the logs.
78 | * Defaults to `true`.
79 | */
80 | includeStack?: boolean;
81 |
82 | /**
83 | * If true, indicates that the error is critical and might require immediate attention
84 | * or could lead to system instability. This is primarily for logging and alerting.
85 | * Defaults to `false`.
86 | */
87 | critical?: boolean;
88 | }
89 |
90 | /**
91 | * Defines a basic rule for mapping errors based on patterns.
92 | * Used internally by `COMMON_ERROR_PATTERNS` and as a base for `ErrorMapping`.
93 | */
94 | export interface BaseErrorMapping {
95 | /**
96 | * A string or regular expression to match against the error message.
97 | * If a string is provided, it's typically used for substring matching (case-insensitive).
98 | */
99 | pattern: string | RegExp;
100 |
101 | /**
102 | * The `BaseErrorCode` to assign if the pattern matches.
103 | */
104 | errorCode: BaseErrorCode;
105 |
106 | /**
107 | * An optional custom message template for the mapped error.
108 | * (Note: This property is defined but not directly used by `ErrorHandler.determineErrorCode`
109 | * which focuses on `errorCode`. It's more relevant for custom mapping logic.)
110 | */
111 | messageTemplate?: string;
112 | }
113 |
114 | /**
115 | * Extends `BaseErrorMapping` to include a factory function for creating
116 | * specific error instances and additional context for the mapping.
117 | * Used by `ErrorHandler.mapError`.
118 | * @template T The type of `Error` this mapping will produce, defaults to `Error`.
119 | */
120 | export interface ErrorMapping<T extends Error = Error>
121 | extends BaseErrorMapping {
122 | /**
123 | * A factory function that creates and returns an instance of the mapped error type `T`.
124 | * @param error - The original error that occurred.
125 | * @param context - Optional additional context provided in the mapping rule.
126 | * @returns The newly created error instance.
127 | */
128 | factory: (error: unknown, context?: Record<string, unknown>) => T;
129 |
130 | /**
131 | * Additional static context to be merged or passed to the `factory` function
132 | * when this mapping rule is applied.
133 | */
134 | additionalContext?: Record<string, unknown>;
135 | }
136 |
137 | /**
138 | * Maps standard JavaScript error constructor names to `BaseErrorCode` values.
139 | * @private
140 | */
141 | const ERROR_TYPE_MAPPINGS: Readonly<Record<string, BaseErrorCode>> = {
142 | SyntaxError: BaseErrorCode.VALIDATION_ERROR,
143 | TypeError: BaseErrorCode.VALIDATION_ERROR,
144 | ReferenceError: BaseErrorCode.INTERNAL_ERROR,
145 | RangeError: BaseErrorCode.VALIDATION_ERROR,
146 | URIError: BaseErrorCode.VALIDATION_ERROR,
147 | EvalError: BaseErrorCode.INTERNAL_ERROR,
148 | };
149 |
150 | /**
151 | * Array of `BaseErrorMapping` rules to classify errors by message/name patterns.
152 | * Order matters: more specific patterns should precede generic ones.
153 | * @private
154 | */
155 | const COMMON_ERROR_PATTERNS: ReadonlyArray<Readonly<BaseErrorMapping>> = [
156 | {
157 | pattern:
158 | /auth|unauthorized|unauthenticated|not.*logged.*in|invalid.*token|expired.*token/i,
159 | errorCode: BaseErrorCode.UNAUTHORIZED,
160 | },
161 | {
162 | pattern: /permission|forbidden|access.*denied|not.*allowed/i,
163 | errorCode: BaseErrorCode.FORBIDDEN,
164 | },
165 | {
166 | pattern: /not found|missing|no such|doesn't exist|couldn't find/i,
167 | errorCode: BaseErrorCode.NOT_FOUND,
168 | },
169 | {
170 | pattern:
171 | /invalid|validation|malformed|bad request|wrong format|missing required/i,
172 | errorCode: BaseErrorCode.VALIDATION_ERROR,
173 | },
174 | {
175 | pattern: /conflict|already exists|duplicate|unique constraint/i,
176 | errorCode: BaseErrorCode.CONFLICT,
177 | },
178 | {
179 | pattern: /rate limit|too many requests|throttled/i,
180 | errorCode: BaseErrorCode.RATE_LIMITED,
181 | },
182 | {
183 | pattern: /timeout|timed out|deadline exceeded/i,
184 | errorCode: BaseErrorCode.TIMEOUT,
185 | },
186 | {
187 | pattern: /service unavailable|bad gateway|gateway timeout|upstream error/i,
188 | errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
189 | },
190 | ];
191 |
192 | /**
193 | * Creates a "safe" RegExp for testing error messages.
194 | * Ensures case-insensitivity and removes the global flag.
195 | * @param pattern - The string or RegExp pattern.
196 | * @returns A new RegExp instance.
197 | * @private
198 | */
199 | function createSafeRegex(pattern: string | RegExp): RegExp {
200 | if (pattern instanceof RegExp) {
201 | let flags = pattern.flags.replace("g", "");
202 | if (!flags.includes("i")) {
203 | flags += "i";
204 | }
205 | return new RegExp(pattern.source, flags);
206 | }
207 | return new RegExp(pattern, "i");
208 | }
209 |
210 | /**
211 | * Retrieves a descriptive name for an error object or value.
212 | * @param error - The error object or value.
213 | * @returns A string representing the error's name or type.
214 | * @private
215 | */
216 | function getErrorName(error: unknown): string {
217 | if (error instanceof Error) {
218 | return error.name || "Error";
219 | }
220 | if (error === null) {
221 | return "NullValueEncountered";
222 | }
223 | if (error === undefined) {
224 | return "UndefinedValueEncountered";
225 | }
226 | if (
227 | typeof error === "object" &&
228 | error !== null &&
229 | error.constructor &&
230 | typeof error.constructor.name === "string" &&
231 | error.constructor.name !== "Object"
232 | ) {
233 | return `${error.constructor.name}Encountered`;
234 | }
235 | return `${typeof error}Encountered`;
236 | }
237 |
238 | /**
239 | * Extracts a message string from an error object or value.
240 | * @param error - The error object or value.
241 | * @returns The error message string.
242 | * @private
243 | */
244 | function getErrorMessage(error: unknown): string {
245 | if (error instanceof Error) {
246 | return error.message;
247 | }
248 | if (error === null) {
249 | return "Null value encountered as error";
250 | }
251 | if (error === undefined) {
252 | return "Undefined value encountered as error";
253 | }
254 | if (typeof error === "string") {
255 | return error;
256 | }
257 | try {
258 | const str = String(error);
259 | if (str === "[object Object]" && error !== null) {
260 | try {
261 | return `Non-Error object encountered: ${JSON.stringify(error)}`;
262 | } catch (stringifyError) {
263 | return `Unstringifyable non-Error object encountered (constructor: ${error.constructor?.name || "Unknown"})`;
264 | }
265 | }
266 | return str;
267 | } catch (e) {
268 | return `Error converting error to string: ${e instanceof Error ? e.message : "Unknown conversion error"}`;
269 | }
270 | }
271 |
272 | /**
273 | * A utility class providing static methods for comprehensive error handling.
274 | */
275 | export class ErrorHandler {
276 | /**
277 | * Determines an appropriate `BaseErrorCode` for a given error.
278 | * Checks `McpError` instances, `ERROR_TYPE_MAPPINGS`, and `COMMON_ERROR_PATTERNS`.
279 | * Defaults to `BaseErrorCode.INTERNAL_ERROR`.
280 | * @param error - The error instance or value to classify.
281 | * @returns The determined error code.
282 | */
283 | public static determineErrorCode(error: unknown): BaseErrorCode {
284 | if (error instanceof McpError) {
285 | return error.code;
286 | }
287 |
288 | const errorName = getErrorName(error);
289 | const errorMessage = getErrorMessage(error);
290 |
291 | if (errorName in ERROR_TYPE_MAPPINGS) {
292 | return ERROR_TYPE_MAPPINGS[errorName as keyof typeof ERROR_TYPE_MAPPINGS];
293 | }
294 |
295 | for (const mapping of COMMON_ERROR_PATTERNS) {
296 | const regex = createSafeRegex(mapping.pattern);
297 | if (regex.test(errorMessage) || regex.test(errorName)) {
298 | return mapping.errorCode;
299 | }
300 | }
301 | return BaseErrorCode.INTERNAL_ERROR;
302 | }
303 |
304 | /**
305 | * Handles an error with consistent logging and optional transformation.
306 | * Sanitizes input, determines error code, logs details, and can rethrow.
307 | * @param error - The error instance or value that occurred.
308 | * @param options - Configuration for handling the error.
309 | * @returns The handled (and potentially transformed) error instance.
310 | */
311 | public static handleError(
312 | error: unknown,
313 | options: ErrorHandlerOptions,
314 | ): Error {
315 | const {
316 | context = {},
317 | operation,
318 | input,
319 | rethrow = false,
320 | errorCode: explicitErrorCode,
321 | includeStack = true,
322 | critical = false,
323 | errorMapper,
324 | } = options;
325 |
326 | const sanitizedInput =
327 | input !== undefined ? sanitizeInputForLogging(input) : undefined;
328 | const originalErrorName = getErrorName(error);
329 | const originalErrorMessage = getErrorMessage(error);
330 | const originalStack = error instanceof Error ? error.stack : undefined;
331 |
332 | let finalError: Error;
333 | let loggedErrorCode: BaseErrorCode;
334 |
335 | const errorDetailsSeed =
336 | error instanceof McpError &&
337 | typeof error.details === "object" &&
338 | error.details !== null
339 | ? { ...error.details }
340 | : {};
341 |
342 | const consolidatedDetails: Record<string, unknown> = {
343 | ...errorDetailsSeed,
344 | ...context,
345 | originalErrorName,
346 | originalMessage: originalErrorMessage,
347 | };
348 | if (
349 | originalStack &&
350 | !(error instanceof McpError && error.details?.originalStack)
351 | ) {
352 | consolidatedDetails.originalStack = originalStack;
353 | }
354 |
355 | if (error instanceof McpError) {
356 | loggedErrorCode = error.code;
357 | finalError = errorMapper
358 | ? errorMapper(error)
359 | : new McpError(error.code, error.message, consolidatedDetails);
360 | } else {
361 | loggedErrorCode =
362 | explicitErrorCode || ErrorHandler.determineErrorCode(error);
363 | const message = `Error in ${operation}: ${originalErrorMessage}`;
364 | finalError = errorMapper
365 | ? errorMapper(error)
366 | : new McpError(loggedErrorCode, message, consolidatedDetails);
367 | }
368 |
369 | if (
370 | finalError !== error &&
371 | error instanceof Error &&
372 | finalError instanceof Error &&
373 | !finalError.stack &&
374 | error.stack
375 | ) {
376 | finalError.stack = error.stack;
377 | }
378 |
379 | const logRequestId =
380 | typeof context.requestId === "string" && context.requestId
381 | ? context.requestId
382 | : generateUUID();
383 |
384 | const logTimestamp =
385 | typeof context.timestamp === "string" && context.timestamp
386 | ? context.timestamp
387 | : new Date().toISOString();
388 |
389 | const logPayload: Record<string, unknown> = {
390 | requestId: logRequestId,
391 | timestamp: logTimestamp,
392 | operation,
393 | input: sanitizedInput,
394 | critical,
395 | errorCode: loggedErrorCode,
396 | originalErrorType: originalErrorName,
397 | finalErrorType: getErrorName(finalError),
398 | ...Object.fromEntries(
399 | Object.entries(context).filter(
400 | ([key]) => key !== "requestId" && key !== "timestamp",
401 | ),
402 | ),
403 | };
404 |
405 | if (finalError instanceof McpError && finalError.details) {
406 | logPayload.errorDetails = finalError.details;
407 | } else {
408 | logPayload.errorDetails = consolidatedDetails;
409 | }
410 |
411 | if (includeStack) {
412 | const stack =
413 | finalError instanceof Error ? finalError.stack : originalStack;
414 | if (stack) {
415 | logPayload.stack = stack;
416 | }
417 | }
418 |
419 | logger.error(
420 | `Error in ${operation}: ${finalError.message || originalErrorMessage}`,
421 | logPayload as unknown as RequestContext, // Cast to RequestContext for logger compatibility
422 | );
423 |
424 | if (rethrow) {
425 | throw finalError;
426 | }
427 | return finalError;
428 | }
429 |
430 | /**
431 | * Maps an error to a specific error type `T` based on `ErrorMapping` rules.
432 | * Returns original/default error if no mapping matches.
433 | * @template T The target error type, extending `Error`.
434 | * @param error - The error instance or value to map.
435 | * @param mappings - An array of mapping rules to apply.
436 | * @param defaultFactory - Optional factory for a default error if no mapping matches.
437 | * @returns The mapped error of type `T`, or the original/defaulted error.
438 | */
439 | public static mapError<T extends Error>(
440 | error: unknown,
441 | mappings: ReadonlyArray<ErrorMapping<T>>,
442 | defaultFactory?: (error: unknown, context?: Record<string, unknown>) => T,
443 | ): T | Error {
444 | const errorMessage = getErrorMessage(error);
445 | const errorName = getErrorName(error);
446 |
447 | for (const mapping of mappings) {
448 | const regex = createSafeRegex(mapping.pattern);
449 | if (regex.test(errorMessage) || regex.test(errorName)) {
450 | return mapping.factory(error, mapping.additionalContext);
451 | }
452 | }
453 |
454 | if (defaultFactory) {
455 | return defaultFactory(error);
456 | }
457 | return error instanceof Error ? error : new Error(String(error));
458 | }
459 |
460 | /**
461 | * Formats an error into a consistent object structure for API responses or structured logging.
462 | * @param error - The error instance or value to format.
463 | * @returns A structured representation of the error.
464 | */
465 | public static formatError(error: unknown): Record<string, unknown> {
466 | if (error instanceof McpError) {
467 | return {
468 | code: error.code,
469 | message: error.message,
470 | details:
471 | typeof error.details === "object" && error.details !== null
472 | ? error.details
473 | : {},
474 | };
475 | }
476 |
477 | if (error instanceof Error) {
478 | return {
479 | code: ErrorHandler.determineErrorCode(error),
480 | message: error.message,
481 | details: { errorType: error.name || "Error" },
482 | };
483 | }
484 |
485 | return {
486 | code: BaseErrorCode.UNKNOWN_ERROR,
487 | message: getErrorMessage(error),
488 | details: { errorType: getErrorName(error) },
489 | };
490 | }
491 |
492 | /**
493 | * Safely executes a function (sync or async) and handles errors using `ErrorHandler.handleError`.
494 | * The error is always rethrown.
495 | * @template T The expected return type of the function `fn`.
496 | * @param fn - The function to execute.
497 | * @param options - Error handling options (excluding `rethrow`).
498 | * @returns A promise resolving with the result of `fn` if successful.
499 | * @throws {McpError | Error} The error processed by `ErrorHandler.handleError`.
500 | * @example
501 | * ```typescript
502 | * async function fetchData(userId: string, context: RequestContext) {
503 | * return ErrorHandler.tryCatch(
504 | * async () => {
505 | * const response = await fetch(`/api/users/${userId}`);
506 | * if (!response.ok) throw new Error(`Failed to fetch user: ${response.status}`);
507 | * return response.json();
508 | * },
509 | * { operation: 'fetchUserData', context, input: { userId } }
510 | * );
511 | * }
512 | * ```
513 | */
514 | public static async tryCatch<T>(
515 | fn: () => Promise<T> | T,
516 | options: Omit<ErrorHandlerOptions, "rethrow">,
517 | ): Promise<T> {
518 | try {
519 | return await Promise.resolve(fn());
520 | } catch (error) {
521 | // ErrorHandler.handleError will return the error to be thrown.
522 | throw ErrorHandler.handleError(error, { ...options, rethrow: true });
523 | }
524 | }
525 | }
526 |
```
--------------------------------------------------------------------------------
/scripts/tree.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Directory Tree Generation Operation
5 | * ==================================
6 | *
7 | * A utility for generating visual tree representations of the project's directory
8 | * structure with configurable depth control and gitignore integration.
9 | *
10 | * This operation creates a formatted markdown file containing a hierarchical
11 | * representation of directories and files, respecting ignore patterns and
12 | * applying configurable filtering.
13 | *
14 | * Features:
15 | * - Respects .gitignore patterns and common exclusions
16 | * - Configurable maximum depth traversal
17 | * - Customizable output location
18 | * - Sorting with directories first
19 | * - Cross-platform compatibility
20 | *
21 | * @module utilities/generate.directory.tree.operation
22 | *
23 | * Usage examples:
24 | * - Add to package.json: "tree": "ts-node scripts/tree.ts"
25 | * - Run directly: npm run tree
26 | * - Custom output: ts-node scripts/tree.ts ./documentation/structure.md
27 | * - Limit depth: ts-node scripts/tree.ts --depth=3
28 | * - Show help: ts-node scripts/tree.ts --help
29 | */
30 |
31 | import fs from 'fs/promises';
32 | import path from 'path';
33 |
34 | // -----------------------------------
35 | // Type Definitions
36 | // -----------------------------------
37 |
38 | /**
39 | * Standardized error category classification (using type alias instead of enum)
40 | */
41 | type ErrorCategoryType =
42 | | 'VALIDATION'
43 | | 'FILESYSTEM'
44 | | 'SYSTEM'
45 | | 'UNKNOWN';
46 |
47 | const ErrorCategory = {
48 | VALIDATION: 'VALIDATION' as ErrorCategoryType,
49 | FILESYSTEM: 'FILESYSTEM' as ErrorCategoryType,
50 | SYSTEM: 'SYSTEM' as ErrorCategoryType,
51 | UNKNOWN: 'UNKNOWN' as ErrorCategoryType,
52 | };
53 |
54 | /**
55 | * Error severity classification (using type alias instead of enum)
56 | */
57 | type ErrorSeverityLevel = 0 | 1 | 2 | 3 | 4;
58 |
59 | const ErrorSeverity = {
60 | DEBUG: 0 as ErrorSeverityLevel,
61 | INFO: 1 as ErrorSeverityLevel,
62 | WARN: 2 as ErrorSeverityLevel,
63 | ERROR: 3 as ErrorSeverityLevel,
64 | FATAL: 4 as ErrorSeverityLevel,
65 | };
66 |
67 | /**
68 | * Standardized error structure for consistent error handling
69 | */
70 | interface StandardizedApplicationErrorObject {
71 | errorMessage: string; // Human-readable description
72 | errorCode: string; // Machine-readable identifier
73 | errorCategory: ErrorCategoryType; // System area affected (using type alias)
74 | errorSeverity: ErrorSeverityLevel; // How critical the error is (using type alias)
75 | errorTimestamp: string; // When the error occurred
76 | errorContext: Record<string, unknown>; // Additional relevant data
77 | errorStack?: string; // Stack trace if available
78 | }
79 |
80 | /**
81 | * Successful result from an operation
82 | */
83 | interface OperationResultSuccess<DataType> {
84 | resultSuccessful: true;
85 | resultData: DataType;
86 | }
87 |
88 | /**
89 | * Failed result from an operation
90 | */
91 | interface OperationResultFailure<ErrorType> {
92 | resultSuccessful: false;
93 | resultError: ErrorType;
94 | }
95 |
96 | /**
97 | * Combined result type for operations
98 | */
99 | type OperationResult<DataType, ErrorType = StandardizedApplicationErrorObject> =
100 | | OperationResultSuccess<DataType>
101 | | OperationResultFailure<ErrorType>;
102 |
103 | /**
104 | * Configuration options for the tree generation operation
105 | */
106 | interface TreeGenerationConfiguration {
107 | treeOutputFilePath: string;
108 | maximumDirectoryDepth: number;
109 | showHelpText: boolean;
110 | }
111 |
112 | /**
113 | * Definition of a gitignore pattern with parsing metadata
114 | */
115 | interface GitignorePatternDefinition {
116 | patternText: string;
117 | isNegatedPattern: boolean;
118 | regexPattern: string;
119 | }
120 |
121 | /**
122 | * Result from the tree generation operation
123 | */
124 | interface TreeGenerationResult {
125 | projectName: string;
126 | treeOutputFilePath: string;
127 | treeContentLength: number;
128 | maximumDepthApplied: number;
129 | generationTimestamp: string;
130 | }
131 |
132 | // -----------------------------------
133 | // Constants
134 | // -----------------------------------
135 |
136 | /**
137 | * Default patterns to always ignore regardless of gitignore contents
138 | */
139 | const DEFAULT_IGNORE_PATTERNS: string[] = [
140 | '.git',
141 | 'node_modules',
142 | '.DS_Store',
143 | 'dist',
144 | 'build'
145 | ];
146 |
147 | /**
148 | * Default output path for the generated tree
149 | */
150 | const DEFAULT_OUTPUT_PATH = 'docs/tree.md';
151 |
152 | /**
153 | * Help text displayed when requested
154 | */
155 | const HELP_TEXT = `
156 | Directory Tree Generator - Project structure visualization tool
157 |
158 | Usage:
159 | node dist/utilities/generate.directory.tree.operation.js [output-path] [--depth=<number>] [--help]
160 |
161 | Options:
162 | output-path Custom file path for the tree output (default: docs/tree.md)
163 | --depth=<number> Maximum directory depth to display (default: unlimited)
164 | --help Show this help message
165 | `;
166 |
167 | // -----------------------------------
168 | // Utility Functions
169 | // -----------------------------------
170 |
171 | /**
172 | * Creates a standardized success result
173 | *
174 | * @param data - The data to include in the success result
175 | * @returns A standardized success result object
176 | */
177 | function createSuccessResult<DataType>(data: DataType): OperationResultSuccess<DataType> {
178 | return { resultSuccessful: true, resultData: data };
179 | }
180 |
181 | /**
182 | * Creates a standardized failure result
183 | *
184 | * @param error - The error to include in the failure result
185 | * @returns A standardized failure result object
186 | */
187 | function createFailureResult<ErrorType>(error: ErrorType): OperationResultFailure<ErrorType> {
188 | return { resultSuccessful: false, resultError: error };
189 | }
190 |
191 | /**
192 | * Creates a standardized error object
193 | *
194 | * @param message - Human-readable error message
195 | * @param code - Machine-readable error code
196 | * @param category - Error category classification
197 | * @param severity - Error severity level
198 | * @param context - Additional context data
199 | * @returns A standardized error object
200 | */
201 | function createStandardizedError(
202 | message: string,
203 | code: string,
204 | category: ErrorCategoryType, // Use the type alias
205 | severity: ErrorSeverityLevel, // Use the type alias
206 | context: Record<string, unknown> = {}
207 | ): StandardizedApplicationErrorObject {
208 | return {
209 | errorMessage: message,
210 | errorCode: code,
211 | errorCategory: category,
212 | errorSeverity: severity,
213 | errorTimestamp: new Date().toISOString(),
214 | errorContext: context
215 | };
216 | }
217 |
218 | /**
219 | * Converts an exception to a standardized error object
220 | *
221 | * @param exception - The caught exception
222 | * @param defaultMessage - Fallback message if exception is not an Error object
223 | * @returns A standardized error object
224 | */
225 | function wrapExceptionAsStandardizedError(
226 | exception: unknown,
227 | defaultMessage: string
228 | ): StandardizedApplicationErrorObject {
229 | const errorMessage = exception instanceof Error ? exception.message : defaultMessage;
230 | const errorStack = exception instanceof Error ? exception.stack : undefined;
231 |
232 | return {
233 | errorMessage,
234 | errorCode: 'UNEXPECTED_ERROR',
235 | errorCategory: ErrorCategory.UNKNOWN, // Use the constant object
236 | errorSeverity: ErrorSeverity.ERROR, // Use the constant object
237 | errorTimestamp: new Date().toISOString(),
238 | errorContext: { originalException: exception },
239 | errorStack
240 | };
241 | }
242 |
243 | // -----------------------------------
244 | // Implementation Functions
245 | // -----------------------------------
246 |
247 | /**
248 | * Parses command line arguments to extract configuration options
249 | *
250 | * @param commandLineArguments - Array of arguments from process.argv
251 | * @returns Configuration object for tree generation
252 | */
253 | function parseCommandLineArguments(
254 | commandLineArguments: string[]
255 | ): TreeGenerationConfiguration {
256 | let treeOutputFilePath = DEFAULT_OUTPUT_PATH;
257 | let maximumDirectoryDepth = Infinity;
258 | let showHelpText = false;
259 |
260 | for (const argumentValue of commandLineArguments) {
261 | if (argumentValue === '--help') {
262 | showHelpText = true;
263 | } else if (argumentValue.startsWith('--depth=')) {
264 | const depthValue = argumentValue.split('=')[1];
265 | const parsedDepth = parseInt(depthValue, 10);
266 |
267 | if (isNaN(parsedDepth) || parsedDepth < 1) {
268 | console.error('Invalid depth value. Using unlimited depth.');
269 | maximumDirectoryDepth = Infinity;
270 | } else {
271 | maximumDirectoryDepth = parsedDepth;
272 | }
273 | } else if (!argumentValue.startsWith('--')) {
274 | // If it's not an option flag, assume it's the output path
275 | treeOutputFilePath = argumentValue;
276 | }
277 | }
278 |
279 | return {
280 | treeOutputFilePath,
281 | maximumDirectoryDepth,
282 | showHelpText
283 | };
284 | }
285 |
286 | /**
287 | * Loads and parses patterns from the .gitignore file
288 | *
289 | * @returns Promise resolving to an array of parsed gitignore patterns
290 | */
291 | async function loadGitignorePatternDefinitions(): Promise<OperationResult<GitignorePatternDefinition[]>> {
292 | try {
293 | const gitignoreContent = await fs.readFile('.gitignore', 'utf-8');
294 |
295 | const patternDefinitions = gitignoreContent
296 | .split('\n')
297 | .map(line => line.trim())
298 | // Remove comments, empty lines, and lines with just whitespace
299 | .filter(line => line && !line.startsWith('#') && line.trim() !== '')
300 | // Process each pattern
301 | .map(pattern => ({
302 | patternText: pattern.startsWith('!') ? pattern.slice(1) : pattern,
303 | isNegatedPattern: pattern.startsWith('!'),
304 | // Convert glob patterns to regex-compatible strings (simplified approach)
305 | regexPattern: pattern
306 | .replace(/\./g, '\\.') // Escape dots first
307 | .replace(/\*/g, '.*') // Convert * to .*
308 | .replace(/\?/g, '.') // Convert ? to .
309 | .replace(/\/$/, '(/.*)?') // Handle directory indicators
310 | }));
311 |
312 | return createSuccessResult(patternDefinitions);
313 | } catch (exceptionObject) {
314 | console.warn('No .gitignore file found, using default patterns only');
315 | return createSuccessResult([]);
316 | }
317 | }
318 |
319 | /**
320 | * Checks if a given file path should be ignored based on patterns
321 | *
322 | * @param entryPath - The relative path to check
323 | * @param ignorePatternDefinitions - Array of parsed gitignore patterns
324 | * @returns Boolean indicating if the path should be ignored
325 | */
326 | function checkPathShouldBeIgnored(
327 | entryPath: string,
328 | ignorePatternDefinitions: GitignorePatternDefinition[]
329 | ): boolean {
330 | // Always check default patterns first
331 | if (DEFAULT_IGNORE_PATTERNS.some(pattern => entryPath.includes(pattern))) {
332 | return true;
333 | }
334 |
335 | let shouldBeIgnored = false;
336 |
337 | for (const { patternText, isNegatedPattern, regexPattern } of ignorePatternDefinitions) {
338 | // Convert the pattern to a proper regex
339 | const compiledRegexPattern = new RegExp(`^${regexPattern}$|/${regexPattern}$|/${regexPattern}/`);
340 |
341 | if (compiledRegexPattern.test(entryPath)) {
342 | // If it's a negation pattern (!pattern), this file should NOT be ignored
343 | // Otherwise, it should be ignored
344 | shouldBeIgnored = !isNegatedPattern;
345 | }
346 | }
347 |
348 | return shouldBeIgnored;
349 | }
350 |
351 | /**
352 | * Recursively generates a tree representation of the directory structure
353 | *
354 | * @param directoryPath - Path to the directory to process
355 | * @param ignorePatternDefinitions - Array of gitignore pattern definitions
356 | * @param prefixString - Prefix string for the current level (used for indentation)
357 | * @param isLastEntry - Whether this is the last entry at the current level
358 | * @param relativePathString - Relative path from the root directory
359 | * @param currentDepthLevel - Current depth level in the traversal
360 | * @returns Promise resolving to the string representation of the tree
361 | */
362 | async function generateDirectoryTreeRepresentation(
363 | directoryPath: string,
364 | ignorePatternDefinitions: GitignorePatternDefinition[],
365 | prefixString = '',
366 | isLastEntry = true,
367 | relativePathString = '',
368 | currentDepthLevel = 0,
369 | maximumDepthLevel = Infinity
370 | ): Promise<OperationResult<string>> {
371 | try {
372 | const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true });
373 | let treeOutputContent = '';
374 |
375 | // Filter and sort entries
376 | const filteredEntries = directoryEntries
377 | .filter(entry => {
378 | const entryPath = path.join(relativePathString, entry.name);
379 | return !checkPathShouldBeIgnored(entryPath, ignorePatternDefinitions);
380 | })
381 | .sort((a, b) => {
382 | // Directories first, then files
383 | if (a.isDirectory() && !b.isDirectory()) return -1;
384 | if (!a.isDirectory() && b.isDirectory()) return 1;
385 | return a.name.localeCompare(b.name);
386 | });
387 |
388 | for (let entryIndex = 0; entryIndex < filteredEntries.length; entryIndex++) {
389 | const entryItem = filteredEntries[entryIndex];
390 | const isLastItem = entryIndex === filteredEntries.length - 1;
391 | const newPrefixString = prefixString + (isLastEntry ? ' ' : '│ ');
392 | const newRelativePath = path.join(relativePathString, entryItem.name);
393 |
394 | treeOutputContent += prefixString + (isLastItem ? '└── ' : '├── ') + entryItem.name + '\n';
395 |
396 | // Only traverse deeper if we haven't reached maximumDepthLevel
397 | if (entryItem.isDirectory() && currentDepthLevel < maximumDepthLevel) {
398 | const subTreeResult = await generateDirectoryTreeRepresentation(
399 | path.join(directoryPath, entryItem.name),
400 | ignorePatternDefinitions,
401 | newPrefixString,
402 | isLastItem,
403 | newRelativePath,
404 | currentDepthLevel + 1,
405 | maximumDepthLevel
406 | );
407 |
408 | if (subTreeResult.resultSuccessful) {
409 | treeOutputContent += subTreeResult.resultData;
410 | } else {
411 | return subTreeResult; // Propagate error
412 | }
413 | }
414 | }
415 |
416 | return createSuccessResult(treeOutputContent);
417 | } catch (exceptionObject) {
418 | return createFailureResult(
419 | wrapExceptionAsStandardizedError(
420 | exceptionObject,
421 | `Failed to generate tree for directory: ${directoryPath}`
422 | )
423 | );
424 | }
425 | }
426 |
427 | /**
428 | * Ensures the directory for the output file exists, creating it if needed
429 | *
430 | * @param directoryPath - Path to the directory to check/create
431 | * @returns Promise resolving to operation result
432 | */
433 | async function ensureDirectoryExists(
434 | directoryPath: string
435 | ): Promise<OperationResult<boolean>> {
436 | try {
437 | await fs.access(directoryPath);
438 | return createSuccessResult(true);
439 | } catch {
440 | try {
441 | await fs.mkdir(directoryPath, { recursive: true });
442 | console.log(`Creating directory: ${directoryPath}`);
443 | return createSuccessResult(true);
444 | } catch (exceptionObject) {
445 | return createFailureResult(
446 | wrapExceptionAsStandardizedError(
447 | exceptionObject,
448 | `Failed to create directory: ${directoryPath}`
449 | )
450 | );
451 | }
452 | }
453 | }
454 |
455 | /**
456 | * Writes the generated tree content to a markdown file
457 | *
458 | * @param projectName - Name of the project
459 | * @param treeContent - Generated tree content
460 | * @param outputFilePath - Path where the output file should be written
461 | * @param maximumDepthValue - Maximum depth value that was applied
462 | * @returns Promise resolving to operation result
463 | */
464 | async function writeTreeContentToFile(
465 | projectName: string,
466 | treeContent: string,
467 | outputFilePath: string,
468 | maximumDepthValue: number
469 | ): Promise<OperationResult<TreeGenerationResult>> {
470 | try {
471 | const rootDirectoryPath = process.cwd();
472 | const outputDirectoryPath = path.dirname(path.resolve(rootDirectoryPath, outputFilePath));
473 |
474 | // Ensure output directory exists
475 | const directoryResult = await ensureDirectoryExists(outputDirectoryPath);
476 | if (!directoryResult.resultSuccessful) {
477 | return directoryResult;
478 | }
479 |
480 | // Format the timestamp
481 | const timestamp = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
482 |
483 | // Format the markdown content
484 | const markdownContent = `# ${projectName} - Directory Structure
485 |
486 | Generated on: ${timestamp}
487 |
488 | ${maximumDepthValue !== Infinity ? `_Depth limited to ${maximumDepthValue} levels_\n\n` : ''}
489 | \`\`\`
490 | ${projectName}
491 | ${treeContent}
492 | \`\`\`
493 |
494 | _Note: This tree excludes files and directories matched by .gitignore and common patterns like node_modules._
495 | `;
496 |
497 | // Write the content to the file
498 | await fs.writeFile(
499 | path.resolve(rootDirectoryPath, outputFilePath),
500 | markdownContent
501 | );
502 |
503 | return createSuccessResult({
504 | projectName,
505 | treeOutputFilePath: outputFilePath,
506 | treeContentLength: treeContent.length,
507 | maximumDepthApplied: maximumDepthValue,
508 | generationTimestamp: timestamp
509 | });
510 | } catch (exceptionObject) {
511 | return createFailureResult(
512 | wrapExceptionAsStandardizedError(
513 | exceptionObject,
514 | `Failed to write tree to file: ${outputFilePath}`
515 | )
516 | );
517 | }
518 | }
519 |
520 | /**
521 | * Main operation function that orchestrates the tree generation process
522 | *
523 | * @returns Promise that resolves when the operation completes
524 | */
525 | async function generateProjectDirectoryTree(): Promise<void> {
526 | try {
527 | // Parse command line arguments
528 | const commandLineArguments = process.argv.slice(2);
529 | const configurationSettings = parseCommandLineArguments(commandLineArguments);
530 |
531 | // Display help if requested
532 | if (configurationSettings.showHelpText) {
533 | console.log(HELP_TEXT);
534 | process.exit(0);
535 | }
536 |
537 | const rootDirectoryPath = process.cwd();
538 | const projectName = path.basename(rootDirectoryPath);
539 |
540 | // Load gitignore patterns
541 | const ignorePatternResult = await loadGitignorePatternDefinitions();
542 | if (!ignorePatternResult.resultSuccessful) {
543 | throw new Error(`Failed to load gitignore patterns: ${ignorePatternResult.resultError.errorMessage}`);
544 | }
545 |
546 | const ignorePatternDefinitions = ignorePatternResult.resultData;
547 |
548 | console.log(`Generating directory tree for: ${projectName}`);
549 | console.log(`Output path: ${configurationSettings.treeOutputFilePath}`);
550 |
551 | if (configurationSettings.maximumDirectoryDepth !== Infinity) {
552 | console.log(`Maximum depth: ${configurationSettings.maximumDirectoryDepth}`);
553 | }
554 |
555 | // Generate the tree structure
556 | const treeGenerationResult = await generateDirectoryTreeRepresentation(
557 | rootDirectoryPath,
558 | ignorePatternDefinitions,
559 | '',
560 | true,
561 | '',
562 | 0,
563 | configurationSettings.maximumDirectoryDepth
564 | );
565 |
566 | if (!treeGenerationResult.resultSuccessful) {
567 | throw new Error(`Failed to generate tree: ${treeGenerationResult.resultError.errorMessage}`);
568 | }
569 |
570 | // Write the tree to a file
571 | const writeResult = await writeTreeContentToFile(
572 | projectName,
573 | treeGenerationResult.resultData,
574 | configurationSettings.treeOutputFilePath,
575 | configurationSettings.maximumDirectoryDepth
576 | );
577 |
578 | if (!writeResult.resultSuccessful) {
579 | throw new Error(`Failed to write tree: ${writeResult.resultError.errorMessage}`);
580 | }
581 |
582 | console.log(`✓ Successfully generated tree structure in ${configurationSettings.treeOutputFilePath}`);
583 | } catch (exceptionObject) {
584 | const standardizedError = wrapExceptionAsStandardizedError(
585 | exceptionObject,
586 | 'Unhandled error during tree generation'
587 | );
588 |
589 | console.error(`× Error generating tree: ${standardizedError.errorMessage}`);
590 | process.exit(1);
591 | }
592 | }
593 |
594 | // -----------------------------------
595 | // Script Execution
596 | // -----------------------------------
597 |
598 | // Execute the main operation function
599 | generateProjectDirectoryTree();
600 |
```
--------------------------------------------------------------------------------
/src/utils/internal/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Provides a singleton Logger class that wraps Winston for file logging
3 | * and supports sending MCP (Model Context Protocol) `notifications/message`.
4 | * It handles different log levels compliant with RFC 5424 and MCP specifications.
5 | * @module src/utils/internal/logger
6 | */
7 | import fs from "fs";
8 | import path from "path";
9 | import winston from "winston";
10 | import TransportStream from "winston-transport";
11 | import { config } from "../../config/index.js";
12 | import { RequestContext } from "./requestContext.js";
13 |
14 | /**
15 | * Defines the supported logging levels based on RFC 5424 Syslog severity levels,
16 | * as used by the Model Context Protocol (MCP).
17 | * Levels are: 'debug'(7), 'info'(6), 'notice'(5), 'warning'(4), 'error'(3), 'crit'(2), 'alert'(1), 'emerg'(0).
18 | * Lower numeric values indicate higher severity.
19 | */
20 | export type McpLogLevel =
21 | | "debug"
22 | | "info"
23 | | "notice"
24 | | "warning"
25 | | "error"
26 | | "crit"
27 | | "alert"
28 | | "emerg";
29 |
30 | /**
31 | * Numeric severity mapping for MCP log levels (lower is more severe).
32 | * @private
33 | */
34 | const mcpLevelSeverity: Record<McpLogLevel, number> = {
35 | emerg: 0,
36 | alert: 1,
37 | crit: 2,
38 | error: 3,
39 | warning: 4,
40 | notice: 5,
41 | info: 6,
42 | debug: 7,
43 | };
44 |
45 | /**
46 | * Maps MCP log levels to Winston's core levels for file logging.
47 | * @private
48 | */
49 | const mcpToWinstonLevel: Record<
50 | McpLogLevel,
51 | "debug" | "info" | "warn" | "error"
52 | > = {
53 | debug: "debug",
54 | info: "info",
55 | notice: "info",
56 | warning: "warn",
57 | error: "error",
58 | crit: "error",
59 | alert: "error",
60 | emerg: "error",
61 | };
62 |
63 | /**
64 | * Interface for a more structured error object, primarily for formatting console logs.
65 | * @private
66 | */
67 | interface ErrorWithMessageAndStack {
68 | message?: string;
69 | stack?: string;
70 | [key: string]: any;
71 | }
72 |
73 | /**
74 | * Interface for the payload of an MCP log notification.
75 | * This structure is used when sending log data via MCP `notifications/message`.
76 | */
77 | export interface McpLogPayload {
78 | message: string;
79 | context?: RequestContext;
80 | error?: {
81 | message: string;
82 | stack?: string;
83 | };
84 | [key: string]: any;
85 | }
86 |
87 | /**
88 | * Type for the `data` parameter of the `McpNotificationSender` function.
89 | */
90 | export type McpNotificationData = McpLogPayload | Record<string, unknown>;
91 |
92 | /**
93 | * Defines the signature for a function that can send MCP log notifications.
94 | * This function is typically provided by the MCP server instance.
95 | * @param level - The severity level of the log message.
96 | * @param data - The payload of the log notification.
97 | * @param loggerName - An optional name or identifier for the logger/server.
98 | */
99 | export type McpNotificationSender = (
100 | level: McpLogLevel,
101 | data: McpNotificationData,
102 | loggerName?: string,
103 | ) => void;
104 |
105 | // The logsPath from config is already resolved and validated by src/config/index.ts
106 | const resolvedLogsDir = config.logsPath;
107 | const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic.
108 |
109 | /**
110 | * Creates the Winston console log format.
111 | * @returns The Winston log format for console output.
112 | * @private
113 | */
114 | function createWinstonConsoleFormat(): winston.Logform.Format {
115 | return winston.format.combine(
116 | winston.format.colorize(),
117 | winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
118 | winston.format.printf(({ timestamp, level, message, ...meta }) => {
119 | let metaString = "";
120 | const metaCopy = { ...meta };
121 | if (metaCopy.error && typeof metaCopy.error === "object") {
122 | const errorObj = metaCopy.error as ErrorWithMessageAndStack;
123 | if (errorObj.message) metaString += `\n Error: ${errorObj.message}`;
124 | if (errorObj.stack)
125 | metaString += `\n Stack: ${String(errorObj.stack)
126 | .split("\n")
127 | .map((l: string) => ` ${l}`)
128 | .join("\n")}`;
129 | delete metaCopy.error;
130 | }
131 | if (Object.keys(metaCopy).length > 0) {
132 | try {
133 | const remainingMetaJson = JSON.stringify(metaCopy, null, 2);
134 | if (remainingMetaJson !== "{}")
135 | metaString += `\n Meta: ${remainingMetaJson}`;
136 | } catch (stringifyError: unknown) {
137 | const errorMessage =
138 | stringifyError instanceof Error
139 | ? stringifyError.message
140 | : String(stringifyError);
141 | metaString += `\n Meta: [Error stringifying metadata: ${errorMessage}]`;
142 | }
143 | }
144 | return `${timestamp} ${level}: ${message}${metaString}`;
145 | }),
146 | );
147 | }
148 |
149 | /**
150 | * Singleton Logger class that wraps Winston for robust logging.
151 | * Supports file logging, conditional console logging, and MCP notifications.
152 | */
153 | export class Logger {
154 | private static instance: Logger;
155 | private winstonLogger?: winston.Logger;
156 | private initialized = false;
157 | private mcpNotificationSender?: McpNotificationSender;
158 | private currentMcpLevel: McpLogLevel = "info";
159 | private currentWinstonLevel: "debug" | "info" | "warn" | "error" = "info";
160 |
161 | private readonly MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH = 1024;
162 | private readonly LOG_FILE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
163 | private readonly LOG_MAX_FILES = 5;
164 |
165 | /** @private */
166 | private constructor() {}
167 |
168 | /**
169 | * Initializes the Winston logger instance.
170 | * Should be called once at application startup.
171 | * @param level - The initial minimum MCP log level.
172 | */
173 | public async initialize(level: McpLogLevel = "info"): Promise<void> {
174 | if (this.initialized) {
175 | this.warning("Logger already initialized.", {
176 | loggerSetup: true,
177 | requestId: "logger-init",
178 | timestamp: new Date().toISOString(),
179 | });
180 | return;
181 | }
182 | this.currentMcpLevel = level;
183 | this.currentWinstonLevel = mcpToWinstonLevel[level];
184 |
185 | let logsDirCreatedMessage: string | null = null; // This message is now informational as creation is handled by config
186 |
187 | if (isLogsDirSafe) {
188 | // Directory creation is handled by config/index.ts ensureDirectory.
189 | // We can log if it was newly created by checking if it existed before config ran,
190 | // but that's complex. For now, we assume config handled it.
191 | // If resolvedLogsDir is set, config ensures it exists.
192 | if (!fs.existsSync(resolvedLogsDir)) {
193 | // This case should ideally not be hit if config.logsPath is correctly set up and validated.
194 | // However, if it somehow occurs (e.g. dir deleted after config init but before logger init),
195 | // we attempt to create it.
196 | try {
197 | await fs.promises.mkdir(resolvedLogsDir, { recursive: true });
198 | logsDirCreatedMessage = `Re-created logs directory (should have been created by config): ${resolvedLogsDir}`;
199 | } catch (err: unknown) {
200 | if (process.stdout.isTTY) {
201 | const errorMessage =
202 | err instanceof Error ? err.message : String(err);
203 | console.error(
204 | `Error creating logs directory at ${resolvedLogsDir}: ${errorMessage}. File logging disabled.`,
205 | );
206 | }
207 | throw err; // Critical if logs dir cannot be ensured
208 | }
209 | }
210 | }
211 |
212 | const fileFormat = winston.format.combine(
213 | winston.format.timestamp(),
214 | winston.format.errors({ stack: true }),
215 | winston.format.json(),
216 | );
217 |
218 | const transports: TransportStream[] = [];
219 | const fileTransportOptions = {
220 | format: fileFormat,
221 | maxsize: this.LOG_FILE_MAX_SIZE,
222 | maxFiles: this.LOG_MAX_FILES,
223 | tailable: true,
224 | };
225 |
226 | if (isLogsDirSafe) {
227 | transports.push(
228 | new winston.transports.File({
229 | filename: path.join(resolvedLogsDir, "error.log"),
230 | level: "error",
231 | ...fileTransportOptions,
232 | }),
233 | new winston.transports.File({
234 | filename: path.join(resolvedLogsDir, "warn.log"),
235 | level: "warn",
236 | ...fileTransportOptions,
237 | }),
238 | new winston.transports.File({
239 | filename: path.join(resolvedLogsDir, "info.log"),
240 | level: "info",
241 | ...fileTransportOptions,
242 | }),
243 | new winston.transports.File({
244 | filename: path.join(resolvedLogsDir, "debug.log"),
245 | level: "debug",
246 | ...fileTransportOptions,
247 | }),
248 | new winston.transports.File({
249 | filename: path.join(resolvedLogsDir, "combined.log"),
250 | ...fileTransportOptions,
251 | }),
252 | );
253 | } else {
254 | if (process.stdout.isTTY) {
255 | console.warn(
256 | "File logging disabled as logsPath is not configured or invalid.",
257 | );
258 | }
259 | }
260 |
261 | this.winstonLogger = winston.createLogger({
262 | level: this.currentWinstonLevel,
263 | transports,
264 | exitOnError: false,
265 | });
266 |
267 | // Configure console transport after Winston logger is created
268 | const consoleStatus = this._configureConsoleTransport();
269 |
270 | const initialContext: RequestContext = {
271 | loggerSetup: true,
272 | requestId: "logger-init-deferred",
273 | timestamp: new Date().toISOString(),
274 | };
275 | if (logsDirCreatedMessage) {
276 | // Log if we had to re-create it
277 | this.info(logsDirCreatedMessage, initialContext);
278 | }
279 | if (consoleStatus.message) {
280 | this.info(consoleStatus.message, initialContext);
281 | }
282 |
283 | this.initialized = true;
284 | this.info(
285 | `Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
286 | {
287 | loggerSetup: true,
288 | requestId: "logger-post-init",
289 | timestamp: new Date().toISOString(),
290 | logsPathUsed: resolvedLogsDir,
291 | },
292 | );
293 | }
294 |
295 | /**
296 | * Sets the function used to send MCP 'notifications/message'.
297 | * @param sender - The function to call for sending notifications, or undefined to disable.
298 | */
299 | public setMcpNotificationSender(
300 | sender: McpNotificationSender | undefined,
301 | ): void {
302 | this.mcpNotificationSender = sender;
303 | const status = sender ? "enabled" : "disabled";
304 | this.info(`MCP notification sending ${status}.`, {
305 | loggerSetup: true,
306 | requestId: "logger-set-sender",
307 | timestamp: new Date().toISOString(),
308 | });
309 | }
310 |
311 | /**
312 | * Dynamically sets the minimum logging level.
313 | * @param newLevel - The new minimum MCP log level to set.
314 | */
315 | public setLevel(newLevel: McpLogLevel): void {
316 | const setLevelContext: RequestContext = {
317 | loggerSetup: true,
318 | requestId: "logger-set-level",
319 | timestamp: new Date().toISOString(),
320 | };
321 | if (!this.ensureInitialized()) {
322 | if (process.stdout.isTTY) {
323 | console.error("Cannot set level: Logger not initialized.");
324 | }
325 | return;
326 | }
327 | if (!(newLevel in mcpLevelSeverity)) {
328 | this.warning(
329 | `Invalid MCP log level provided: ${newLevel}. Level not changed.`,
330 | setLevelContext,
331 | );
332 | return;
333 | }
334 |
335 | const oldLevel = this.currentMcpLevel;
336 | this.currentMcpLevel = newLevel;
337 | this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
338 | if (this.winstonLogger) {
339 | // Ensure winstonLogger is defined
340 | this.winstonLogger.level = this.currentWinstonLevel;
341 | }
342 |
343 | const consoleStatus = this._configureConsoleTransport();
344 |
345 | if (oldLevel !== newLevel) {
346 | this.info(
347 | `Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
348 | setLevelContext,
349 | );
350 | if (
351 | consoleStatus.message &&
352 | consoleStatus.message !== "Console logging status unchanged."
353 | ) {
354 | this.info(consoleStatus.message, setLevelContext);
355 | }
356 | }
357 | }
358 |
359 | /**
360 | * Configures the console transport based on the current log level and TTY status.
361 | * Adds or removes the console transport as needed.
362 | * @returns {{ enabled: boolean, message: string | null }} Status of console logging.
363 | * @private
364 | */
365 | private _configureConsoleTransport(): {
366 | enabled: boolean;
367 | message: string | null;
368 | } {
369 | if (!this.winstonLogger) {
370 | return {
371 | enabled: false,
372 | message: "Cannot configure console: Winston logger not initialized.",
373 | };
374 | }
375 |
376 | const consoleTransport = this.winstonLogger.transports.find(
377 | (t) => t instanceof winston.transports.Console,
378 | );
379 | const shouldHaveConsole =
380 | this.currentMcpLevel === "debug" && process.stdout.isTTY;
381 | let message: string | null = null;
382 |
383 | if (shouldHaveConsole && !consoleTransport) {
384 | const consoleFormat = createWinstonConsoleFormat();
385 | this.winstonLogger.add(
386 | new winston.transports.Console({
387 | level: "debug", // Console always logs debug if enabled
388 | format: consoleFormat,
389 | }),
390 | );
391 | message = "Console logging enabled (level: debug, stdout is TTY).";
392 | } else if (!shouldHaveConsole && consoleTransport) {
393 | this.winstonLogger.remove(consoleTransport);
394 | message = "Console logging disabled (level not debug or stdout not TTY).";
395 | } else {
396 | message = "Console logging status unchanged.";
397 | }
398 | return { enabled: shouldHaveConsole, message };
399 | }
400 |
401 | /**
402 | * Gets the singleton instance of the Logger.
403 | * @returns The singleton Logger instance.
404 | */
405 | public static getInstance(): Logger {
406 | if (!Logger.instance) {
407 | Logger.instance = new Logger();
408 | }
409 | return Logger.instance;
410 | }
411 |
412 | /**
413 | * Ensures the logger has been initialized.
414 | * @returns True if initialized, false otherwise.
415 | * @private
416 | */
417 | private ensureInitialized(): boolean {
418 | if (!this.initialized || !this.winstonLogger) {
419 | if (process.stdout.isTTY) {
420 | console.warn("Logger not initialized; message dropped.");
421 | }
422 | return false;
423 | }
424 | return true;
425 | }
426 |
427 | /**
428 | * Centralized log processing method.
429 | * @param level - The MCP severity level of the message.
430 | * @param msg - The main log message.
431 | * @param context - Optional request context for the log.
432 | * @param error - Optional error object associated with the log.
433 | * @private
434 | */
435 | private log(
436 | level: McpLogLevel,
437 | msg: string,
438 | context?: RequestContext,
439 | error?: Error,
440 | ): void {
441 | if (!this.ensureInitialized()) return;
442 | if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
443 | return; // Do not log if message level is less severe than currentMcpLevel
444 | }
445 |
446 | const logData: Record<string, unknown> = { ...context };
447 | const winstonLevel = mcpToWinstonLevel[level];
448 |
449 | if (error) {
450 | this.winstonLogger!.log(winstonLevel, msg, { ...logData, error });
451 | } else {
452 | this.winstonLogger!.log(winstonLevel, msg, logData);
453 | }
454 |
455 | if (this.mcpNotificationSender) {
456 | const mcpDataPayload: McpLogPayload = { message: msg };
457 | if (context && Object.keys(context).length > 0)
458 | mcpDataPayload.context = context;
459 | if (error) {
460 | mcpDataPayload.error = { message: error.message };
461 | // Include stack trace in debug mode for MCP notifications, truncated for brevity
462 | if (this.currentMcpLevel === "debug" && error.stack) {
463 | mcpDataPayload.error.stack = error.stack.substring(
464 | 0,
465 | this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH,
466 | );
467 | }
468 | }
469 | try {
470 | const serverName =
471 | config?.mcpServerName ?? "MCP_SERVER_NAME_NOT_CONFIGURED";
472 | this.mcpNotificationSender(level, mcpDataPayload, serverName);
473 | } catch (sendError: unknown) {
474 | const errorMessage =
475 | sendError instanceof Error ? sendError.message : String(sendError);
476 | const internalErrorContext: RequestContext = {
477 | requestId: context?.requestId || "logger-internal-error",
478 | timestamp: new Date().toISOString(),
479 | originalLevel: level,
480 | originalMessage: msg,
481 | sendError: errorMessage,
482 | mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
483 | };
484 | this.winstonLogger!.error(
485 | "Failed to send MCP log notification",
486 | internalErrorContext,
487 | );
488 | }
489 | }
490 | }
491 |
492 | /** Logs a message at the 'debug' level. */
493 | public debug(msg: string, context?: RequestContext): void {
494 | this.log("debug", msg, context);
495 | }
496 |
497 | /** Logs a message at the 'info' level. */
498 | public info(msg: string, context?: RequestContext): void {
499 | this.log("info", msg, context);
500 | }
501 |
502 | /** Logs a message at the 'notice' level. */
503 | public notice(msg: string, context?: RequestContext): void {
504 | this.log("notice", msg, context);
505 | }
506 |
507 | /** Logs a message at the 'warning' level. */
508 | public warning(msg: string, context?: RequestContext): void {
509 | this.log("warning", msg, context);
510 | }
511 |
512 | /**
513 | * Logs a message at the 'error' level.
514 | * @param msg - The main log message.
515 | * @param err - Optional. Error object or RequestContext.
516 | * @param context - Optional. RequestContext if `err` is an Error.
517 | */
518 | public error(
519 | msg: string,
520 | err?: Error | RequestContext,
521 | context?: RequestContext,
522 | ): void {
523 | const errorObj = err instanceof Error ? err : undefined;
524 | const actualContext = err instanceof Error ? context : err;
525 | this.log("error", msg, actualContext, errorObj);
526 | }
527 |
528 | /**
529 | * Logs a message at the 'crit' (critical) level.
530 | * @param msg - The main log message.
531 | * @param err - Optional. Error object or RequestContext.
532 | * @param context - Optional. RequestContext if `err` is an Error.
533 | */
534 | public crit(
535 | msg: string,
536 | err?: Error | RequestContext,
537 | context?: RequestContext,
538 | ): void {
539 | const errorObj = err instanceof Error ? err : undefined;
540 | const actualContext = err instanceof Error ? context : err;
541 | this.log("crit", msg, actualContext, errorObj);
542 | }
543 |
544 | /**
545 | * Logs a message at the 'alert' level.
546 | * @param msg - The main log message.
547 | * @param err - Optional. Error object or RequestContext.
548 | * @param context - Optional. RequestContext if `err` is an Error.
549 | */
550 | public alert(
551 | msg: string,
552 | err?: Error | RequestContext,
553 | context?: RequestContext,
554 | ): void {
555 | const errorObj = err instanceof Error ? err : undefined;
556 | const actualContext = err instanceof Error ? context : err;
557 | this.log("alert", msg, actualContext, errorObj);
558 | }
559 |
560 | /**
561 | * Logs a message at the 'emerg' (emergency) level.
562 | * @param msg - The main log message.
563 | * @param err - Optional. Error object or RequestContext.
564 | * @param context - Optional. RequestContext if `err` is an Error.
565 | */
566 | public emerg(
567 | msg: string,
568 | err?: Error | RequestContext,
569 | context?: RequestContext,
570 | ): void {
571 | const errorObj = err instanceof Error ? err : undefined;
572 | const actualContext = err instanceof Error ? context : err;
573 | this.log("emerg", msg, actualContext, errorObj);
574 | }
575 |
576 | /**
577 | * Logs a message at the 'emerg' (emergency) level, typically for fatal errors.
578 | * @param msg - The main log message.
579 | * @param err - Optional. Error object or RequestContext.
580 | * @param context - Optional. RequestContext if `err` is an Error.
581 | */
582 | public fatal(
583 | msg: string,
584 | err?: Error | RequestContext,
585 | context?: RequestContext,
586 | ): void {
587 | const errorObj = err instanceof Error ? err : undefined;
588 | const actualContext = err instanceof Error ? context : err;
589 | this.log("emerg", msg, actualContext, errorObj);
590 | }
591 | }
592 |
593 | /**
594 | * The singleton instance of the Logger.
595 | * Use this instance for all logging operations.
596 | */
597 | export const logger = Logger.getInstance();
598 |
```
--------------------------------------------------------------------------------
/src/utils/security/sanitization.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Provides a comprehensive `Sanitization` class for various input cleaning and validation tasks.
3 | * This module includes utilities for sanitizing HTML, strings, URLs, file paths, JSON, numbers,
4 | * and for redacting sensitive information from data intended for logging.
5 | * @module src/utils/security/sanitization
6 | */
7 | import path from "path";
8 | import sanitizeHtml from "sanitize-html";
9 | import validator from "validator";
10 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
11 | import { logger, requestContextService } from "../index.js";
12 |
13 | /**
14 | * Defines options for path sanitization to control how file paths are processed and validated.
15 | */
16 | export interface PathSanitizeOptions {
17 | /** If provided, restricts sanitized paths to be relative to this directory. */
18 | rootDir?: string;
19 | /** If true, normalizes Windows backslashes to POSIX forward slashes. */
20 | toPosix?: boolean;
21 | /** If true, absolute paths are permitted (subject to `rootDir`). Default: false. */
22 | allowAbsolute?: boolean;
23 | }
24 |
25 | /**
26 | * Contains information about a path sanitization operation.
27 | */
28 | export interface SanitizedPathInfo {
29 | /** The final sanitized and normalized path string. */
30 | sanitizedPath: string;
31 | /** The original path string before any processing. */
32 | originalInput: string;
33 | /** True if the input path was absolute after initial normalization. */
34 | wasAbsolute: boolean;
35 | /** True if an absolute path was converted to relative due to `allowAbsolute: false`. */
36 | convertedToRelative: boolean;
37 | /** The effective options used for sanitization, including defaults. */
38 | optionsUsed: PathSanitizeOptions;
39 | }
40 |
41 | /**
42 | * Defines options for context-specific string sanitization.
43 | */
44 | export interface SanitizeStringOptions {
45 | /** The context in which the string will be used. 'javascript' is disallowed. */
46 | context?: "text" | "html" | "attribute" | "url" | "javascript";
47 | /** Custom allowed HTML tags if `context` is 'html'. */
48 | allowedTags?: string[];
49 | /** Custom allowed HTML attributes if `context` is 'html'. */
50 | allowedAttributes?: Record<string, string[]>;
51 | }
52 |
53 | /**
54 | * Configuration options for HTML sanitization, mirroring `sanitize-html` library options.
55 | */
56 | export interface HtmlSanitizeConfig {
57 | /** An array of allowed HTML tag names. */
58 | allowedTags?: string[];
59 | /** Specifies allowed attributes, either globally or per tag. */
60 | allowedAttributes?: sanitizeHtml.IOptions["allowedAttributes"];
61 | /** If true, HTML comments are preserved. */
62 | preserveComments?: boolean;
63 | /** Custom functions to transform tags during sanitization. */
64 | transformTags?: sanitizeHtml.IOptions["transformTags"];
65 | }
66 |
67 | /**
68 | * A singleton class providing various methods for input sanitization.
69 | * Aims to protect against common vulnerabilities like XSS and path traversal.
70 | */
71 | export class Sanitization {
72 | /** @private */
73 | private static instance: Sanitization;
74 |
75 | /**
76 | * Default list of field names considered sensitive for log redaction.
77 | * Case-insensitive matching is applied.
78 | * @private
79 | */
80 | private sensitiveFields: string[] = [
81 | "password",
82 | "token",
83 | "secret",
84 | "key",
85 | "apiKey",
86 | "auth",
87 | "credential",
88 | "jwt",
89 | "ssn",
90 | "credit",
91 | "card",
92 | "cvv",
93 | "authorization",
94 | ];
95 |
96 | /**
97 | * Default configuration for HTML sanitization.
98 | * @private
99 | */
100 | private defaultHtmlSanitizeConfig: HtmlSanitizeConfig = {
101 | allowedTags: [
102 | "h1",
103 | "h2",
104 | "h3",
105 | "h4",
106 | "h5",
107 | "h6",
108 | "p",
109 | "a",
110 | "ul",
111 | "ol",
112 | "li",
113 | "b",
114 | "i",
115 | "strong",
116 | "em",
117 | "strike",
118 | "code",
119 | "hr",
120 | "br",
121 | "div",
122 | "table",
123 | "thead",
124 | "tbody",
125 | "tr",
126 | "th",
127 | "td",
128 | "pre",
129 | ],
130 | allowedAttributes: {
131 | a: ["href", "name", "target"],
132 | img: ["src", "alt", "title", "width", "height"],
133 | "*": ["class", "id", "style"],
134 | },
135 | preserveComments: false,
136 | };
137 |
138 | /** @private */
139 | private constructor() {}
140 |
141 | /**
142 | * Retrieves the singleton instance of the `Sanitization` class.
143 | * @returns The singleton `Sanitization` instance.
144 | */
145 | public static getInstance(): Sanitization {
146 | if (!Sanitization.instance) {
147 | Sanitization.instance = new Sanitization();
148 | }
149 | return Sanitization.instance;
150 | }
151 |
152 | /**
153 | * Sets or extends the list of sensitive field names for log sanitization.
154 | * @param fields - An array of field names to add to the sensitive list.
155 | */
156 | public setSensitiveFields(fields: string[]): void {
157 | this.sensitiveFields = [
158 | ...new Set([
159 | ...this.sensitiveFields,
160 | ...fields.map((f) => f.toLowerCase()),
161 | ]),
162 | ];
163 | const logContext = requestContextService.createRequestContext({
164 | operation: "Sanitization.setSensitiveFields",
165 | newSensitiveFieldCount: this.sensitiveFields.length,
166 | });
167 | logger.debug(
168 | "Updated sensitive fields list for log sanitization",
169 | logContext,
170 | );
171 | }
172 |
173 | /**
174 | * Gets a copy of the current list of sensitive field names.
175 | * @returns An array of sensitive field names.
176 | */
177 | public getSensitiveFields(): string[] {
178 | return [...this.sensitiveFields];
179 | }
180 |
181 | /**
182 | * Sanitizes an HTML string by removing potentially malicious tags and attributes.
183 | * @param input - The HTML string to sanitize.
184 | * @param config - Optional custom configuration for `sanitize-html`.
185 | * @returns The sanitized HTML string. Returns an empty string if input is falsy.
186 | */
187 | public sanitizeHtml(input: string, config?: HtmlSanitizeConfig): string {
188 | if (!input) return "";
189 | const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
190 | const options: sanitizeHtml.IOptions = {
191 | allowedTags: effectiveConfig.allowedTags,
192 | allowedAttributes: effectiveConfig.allowedAttributes,
193 | transformTags: effectiveConfig.transformTags,
194 | };
195 | if (effectiveConfig.preserveComments) {
196 | options.allowedTags = [...(options.allowedTags || []), "!--"];
197 | }
198 | return sanitizeHtml(input, options);
199 | }
200 |
201 | /**
202 | * Sanitizes a string based on its intended context (e.g., HTML, URL, text).
203 | * **Important:** `context: 'javascript'` is disallowed due to security risks.
204 | *
205 | * @param input - The string to sanitize.
206 | * @param options - Options specifying the sanitization context.
207 | * @returns The sanitized string. Returns an empty string if input is falsy.
208 | * @throws {McpError} If `options.context` is 'javascript', or URL validation fails.
209 | */
210 | public sanitizeString(
211 | input: string,
212 | options: SanitizeStringOptions = {},
213 | ): string {
214 | if (!input) return "";
215 |
216 | switch (options.context) {
217 | case "html":
218 | return this.sanitizeHtml(input, {
219 | allowedTags: options.allowedTags,
220 | allowedAttributes: options.allowedAttributes
221 | ? this.convertAttributesFormat(options.allowedAttributes)
222 | : undefined,
223 | });
224 | case "attribute":
225 | return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
226 | case "url":
227 | if (
228 | !validator.isURL(input, {
229 | protocols: ["http", "https"],
230 | require_protocol: true,
231 | require_host: true,
232 | })
233 | ) {
234 | logger.warning(
235 | "Potentially invalid URL detected during string sanitization (context: url)",
236 | requestContextService.createRequestContext({
237 | operation: "Sanitization.sanitizeString.urlWarning",
238 | invalidUrlAttempt: input,
239 | }),
240 | );
241 | return "";
242 | }
243 | return validator.trim(input);
244 | case "javascript":
245 | logger.error(
246 | "Attempted JavaScript sanitization via sanitizeString, which is disallowed.",
247 | requestContextService.createRequestContext({
248 | operation: "Sanitization.sanitizeString.jsAttempt",
249 | inputSnippet: input.substring(0, 50),
250 | }),
251 | );
252 | throw new McpError(
253 | BaseErrorCode.VALIDATION_ERROR,
254 | "JavaScript sanitization is not supported through sanitizeString due to security risks.",
255 | );
256 | case "text":
257 | default:
258 | return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
259 | }
260 | }
261 |
262 | /**
263 | * Converts attribute format for `sanitizeHtml`.
264 | * @param attrs - Attributes in `{ tagName: ['attr1'] }` format.
265 | * @returns Attributes in `sanitize-html` expected format.
266 | * @private
267 | */
268 | private convertAttributesFormat(
269 | attrs: Record<string, string[]>,
270 | ): sanitizeHtml.IOptions["allowedAttributes"] {
271 | return attrs;
272 | }
273 |
274 | /**
275 | * Sanitizes a URL string by validating its format and protocol.
276 | * @param input - The URL string to sanitize.
277 | * @param allowedProtocols - Array of allowed URL protocols. Default: `['http', 'https']`.
278 | * @returns The sanitized and trimmed URL string.
279 | * @throws {McpError} If the URL is invalid or uses a disallowed protocol.
280 | */
281 | public sanitizeUrl(
282 | input: string,
283 | allowedProtocols: string[] = ["http", "https"],
284 | ): string {
285 | try {
286 | const trimmedInput = input.trim();
287 | if (
288 | !validator.isURL(trimmedInput, {
289 | protocols: allowedProtocols,
290 | require_protocol: true,
291 | require_host: true,
292 | })
293 | ) {
294 | throw new Error("Invalid URL format or protocol not in allowed list.");
295 | }
296 | if (trimmedInput.toLowerCase().startsWith("javascript:")) {
297 | throw new Error("JavaScript pseudo-protocol is not allowed in URLs.");
298 | }
299 | return trimmedInput;
300 | } catch (error) {
301 | throw new McpError(
302 | BaseErrorCode.VALIDATION_ERROR,
303 | error instanceof Error
304 | ? error.message
305 | : "Invalid or unsafe URL provided.",
306 | { input },
307 | );
308 | }
309 | }
310 |
311 | /**
312 | * Sanitizes a file path to prevent path traversal and normalize format.
313 | * @param input - The file path string to sanitize.
314 | * @param options - Options to control sanitization behavior.
315 | * @returns An object with the sanitized path and sanitization metadata.
316 | * @throws {McpError} If the path is invalid or unsafe.
317 | */
318 | public sanitizePath(
319 | input: string,
320 | options: PathSanitizeOptions = {},
321 | ): SanitizedPathInfo {
322 | const originalInput = input;
323 | const effectiveOptions: PathSanitizeOptions = {
324 | toPosix: options.toPosix ?? false,
325 | allowAbsolute: options.allowAbsolute ?? false,
326 | rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined,
327 | };
328 |
329 | let wasAbsoluteInitially = false;
330 | let convertedToRelative = false;
331 |
332 | try {
333 | if (!input || typeof input !== "string")
334 | throw new Error("Invalid path input: must be a non-empty string.");
335 | if (input.includes("\0"))
336 | throw new Error("Path contains null byte, which is disallowed.");
337 |
338 | let normalized = path.normalize(input);
339 | wasAbsoluteInitially = path.isAbsolute(normalized);
340 |
341 | if (effectiveOptions.toPosix) {
342 | normalized = normalized.replace(/\\/g, "/");
343 | }
344 |
345 | let finalSanitizedPath: string;
346 |
347 | if (effectiveOptions.rootDir) {
348 | const fullPath = path.resolve(effectiveOptions.rootDir, normalized);
349 | if (
350 | !fullPath.startsWith(effectiveOptions.rootDir + path.sep) &&
351 | fullPath !== effectiveOptions.rootDir
352 | ) {
353 | throw new Error(
354 | "Path traversal detected: attempts to escape the defined root directory.",
355 | );
356 | }
357 | finalSanitizedPath = path.relative(effectiveOptions.rootDir, fullPath);
358 | finalSanitizedPath =
359 | finalSanitizedPath === "" ? "." : finalSanitizedPath;
360 | if (
361 | path.isAbsolute(finalSanitizedPath) &&
362 | !effectiveOptions.allowAbsolute
363 | ) {
364 | throw new Error(
365 | "Path resolved to absolute outside root when absolute paths are disallowed.",
366 | );
367 | }
368 | } else {
369 | if (path.isAbsolute(normalized)) {
370 | if (!effectiveOptions.allowAbsolute) {
371 | finalSanitizedPath = normalized.replace(
372 | /^(?:[A-Za-z]:)?[/\\]+/,
373 | "",
374 | );
375 | convertedToRelative = true;
376 | } else {
377 | finalSanitizedPath = normalized;
378 | }
379 | } else {
380 | const resolvedAgainstCwd = path.resolve(normalized);
381 | const currentWorkingDir = path.resolve(".");
382 | if (
383 | !resolvedAgainstCwd.startsWith(currentWorkingDir + path.sep) &&
384 | resolvedAgainstCwd !== currentWorkingDir
385 | ) {
386 | throw new Error(
387 | "Relative path traversal detected (escapes current working directory context).",
388 | );
389 | }
390 | finalSanitizedPath = normalized;
391 | }
392 | }
393 |
394 | return {
395 | sanitizedPath: finalSanitizedPath,
396 | originalInput,
397 | wasAbsolute: wasAbsoluteInitially,
398 | convertedToRelative:
399 | wasAbsoluteInitially &&
400 | !path.isAbsolute(finalSanitizedPath) &&
401 | !effectiveOptions.allowAbsolute,
402 | optionsUsed: effectiveOptions,
403 | };
404 | } catch (error) {
405 | logger.warning(
406 | "Path sanitization error",
407 | requestContextService.createRequestContext({
408 | operation: "Sanitization.sanitizePath.error",
409 | originalPathInput: originalInput,
410 | pathOptionsUsed: effectiveOptions,
411 | errorMessage: error instanceof Error ? error.message : String(error),
412 | }),
413 | );
414 | throw new McpError(
415 | BaseErrorCode.VALIDATION_ERROR,
416 | error instanceof Error
417 | ? error.message
418 | : "Invalid or unsafe path provided.",
419 | { input: originalInput },
420 | );
421 | }
422 | }
423 |
424 | /**
425 | * Sanitizes a JSON string by parsing it to validate its format.
426 | * Optionally checks if the JSON string exceeds a maximum allowed size.
427 | * @template T The expected type of the parsed JSON object. Defaults to `unknown`.
428 | * @param input - The JSON string to sanitize/validate.
429 | * @param maxSize - Optional maximum allowed size of the JSON string in bytes.
430 | * @returns The parsed JavaScript object.
431 | * @throws {McpError} If input is not a string, too large, or invalid JSON.
432 | */
433 | public sanitizeJson<T = unknown>(input: string, maxSize?: number): T {
434 | try {
435 | if (typeof input !== "string")
436 | throw new Error("Invalid input: expected a JSON string.");
437 | if (maxSize !== undefined && Buffer.byteLength(input, "utf8") > maxSize) {
438 | throw new McpError(
439 | BaseErrorCode.VALIDATION_ERROR,
440 | `JSON string exceeds maximum allowed size of ${maxSize} bytes.`,
441 | { actualSize: Buffer.byteLength(input, "utf8"), maxSize },
442 | );
443 | }
444 | return JSON.parse(input) as T;
445 | } catch (error) {
446 | if (error instanceof McpError) throw error;
447 | throw new McpError(
448 | BaseErrorCode.VALIDATION_ERROR,
449 | error instanceof Error ? error.message : "Invalid JSON format.",
450 | {
451 | inputPreview:
452 | input.length > 100 ? `${input.substring(0, 100)}...` : input,
453 | },
454 | );
455 | }
456 | }
457 |
458 | /**
459 | * Validates and sanitizes a numeric input, converting strings to numbers.
460 | * Clamps the number to `min`/`max` if provided.
461 | * @param input - The number or string to validate and sanitize.
462 | * @param min - Minimum allowed value (inclusive).
463 | * @param max - Maximum allowed value (inclusive).
464 | * @returns The sanitized (and potentially clamped) number.
465 | * @throws {McpError} If input is not a valid number, NaN, or Infinity.
466 | */
467 | public sanitizeNumber(
468 | input: number | string,
469 | min?: number,
470 | max?: number,
471 | ): number {
472 | let value: number;
473 | if (typeof input === "string") {
474 | const trimmedInput = input.trim();
475 | if (trimmedInput === "" || !validator.isNumeric(trimmedInput)) {
476 | throw new McpError(
477 | BaseErrorCode.VALIDATION_ERROR,
478 | "Invalid number format: input is empty or not numeric.",
479 | { input },
480 | );
481 | }
482 | value = parseFloat(trimmedInput);
483 | } else if (typeof input === "number") {
484 | value = input;
485 | } else {
486 | throw new McpError(
487 | BaseErrorCode.VALIDATION_ERROR,
488 | "Invalid input type: expected number or string.",
489 | { input: String(input) },
490 | );
491 | }
492 |
493 | if (isNaN(value) || !isFinite(value)) {
494 | throw new McpError(
495 | BaseErrorCode.VALIDATION_ERROR,
496 | "Invalid number value (NaN or Infinity).",
497 | { input },
498 | );
499 | }
500 |
501 | let clamped = false;
502 | let originalValueForLog = value;
503 | if (min !== undefined && value < min) {
504 | value = min;
505 | clamped = true;
506 | }
507 | if (max !== undefined && value > max) {
508 | value = max;
509 | clamped = true;
510 | }
511 | if (clamped) {
512 | logger.debug(
513 | "Number clamped to range.",
514 | requestContextService.createRequestContext({
515 | operation: "Sanitization.sanitizeNumber.clamped",
516 | originalInput: String(input),
517 | parsedValue: originalValueForLog,
518 | minValue: min,
519 | maxValue: max,
520 | clampedValue: value,
521 | }),
522 | );
523 | }
524 | return value;
525 | }
526 |
527 | /**
528 | * Sanitizes input for logging by redacting sensitive fields.
529 | * Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
530 | * (case-insensitive substring match) with "[REDACTED]".
531 | * @param input - The input data to sanitize for logging.
532 | * @returns A sanitized (deep cloned) version of the input, safe for logging.
533 | * Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
534 | */
535 | public sanitizeForLogging(input: unknown): unknown {
536 | try {
537 | if (!input || typeof input !== "object") return input;
538 |
539 | const clonedInput =
540 | typeof structuredClone === "function"
541 | ? structuredClone(input)
542 | : JSON.parse(JSON.stringify(input));
543 | this.redactSensitiveFields(clonedInput);
544 | return clonedInput;
545 | } catch (error) {
546 | logger.error(
547 | "Error during log sanitization, returning placeholder.",
548 | requestContextService.createRequestContext({
549 | operation: "Sanitization.sanitizeForLogging.error",
550 | errorMessage: error instanceof Error ? error.message : String(error),
551 | }),
552 | );
553 | return "[Log Sanitization Failed]";
554 | }
555 | }
556 |
557 | /**
558 | * Recursively redacts sensitive fields in an object or array in place.
559 | * @param obj - The object or array to redact.
560 | * @private
561 | */
562 | private redactSensitiveFields(obj: unknown): void {
563 | if (!obj || typeof obj !== "object") return;
564 |
565 | if (Array.isArray(obj)) {
566 | obj.forEach((item) => this.redactSensitiveFields(item));
567 | return;
568 | }
569 |
570 | for (const key in obj) {
571 | if (Object.prototype.hasOwnProperty.call(obj, key)) {
572 | const value = (obj as Record<string, unknown>)[key];
573 | const lowerKey = key.toLowerCase();
574 | const isSensitive = this.sensitiveFields.some((field) =>
575 | lowerKey.includes(field),
576 | );
577 |
578 | if (isSensitive) {
579 | (obj as Record<string, unknown>)[key] = "[REDACTED]";
580 | } else if (value && typeof value === "object") {
581 | this.redactSensitiveFields(value);
582 | }
583 | }
584 | }
585 | }
586 | }
587 |
588 | /**
589 | * Singleton instance of the `Sanitization` class.
590 | * Use this for all input sanitization tasks.
591 | */
592 | export const sanitization = Sanitization.getInstance();
593 |
594 | /**
595 | * Convenience function calling `sanitization.sanitizeForLogging`.
596 | * @param input - The input data to sanitize.
597 | * @returns A sanitized version of the input, safe for logging.
598 | */
599 | export const sanitizeInputForLogging = (input: unknown): unknown =>
600 | sanitization.sanitizeForLogging(input);
601 |
```
--------------------------------------------------------------------------------
/src/mcp-server/transports/httpTransport.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Handles the setup and management of the Streamable HTTP MCP transport.
3 | * Implements the MCP Specification 2025-03-26 for Streamable HTTP.
4 | * This includes creating an Express server, configuring middleware (CORS, Authentication),
5 | * defining request routing for the single MCP endpoint (POST/GET/DELETE),
6 | * managing server-side sessions, handling Server-Sent Events (SSE) for streaming,
7 | * and binding to a network port with retry logic for port conflicts.
8 | *
9 | * Specification Reference:
10 | * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
11 | * @module src/mcp-server/transports/httpTransport
12 | */
13 |
14 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
17 | import express, { NextFunction, Request, Response } from "express";
18 | import http from "http";
19 | import { randomUUID } from "node:crypto";
20 | import { config } from "../../config/index.js";
21 | import {
22 | logger,
23 | requestContextService,
24 | } from "../../utils/internal/index.js"; // Corrected path
25 | import { RequestContext } from "../../utils/internal/requestContext.js"; // Explicit path for RequestContext
26 | import { mcpAuthMiddleware } from "./authentication/authMiddleware.js";
27 |
28 | /**
29 | * The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
30 | * Defaults to 3010 if not specified (default is managed by the config module).
31 | * @constant {number} HTTP_PORT
32 | * @private
33 | */
34 | const HTTP_PORT = config.mcpHttpPort;
35 |
36 | /**
37 | * The host address for the HTTP transport, configured via `MCP_HTTP_HOST` environment variable.
38 | * Defaults to '127.0.0.1' if not specified (default is managed by the config module).
39 | * MCP Spec Security Note: Recommends binding to localhost for local servers to minimize exposure.
40 | * @private
41 | */
42 | const HTTP_HOST = config.mcpHttpHost;
43 |
44 | /**
45 | * The single HTTP endpoint path for all MCP communication, as required by the MCP specification.
46 | * This endpoint supports POST, GET, DELETE, and OPTIONS methods.
47 | * @constant {string} MCP_ENDPOINT_PATH
48 | * @private
49 | */
50 | const MCP_ENDPOINT_PATH = "/mcp";
51 |
52 | /**
53 | * Maximum number of attempts to find an available port if the initial `HTTP_PORT` is in use.
54 | * The server will try ports sequentially: `HTTP_PORT`, `HTTP_PORT + 1`, ..., up to `MAX_PORT_RETRIES`.
55 | * @constant {number} MAX_PORT_RETRIES
56 | * @private
57 | */
58 | const MAX_PORT_RETRIES = 15;
59 |
60 | /**
61 | * Stores active `StreamableHTTPServerTransport` instances from the SDK, keyed by their session ID.
62 | * This is essential for routing subsequent HTTP requests (GET, DELETE, non-initialize POST)
63 | * to the correct stateful session transport instance.
64 | * @type {Record<string, StreamableHTTPServerTransport>}
65 | * @private
66 | */
67 | const httpTransports: Record<string, StreamableHTTPServerTransport> = {};
68 |
69 | /**
70 | * Checks if an incoming HTTP request's `Origin` header is permissible based on configuration.
71 | * MCP Spec Security: Servers MUST validate the `Origin` header for cross-origin requests.
72 | * This function checks the request's origin against the `config.mcpAllowedOrigins` list.
73 | * If the server is bound to localhost, requests from localhost or with no/null origin are also permitted.
74 | * Sets appropriate CORS headers (`Access-Control-Allow-Origin`, etc.) if the origin is allowed.
75 | *
76 | * @param req - The Express request object.
77 | * @param res - The Express response object.
78 | * @returns True if the origin is allowed, false otherwise.
79 | * @private
80 | */
81 | function isOriginAllowed(req: Request, res: Response): boolean {
82 | const origin = req.headers.origin;
83 | const host = req.hostname;
84 | const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(host);
85 | const allowedOrigins = config.mcpAllowedOrigins || [];
86 | const context = requestContextService.createRequestContext({
87 | operation: "isOriginAllowed",
88 | origin,
89 | host,
90 | isLocalhostBinding,
91 | allowedOrigins,
92 | });
93 | logger.debug("Checking origin allowance", context);
94 |
95 | const allowed =
96 | (origin && allowedOrigins.includes(origin)) ||
97 | (isLocalhostBinding && (!origin || origin === "null"));
98 |
99 | if (allowed && origin) {
100 | res.setHeader("Access-Control-Allow-Origin", origin);
101 | res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
102 | res.setHeader(
103 | "Access-Control-Allow-Headers",
104 | "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization",
105 | );
106 | res.setHeader("Access-Control-Allow-Credentials", "true");
107 | } else if (!allowed && origin) {
108 | logger.warning(`Origin denied: ${origin}`, context);
109 | }
110 | logger.debug(`Origin check result: ${allowed}`, { ...context, allowed });
111 | return allowed;
112 | }
113 |
114 | /**
115 | * Proactively checks if a specific network port is already in use.
116 | * @param port - The port number to check.
117 | * @param host - The host address to check the port on.
118 | * @param parentContext - Logging context from the caller.
119 | * @returns A promise that resolves to `true` if the port is in use, or `false` otherwise.
120 | * @private
121 | */
122 | async function isPortInUse(
123 | port: number,
124 | host: string,
125 | parentContext: RequestContext,
126 | ): Promise<boolean> {
127 | const checkContext = requestContextService.createRequestContext({
128 | ...parentContext,
129 | operation: "isPortInUse",
130 | port,
131 | host,
132 | });
133 | logger.debug(`Proactively checking port usability...`, checkContext);
134 | return new Promise((resolve) => {
135 | const tempServer = http.createServer();
136 | tempServer
137 | .once("error", (err: NodeJS.ErrnoException) => {
138 | if (err.code === "EADDRINUSE") {
139 | logger.debug(
140 | `Proactive check: Port confirmed in use (EADDRINUSE).`,
141 | checkContext,
142 | );
143 | resolve(true);
144 | } else {
145 | logger.debug(
146 | `Proactive check: Non-EADDRINUSE error encountered: ${err.message}`,
147 | { ...checkContext, errorCode: err.code },
148 | );
149 | resolve(false);
150 | }
151 | })
152 | .once("listening", () => {
153 | logger.debug(`Proactive check: Port is available.`, checkContext);
154 | tempServer.close(() => resolve(false));
155 | })
156 | .listen(port, host);
157 | });
158 | }
159 |
160 | /**
161 | * Attempts to start the HTTP server, retrying on incrementing ports if `EADDRINUSE` occurs.
162 | *
163 | * @param serverInstance - The Node.js HTTP server instance.
164 | * @param initialPort - The initial port number to try.
165 | * @param host - The host address to bind to.
166 | * @param maxRetries - Maximum number of additional ports to attempt.
167 | * @param parentContext - Logging context from the caller.
168 | * @returns A promise that resolves with the port number the server successfully bound to.
169 | * @throws {Error} If binding fails after all retries or for a non-EADDRINUSE error.
170 | * @private
171 | */
172 | function startHttpServerWithRetry(
173 | serverInstance: http.Server,
174 | initialPort: number,
175 | host: string,
176 | maxRetries: number,
177 | parentContext: RequestContext,
178 | ): Promise<number> {
179 | const startContext = requestContextService.createRequestContext({
180 | ...parentContext,
181 | operation: "startHttpServerWithRetry",
182 | initialPort,
183 | host,
184 | maxRetries,
185 | });
186 | logger.debug(`Attempting to start HTTP server...`, startContext);
187 | return new Promise(async (resolve, reject) => {
188 | let lastError: Error | null = null;
189 | for (let i = 0; i <= maxRetries; i++) {
190 | const currentPort = initialPort + i;
191 | const attemptContext = requestContextService.createRequestContext({
192 | ...startContext,
193 | port: currentPort,
194 | attempt: i + 1,
195 | maxAttempts: maxRetries + 1,
196 | });
197 | logger.debug(
198 | `Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`,
199 | attemptContext,
200 | );
201 |
202 | if (await isPortInUse(currentPort, host, attemptContext)) {
203 | logger.warning(
204 | `Proactive check detected port ${currentPort} is in use, retrying...`,
205 | attemptContext,
206 | );
207 | lastError = new Error(
208 | `EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`,
209 | );
210 | await new Promise((res) => setTimeout(res, 100));
211 | continue;
212 | }
213 |
214 | try {
215 | await new Promise<void>((listenResolve, listenReject) => {
216 | serverInstance
217 | .listen(currentPort, host, () => {
218 | const serverAddress = `http://${host}:${currentPort}${MCP_ENDPOINT_PATH}`;
219 | logger.info(
220 | `HTTP transport successfully listening on host ${host} at ${serverAddress}`,
221 | { ...attemptContext, address: serverAddress },
222 | );
223 | listenResolve();
224 | })
225 | .on("error", (err: NodeJS.ErrnoException) => {
226 | listenReject(err);
227 | });
228 | });
229 | resolve(currentPort);
230 | return;
231 | } catch (err: any) {
232 | lastError = err;
233 | logger.debug(
234 | `Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`,
235 | { ...attemptContext, errorCode: err.code, errorMessage: err.message },
236 | );
237 | if (err.code === "EADDRINUSE") {
238 | logger.warning(
239 | `Port ${currentPort} already in use (EADDRINUSE), retrying...`,
240 | attemptContext,
241 | );
242 | await new Promise((res) => setTimeout(res, 100));
243 | } else {
244 | logger.error(
245 | `Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`,
246 | { ...attemptContext, error: err.message },
247 | );
248 | reject(err);
249 | return;
250 | }
251 | }
252 | }
253 | logger.error(
254 | `Failed to bind to any port after ${maxRetries + 1} attempts. Last error: ${lastError?.message}`,
255 | { ...startContext, error: lastError?.message },
256 | );
257 | reject(
258 | lastError ||
259 | new Error("Failed to bind to any port after multiple retries."),
260 | );
261 | });
262 | }
263 |
264 | /**
265 | * Sets up and starts the Streamable HTTP transport layer for the MCP server.
266 | *
267 | * @param createServerInstanceFn - An asynchronous factory function that returns a new `McpServer` instance.
268 | * @param parentContext - Logging context from the main server startup process.
269 | * @returns A promise that resolves when the HTTP server is successfully listening.
270 | * @throws {Error} If the server fails to start after all port retries.
271 | */
272 | export async function startHttpTransport(
273 | createServerInstanceFn: () => Promise<McpServer>,
274 | parentContext: RequestContext,
275 | ): Promise<void> {
276 | const app = express();
277 | const transportContext = requestContextService.createRequestContext({
278 | ...parentContext,
279 | transportType: "HTTP",
280 | component: "HttpTransportSetup",
281 | });
282 | logger.debug(
283 | "Setting up Express app for HTTP transport...",
284 | transportContext,
285 | );
286 |
287 | app.use(express.json());
288 |
289 | app.options(MCP_ENDPOINT_PATH, (req, res) => {
290 | const optionsContext = requestContextService.createRequestContext({
291 | ...transportContext,
292 | operation: "handleOptions",
293 | origin: req.headers.origin,
294 | method: req.method,
295 | path: req.path,
296 | });
297 | logger.debug(
298 | `Received OPTIONS request for ${MCP_ENDPOINT_PATH}`,
299 | optionsContext,
300 | );
301 | if (isOriginAllowed(req, res)) {
302 | logger.debug(
303 | "OPTIONS request origin allowed, sending 204.",
304 | optionsContext,
305 | );
306 | res.sendStatus(204);
307 | } else {
308 | logger.debug(
309 | "OPTIONS request origin denied, sending 403.",
310 | optionsContext,
311 | );
312 | res.status(403).send("Forbidden: Invalid Origin");
313 | }
314 | });
315 |
316 | app.use((req: Request, res: Response, next: NextFunction) => {
317 | const securityContext = requestContextService.createRequestContext({
318 | ...transportContext,
319 | operation: "securityMiddleware",
320 | path: req.path,
321 | method: req.method,
322 | origin: req.headers.origin,
323 | });
324 | logger.debug(`Applying security middleware...`, securityContext);
325 | if (!isOriginAllowed(req, res)) {
326 | logger.debug("Origin check failed, sending 403.", securityContext);
327 | res.status(403).send("Forbidden: Invalid Origin");
328 | return;
329 | }
330 | res.setHeader("X-Content-Type-Options", "nosniff");
331 | res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
332 | res.setHeader(
333 | "Content-Security-Policy",
334 | "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'",
335 | );
336 | logger.debug("Security middleware passed.", securityContext);
337 | next();
338 | });
339 |
340 | app.use(mcpAuthMiddleware);
341 |
342 | app.post(MCP_ENDPOINT_PATH, async (req, res) => {
343 | const basePostContext = requestContextService.createRequestContext({
344 | ...transportContext,
345 | operation: "handlePost",
346 | method: "POST",
347 | path: req.path,
348 | origin: req.headers.origin,
349 | });
350 | logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, {
351 | ...basePostContext,
352 | headers: req.headers,
353 | bodyPreview: JSON.stringify(req.body).substring(0, 100),
354 | });
355 |
356 | const sessionId = req.headers["mcp-session-id"] as string | undefined;
357 | logger.debug(`Extracted session ID: ${sessionId}`, {
358 | ...basePostContext,
359 | sessionId,
360 | });
361 |
362 | let transport = sessionId ? httpTransports[sessionId] : undefined;
363 | logger.debug(`Found existing transport for session ID: ${!!transport}`, {
364 | ...basePostContext,
365 | sessionId,
366 | });
367 |
368 | const isInitReq = isInitializeRequest(req.body);
369 | logger.debug(`Is InitializeRequest: ${isInitReq}`, {
370 | ...basePostContext,
371 | sessionId,
372 | });
373 | const requestId = (req.body as any)?.id || null;
374 |
375 | try {
376 | if (isInitReq) {
377 | if (transport) {
378 | logger.warning(
379 | "Received InitializeRequest on an existing session ID. Closing old session and creating new.",
380 | { ...basePostContext, sessionId },
381 | );
382 | await transport.close();
383 | delete httpTransports[sessionId!];
384 | }
385 | logger.info("Handling Initialize Request: Creating new session...", {
386 | ...basePostContext,
387 | sessionId,
388 | });
389 |
390 | transport = new StreamableHTTPServerTransport({
391 | sessionIdGenerator: () => {
392 | const newId = randomUUID();
393 | logger.debug(`Generated new session ID: ${newId}`, basePostContext);
394 | return newId;
395 | },
396 | onsessioninitialized: (newId) => {
397 | logger.debug(
398 | `Session initialized callback triggered for ID: ${newId}`,
399 | { ...basePostContext, newSessionId: newId },
400 | );
401 | httpTransports[newId] = transport!;
402 | logger.info(`HTTP Session created: ${newId}`, {
403 | ...basePostContext,
404 | newSessionId: newId,
405 | });
406 | },
407 | });
408 |
409 | transport.onclose = () => {
410 | const closedSessionId = transport!.sessionId;
411 | if (closedSessionId) {
412 | logger.debug(
413 | `onclose handler triggered for session ID: ${closedSessionId}`,
414 | { ...basePostContext, closedSessionId },
415 | );
416 | delete httpTransports[closedSessionId];
417 | logger.info(`HTTP Session closed: ${closedSessionId}`, {
418 | ...basePostContext,
419 | closedSessionId,
420 | });
421 | } else {
422 | logger.debug(
423 | "onclose handler triggered for transport without session ID (likely init failure).",
424 | basePostContext,
425 | );
426 | }
427 | };
428 |
429 | logger.debug(
430 | "Creating McpServer instance for new session...",
431 | basePostContext,
432 | );
433 | const server = await createServerInstanceFn();
434 | logger.debug(
435 | "Connecting McpServer to new transport...",
436 | basePostContext,
437 | );
438 | await server.connect(transport);
439 | logger.debug("McpServer connected to transport.", basePostContext);
440 | } else if (!transport) {
441 | logger.warning(
442 | "Invalid or missing session ID for non-initialize POST request.",
443 | { ...basePostContext, sessionId },
444 | );
445 | res.status(404).json({
446 | jsonrpc: "2.0",
447 | error: { code: -32004, message: "Invalid or expired session ID" },
448 | id: requestId,
449 | });
450 | return;
451 | }
452 |
453 | const currentSessionId = transport.sessionId;
454 | logger.debug(
455 | `Processing POST request content for session ${currentSessionId}...`,
456 | { ...basePostContext, sessionId: currentSessionId, isInitReq },
457 | );
458 | await transport.handleRequest(req, res, req.body);
459 | logger.debug(
460 | `Finished processing POST request content for session ${currentSessionId}.`,
461 | { ...basePostContext, sessionId: currentSessionId },
462 | );
463 | } catch (err) {
464 | const errorSessionId = transport?.sessionId || sessionId;
465 | logger.error("Error handling POST request", {
466 | ...basePostContext,
467 | sessionId: errorSessionId,
468 | isInitReq,
469 | error: err instanceof Error ? err.message : String(err),
470 | stack: err instanceof Error ? err.stack : undefined,
471 | });
472 | if (!res.headersSent) {
473 | res.status(500).json({
474 | jsonrpc: "2.0",
475 | error: {
476 | code: -32603,
477 | message: "Internal server error during POST handling",
478 | },
479 | id: requestId,
480 | });
481 | }
482 | if (isInitReq && transport && !transport.sessionId) {
483 | logger.debug("Cleaning up transport after initialization failure.", {
484 | ...basePostContext,
485 | sessionId: errorSessionId,
486 | });
487 | await transport.close().catch((closeErr) =>
488 | logger.error("Error closing transport after init failure", {
489 | ...basePostContext,
490 | sessionId: errorSessionId,
491 | closeError: closeErr,
492 | }),
493 | );
494 | }
495 | }
496 | });
497 |
498 | const handleSessionReq = async (req: Request, res: Response) => {
499 | const method = req.method;
500 | const baseSessionReqContext = requestContextService.createRequestContext({
501 | ...transportContext,
502 | operation: `handle${method}`,
503 | method,
504 | path: req.path,
505 | origin: req.headers.origin,
506 | });
507 | logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, {
508 | ...baseSessionReqContext,
509 | headers: req.headers,
510 | });
511 |
512 | const sessionId = req.headers["mcp-session-id"] as string | undefined;
513 | logger.debug(`Extracted session ID: ${sessionId}`, {
514 | ...baseSessionReqContext,
515 | sessionId,
516 | });
517 |
518 | const transport = sessionId ? httpTransports[sessionId] : undefined;
519 | logger.debug(`Found existing transport for session ID: ${!!transport}`, {
520 | ...baseSessionReqContext,
521 | sessionId,
522 | });
523 |
524 | if (!transport) {
525 | logger.warning(`Session not found for ${method} request`, {
526 | ...baseSessionReqContext,
527 | sessionId,
528 | });
529 | res.status(404).json({
530 | jsonrpc: "2.0",
531 | error: { code: -32004, message: "Session not found or expired" },
532 | id: null, // Or a relevant request identifier if available from context
533 | });
534 | return;
535 | }
536 |
537 | try {
538 | logger.debug(
539 | `Delegating ${method} request to transport for session ${sessionId}...`,
540 | { ...baseSessionReqContext, sessionId },
541 | );
542 | await transport.handleRequest(req, res);
543 | logger.info(
544 | `Successfully handled ${method} request for session ${sessionId}`,
545 | { ...baseSessionReqContext, sessionId },
546 | );
547 | } catch (err) {
548 | logger.error(
549 | `Error handling ${method} request for session ${sessionId}`,
550 | {
551 | ...baseSessionReqContext,
552 | sessionId,
553 | error: err instanceof Error ? err.message : String(err),
554 | stack: err instanceof Error ? err.stack : undefined,
555 | },
556 | );
557 | if (!res.headersSent) {
558 | res.status(500).json({
559 | jsonrpc: "2.0",
560 | error: { code: -32603, message: "Internal Server Error" },
561 | id: null, // Or a relevant request identifier
562 | });
563 | }
564 | }
565 | };
566 | app.get(MCP_ENDPOINT_PATH, handleSessionReq);
567 | app.delete(MCP_ENDPOINT_PATH, handleSessionReq);
568 |
569 | logger.debug("Creating HTTP server instance...", transportContext);
570 | const serverInstance = http.createServer(app);
571 | try {
572 | logger.debug(
573 | "Attempting to start HTTP server with retry logic...",
574 | transportContext,
575 | );
576 | const actualPort = await startHttpServerWithRetry(
577 | serverInstance,
578 | config.mcpHttpPort,
579 | config.mcpHttpHost,
580 | MAX_PORT_RETRIES,
581 | transportContext,
582 | );
583 |
584 | let serverAddressLog = `http://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
585 | let productionNote = "";
586 | if (config.environment === "production") {
587 | // The server itself runs HTTP, but it's expected to be behind an HTTPS proxy in production.
588 | // The log reflects the effective public-facing URL.
589 | serverAddressLog = `https://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
590 | productionNote = ` (via HTTPS, ensure reverse proxy is configured)`;
591 | }
592 |
593 | if (process.stdout.isTTY) {
594 | console.log(
595 | `\n🚀 MCP Server running in HTTP mode at: ${serverAddressLog}${productionNote}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`,
596 | );
597 | }
598 | } catch (err) {
599 | logger.fatal("HTTP server failed to start after multiple port retries.", {
600 | ...transportContext,
601 | error: err instanceof Error ? err.message : String(err),
602 | });
603 | throw err;
604 | }
605 | }
606 |
```