mcp-ts-template 1.4.4 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol SDK](https://img.shields.io/badge/MCP%20SDK-1.12.1-green.svg)](https://github.com/modelcontextprotocol/typescript-sdk)
5
5
  [![MCP Spec Version](https://img.shields.io/badge/MCP%20Spec-2025--03--26-lightgrey.svg)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/changelog.mdx)
6
- [![Version](https://img.shields.io/badge/Version-1.4.4-blue.svg)](./CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/Version-1.4.6-blue.svg)](./CHANGELOG.md)
7
7
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
8
8
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/mcp-ts-template/issues)
9
9
  [![GitHub](https://img.shields.io/github/stars/cyanheads/mcp-ts-template?style=social)](https://github.com/cyanheads/mcp-ts-template)
@@ -40,18 +40,8 @@ export function createStdioClientTransport(transportConfig, parentContext) {
40
40
  // Inheriting all of process.env is a security risk.
41
41
  // If specific variables from process.env are needed, they should be explicitly
42
42
  // listed in the mcp-config.json for that server or handled by an allowlist mechanism.
43
- const filteredProcessEnv = {};
44
- for (const key in process.env) {
45
- if (Object.prototype.hasOwnProperty.call(process.env, key)) {
46
- const value = process.env[key];
47
- if (value !== undefined) {
48
- filteredProcessEnv[key] = value;
49
- }
50
- }
51
- }
52
43
  const serverSpecificEnv = {
53
- ...filteredProcessEnv, // Inherit filtered parent process environment
54
- ...(transportConfig.env || {}), // Apply server-specific overrides/additions
44
+ ...(transportConfig.env || {}), // Only use explicitly defined env vars from config
55
45
  };
56
46
  logger.debug("Creating StdioClientTransport with merged environment", {
57
47
  ...context,
@@ -5,9 +5,8 @@
5
5
  * @module src/mcp-server/tools/catFactFetcher/catFactFetcherLogic
6
6
  */
7
7
  import { z } from "zod";
8
- import { logger, fetchWithTimeout, // Added import
9
- } from "../../../utils/index.js";
10
- import { McpError, BaseErrorCode } from "../../../types-global/errors.js";
8
+ import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
9
+ import { fetchWithTimeout, logger, } from "../../../utils/index.js";
11
10
  /**
12
11
  * Asynchronously fetches a random cat fact from the Cat Fact Ninja API.
13
12
  * @param maxLength - Optional maximum length for the cat fact.
@@ -16,11 +15,13 @@ import { McpError, BaseErrorCode } from "../../../types-global/errors.js";
16
15
  * @throws {McpError} If the API request fails or returns an error.
17
16
  */
18
17
  async function fetchRandomCatFactFromApi(maxLength, context) {
18
+ // Best practice: API URLs should be configurable, e.g., via environment variables or a config file.
19
19
  let apiUrl = "https://catfact.ninja/fact";
20
20
  if (maxLength !== undefined) {
21
21
  apiUrl += `?max_length=${maxLength}`;
22
22
  }
23
23
  logger.info(`Fetching random cat fact from: ${apiUrl}`, context);
24
+ // Best practice: Timeouts should be configurable.
24
25
  const CAT_FACT_API_TIMEOUT_MS = 5000;
25
26
  try {
26
27
  // Use the fetchWithTimeout utility
@@ -31,7 +32,7 @@ async function fetchRandomCatFactFromApi(maxLength, context) {
31
32
  throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Cat Fact API request to ${apiUrl} failed: ${response.status} ${response.statusText}`, {
32
33
  ...context,
33
34
  httpStatusCode: response.status,
34
- responseBodyBrief: errorText.substring(0, 200), // Log a snippet of the response
35
+ responseBodyBrief: errorText,
35
36
  errorSource: "CatFactApiNonOkResponse",
36
37
  });
37
38
  }
@@ -2,9 +2,10 @@
2
2
  * @fileoverview Registration for the fetch_image_test MCP tool.
3
3
  * @module src/mcp-server/tools/imageTest/registration
4
4
  */
5
- import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
6
- import { ErrorHandler, logger, // Added logger import
7
- requestContextService, } from "../../../utils/index.js";
5
+ import { BaseErrorCode } from "../../../types-global/errors.js";
6
+ import { ErrorHandler, logger, requestContextService,
7
+ // RequestContext, // No longer needed directly in this function signature
8
+ } from "../../../utils/index.js";
8
9
  import { FetchImageTestInputSchema, fetchImageTestLogic, } from "./logic.js";
9
10
  /**
10
11
  * Registers the fetch_image_test tool with the MCP server.
@@ -12,27 +13,35 @@ import { FetchImageTestInputSchema, fetchImageTestLogic, } from "./logic.js";
12
13
  */
13
14
  export function registerFetchImageTestTool(server) {
14
15
  const operation = "registerFetchImageTestTool";
15
- const context = requestContextService.createRequestContext({ operation });
16
- try {
16
+ const registrationContext = requestContextService.createRequestContext({
17
+ operation,
18
+ });
19
+ ErrorHandler.tryCatch(() => {
17
20
  server.tool("fetch_image_test", "Fetches a random cat image from an external API (cataas.com) and returns it as a blob. Useful for testing image handling capabilities.", FetchImageTestInputSchema.shape, // CRITICAL: Pass the .shape
18
21
  async (validatedInput, mcpProvidedContext) => {
22
+ // Create a new context for each tool invocation.
23
+ // Link to an initial request ID if available from mcpProvidedContext or use registration context's ID as a fallback.
24
+ const parentRequestId = mcpProvidedContext?.requestId || registrationContext.requestId;
19
25
  const handlerRequestContext = requestContextService.createRequestContext({
20
- parentRequestId: context.requestId, // Optional: link to registration context
26
+ parentRequestId,
21
27
  operation: "fetchImageTestToolHandler",
22
- mcpToolContext: mcpProvidedContext, // Context from MCP SDK during call
28
+ toolName: "fetch_image_test",
29
+ // Include any other relevant details from mcpProvidedContext if needed
30
+ // For example, if mcpProvidedContext itself is a RequestContext or has useful fields:
31
+ // ...(typeof mcpProvidedContext === 'object' && mcpProvidedContext !== null ? mcpProvidedContext : {}),
23
32
  });
24
33
  return fetchImageTestLogic(validatedInput, handlerRequestContext);
25
34
  });
26
- logger.notice(`Tool 'fetch_image_test' registered.`, context);
27
- }
28
- catch (error) {
29
- ErrorHandler.handleError(new McpError(BaseErrorCode.INITIALIZATION_FAILED, `Failed to register 'fetch_image_test'`, {
30
- originalError: error instanceof Error ? error.message : String(error),
31
- }), {
32
- operation,
33
- context,
34
- errorCode: BaseErrorCode.INITIALIZATION_FAILED,
35
- critical: true,
36
- });
37
- }
35
+ logger.notice(`Tool 'fetch_image_test' registered.`, registrationContext);
36
+ }, {
37
+ operation, // Operation name for error handling
38
+ context: registrationContext, // Context for error handling
39
+ errorCode: BaseErrorCode.INITIALIZATION_FAILED, // Default error code if registration fails
40
+ critical: true, // Registration failures are typically critical
41
+ // Note: `rethrow` is not an option for `ErrorHandler.tryCatch`.
42
+ // `tryCatch` internally calls `ErrorHandler.handleError` with `rethrow: true`.
43
+ // If non-rethrowing behavior is essential for a specific registration,
44
+ // a manual try/catch block calling `ErrorHandler.handleError` with `rethrow: false` would be needed.
45
+ // For consistency with the typical use of `tryCatch`, this assumes rethrowing is acceptable.
46
+ });
38
47
  }
@@ -13,8 +13,8 @@
13
13
  * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
14
14
  * @module src/mcp-server/transports/authentication/authMiddleware
15
15
  */
16
- import { NextFunction, Request, Response } from "express";
17
16
  import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
17
+ import { NextFunction, Request, Response } from "express";
18
18
  declare global {
19
19
  namespace Express {
20
20
  interface Request {
@@ -111,18 +111,29 @@ export function mcpAuthMiddleware(req, res, next) {
111
111
  scopesFromToken = [decoded.scope.trim()];
112
112
  }
113
113
  else if (scopesFromToken.length === 0 && decoded.scope.trim() === "") {
114
- // If scope is an empty string, treat as no scopes rather than erroring, or use a default.
115
- // Depending on strictness, could also error here. For now, allow empty array if scope was empty string.
114
+ // If scope is an empty string, treat as no scopes.
115
+ // This will now lead to an error if scopes are considered mandatory.
116
116
  logger.debug("JWT 'scope' claim was an empty string, resulting in empty scopes array.", context);
117
117
  }
118
118
  }
119
119
  else {
120
120
  // If scopes are strictly mandatory and not found or invalid format
121
- logger.warning("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.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
122
- scopesFromToken = []; // Default to empty array if scopes are mandatory but not found/invalid
123
- // Or, if truly mandatory and must be non-empty:
124
- // res.status(401).json({ error: "Unauthorized: Invalid token, missing or invalid scopes." });
125
- // return;
121
+ logger.warning("Authentication failed: JWT 'scp' or 'scope' claim is missing, not an array of strings, or not a valid space-separated string.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
122
+ res.status(401).json({
123
+ error: "Unauthorized: Invalid token, missing or invalid scopes.",
124
+ });
125
+ return;
126
+ }
127
+ // If, after parsing, scopesFromToken is empty and scopes are considered mandatory for any operation.
128
+ // This check assumes that all valid tokens must have at least one scope.
129
+ // If some tokens are legitimately allowed to have no scopes for certain operations,
130
+ // this check might need to be adjusted or handled downstream.
131
+ if (scopesFromToken.length === 0) {
132
+ logger.warning("Authentication failed: Token resulted in an empty scope array, and scopes are required.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
133
+ res.status(401).json({
134
+ error: "Unauthorized: Token must contain valid, non-empty scopes.",
135
+ });
136
+ return;
126
137
  }
127
138
  // Construct req.auth with only the properties defined in SDK's AuthInfo
128
139
  // All other claims from 'decoded' are not part of req.auth for type safety.
@@ -16,7 +16,8 @@ import express from "express";
16
16
  import http from "http";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { config } from "../../config/index.js";
19
- import { logger, requestContextService, } from "../../utils/index.js";
19
+ import { BaseErrorCode, McpError } from "../../types-global/errors.js"; // For McpError type check
20
+ import { logger, rateLimiter, requestContextService, } from "../../utils/index.js";
20
21
  import { mcpAuthMiddleware } from "./authentication/authMiddleware.js";
21
22
  /**
22
23
  * The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
@@ -68,28 +69,52 @@ const httpTransports = {};
68
69
  */
69
70
  function isOriginAllowed(req, res) {
70
71
  const origin = req.headers.origin;
71
- const host = req.hostname;
72
- const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(host);
72
+ // Determine if the server is bound to a localhost interface using the configured HTTP_HOST.
73
+ const isLocalhostBinding = ["127.0.0.1", "::1", "localhost"].includes(config.mcpHttpHost);
73
74
  const allowedOrigins = config.mcpAllowedOrigins || [];
74
75
  const context = requestContextService.createRequestContext({
75
76
  operation: "isOriginAllowed",
76
- origin,
77
- host,
77
+ requestOrigin: origin, // Use a more descriptive key for the request's origin
78
+ serverBindingHost: config.mcpHttpHost, // Log the server's binding host for context
78
79
  isLocalhostBinding,
79
- allowedOrigins,
80
+ configuredAllowedOrigins: allowedOrigins, // Use a more descriptive key
80
81
  });
81
82
  logger.debug("Checking origin allowance", context);
82
- const allowed = (origin && allowedOrigins.includes(origin)) ||
83
- (isLocalhostBinding && (!origin || origin === "null"));
83
+ const allowed = (origin && allowedOrigins.includes(origin)) || // Origin is explicitly in the whitelist
84
+ (isLocalhostBinding && (!origin || origin === "null")); // Or server is localhost and origin is missing or "null"
84
85
  if (allowed && origin) {
85
- res.setHeader("Access-Control-Allow-Origin", origin);
86
+ // If the origin is "null", we must not set Access-Control-Allow-Origin to "null"
87
+ // when Access-Control-Allow-Credentials is also true (which it is in this block).
88
+ // This combination is a security risk. By not setting ACAO to "null",
89
+ // a credentialed request from a "null" origin will likely be blocked by the browser, which is safer.
90
+ if (origin === "null") {
91
+ logger.debug(`Origin is "null". Not setting Access-Control-Allow-Origin to "null" due to Access-Control-Allow-Credentials being true.`, context);
92
+ // Note: Access-Control-Allow-Origin is NOT set to "null".
93
+ }
94
+ else {
95
+ // For any other allowed, non-null origin, reflect it.
96
+ res.setHeader("Access-Control-Allow-Origin", origin);
97
+ }
98
+ // These headers are set for any allowed & originated request.
99
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
100
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization");
101
+ res.setHeader("Access-Control-Allow-Credentials", "true");
102
+ }
103
+ else if (allowed && !origin && isLocalhostBinding) {
104
+ // Case: No origin header, but server is localhost-bound (e.g., same-origin, curl).
105
+ // 'allowed' is true. We can allow credentials. ACAO is not strictly needed for non-browser or same-origin.
106
+ // If it's a browser in a weird state sending no origin but expecting CORS for credentials,
107
+ // it will likely fail without ACAO, which is fine.
108
+ logger.debug(`No origin header, but request allowed due to localhost binding. Setting Access-Control-Allow-Credentials to true.`, context);
86
109
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
87
110
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization");
88
111
  res.setHeader("Access-Control-Allow-Credentials", "true");
89
112
  }
90
113
  else if (!allowed && origin) {
114
+ // Origin was present but not allowed by any rule.
91
115
  logger.warning(`Origin denied: ${origin}`, context);
92
116
  }
117
+ // If !allowed and !origin, no specific CORS headers needed, request proceeds to be potentially denied by other logic or auth.
93
118
  logger.debug(`Origin check result: ${allowed}`, { ...context, allowed });
94
119
  return allowed;
95
120
  }
@@ -218,6 +243,48 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
218
243
  });
219
244
  logger.debug("Setting up Express app for HTTP transport...", transportContext);
220
245
  app.use(express.json());
246
+ // Rate Limiting Middleware
247
+ // Apply this before more expensive operations like auth or request processing.
248
+ const httpRateLimitMiddleware = (req, res, next) => {
249
+ // Determine a reliable key for rate limiting. Prioritize req.ip,
250
+ // then fall back to req.socket.remoteAddress, and finally to a default string.
251
+ const rateLimitKey = req.ip || req.socket.remoteAddress || "unknown_ip_for_rate_limit";
252
+ const context = requestContextService.createRequestContext({
253
+ operation: "httpRateLimitCheck",
254
+ ipAddress: rateLimitKey, // Log the actual key being used
255
+ method: req.method,
256
+ path: req.path,
257
+ });
258
+ try {
259
+ rateLimiter.check(rateLimitKey, context); // Use the guaranteed string key
260
+ logger.debug("Rate limit check passed.", context);
261
+ next();
262
+ }
263
+ catch (error) {
264
+ if (error instanceof McpError && error.code === BaseErrorCode.RATE_LIMITED) {
265
+ logger.warning(`Rate limit exceeded for IP: ${rateLimitKey}`, {
266
+ ...context,
267
+ errorMessage: error.message,
268
+ details: error.details,
269
+ });
270
+ res.status(429).json({
271
+ jsonrpc: "2.0",
272
+ error: { code: -32000, message: "Too Many Requests" }, // Generic JSON-RPC error for rate limit
273
+ id: req.body?.id || null,
274
+ });
275
+ }
276
+ else {
277
+ // For other errors, pass them to the default error handler
278
+ logger.error("Unexpected error in rate limit middleware", {
279
+ ...context,
280
+ error: error instanceof Error ? error.message : String(error),
281
+ });
282
+ next(error);
283
+ }
284
+ }
285
+ };
286
+ // Apply rate limiter to the MCP endpoint for all methods
287
+ app.use(MCP_ENDPOINT_PATH, httpRateLimitMiddleware);
221
288
  app.options(MCP_ENDPOINT_PATH, (req, res) => {
222
289
  const optionsContext = requestContextService.createRequestContext({
223
290
  ...transportContext,
@@ -1,10 +1,3 @@
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
1
  import path from "path";
9
2
  import winston from "winston";
10
3
  import { config } from "../../config/index.js";
@@ -105,34 +98,13 @@ export class Logger {
105
98
  return;
106
99
  }
107
100
  // Set initialized to true at the beginning of the initialization process.
108
- // If initialization fails critically later, the logger might be in an inconsistent state,
109
- // but this will prevent "Logger not initialized" messages from within initialize() itself.
110
101
  this.initialized = true;
111
102
  this.currentMcpLevel = level;
112
103
  this.currentWinstonLevel = mcpToWinstonLevel[level];
113
- let logsDirCreatedMessage = null; // This message is now informational as creation is handled by config
114
- if (isLogsDirSafe) {
115
- // Directory creation is handled by config/index.ts ensureDirectory.
116
- // We can log if it was newly created by checking if it existed before config ran,
117
- // but that's complex. For now, we assume config handled it.
118
- // If resolvedLogsDir is set, config ensures it exists.
119
- if (!fs.existsSync(resolvedLogsDir)) {
120
- // This case should ideally not be hit if config.logsPath is correctly set up and validated.
121
- // However, if it somehow occurs (e.g. dir deleted after config init but before logger init),
122
- // we attempt to create it.
123
- try {
124
- await fs.promises.mkdir(resolvedLogsDir, { recursive: true });
125
- logsDirCreatedMessage = `Re-created logs directory (should have been created by config): ${resolvedLogsDir}`;
126
- }
127
- catch (err) {
128
- if (process.stdout.isTTY) {
129
- const errorMessage = err instanceof Error ? err.message : String(err);
130
- console.error(`Error creating logs directory at ${resolvedLogsDir}: ${errorMessage}. File logging disabled.`);
131
- }
132
- throw err; // Critical if logs dir cannot be ensured
133
- }
134
- }
135
- }
104
+ // The logs directory (config.logsPath / resolvedLogsDir) is expected to be created and validated
105
+ // by the configuration module (src/config/index.ts) before logger initialization.
106
+ // If isLogsDirSafe is true, we assume resolvedLogsDir exists and is usable.
107
+ // No redundant directory creation logic here.
136
108
  const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
137
109
  const transports = [];
138
110
  const fileTransportOptions = {
@@ -180,23 +152,17 @@ export class Logger {
180
152
  requestId: "logger-init-deferred",
181
153
  timestamp: new Date().toISOString(),
182
154
  };
183
- if (logsDirCreatedMessage) {
184
- // Log if we had to re-create it
185
- this.info(logsDirCreatedMessage, initialContext);
186
- }
155
+ // Removed logging of logsDirCreatedMessage as it's no longer set
187
156
  if (consoleStatus.message) {
188
157
  this.info(consoleStatus.message, initialContext);
189
158
  }
190
- this.initialized = true;
159
+ this.initialized = true; // Ensure this is set after successful setup
191
160
  this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`, {
192
161
  loggerSetup: true,
193
162
  requestId: "logger-post-init",
194
163
  timestamp: new Date().toISOString(),
195
164
  logsPathUsed: resolvedLogsDir,
196
165
  });
197
- // Note: If a critical error occurs during initialization after this point,
198
- // this.initialized remains true. Consider adding a try/catch around the core
199
- // initialization logic to set this.initialized = false on failure if needed.
200
166
  }
201
167
  /**
202
168
  * Sets the function used to send MCP 'notifications/message'.
@@ -85,6 +85,7 @@ export declare class IdGenerator {
85
85
  * @param id - The ID string to validate.
86
86
  * @param entityType - The expected entity type of the ID.
87
87
  * @param options - Optional parameters used during generation for validation consistency.
88
+ * The `charset` from these options will be used for validation.
88
89
  * @returns `true` if the ID is valid, `false` otherwise.
89
90
  */
90
91
  isValid(id: string, entityType: string, options?: IdGenerationOptions): boolean;
@@ -112,7 +113,9 @@ export declare class IdGenerator {
112
113
  getEntityType(id: string, separator?: string): string;
113
114
  /**
114
115
  * Normalizes an entity ID to ensure the prefix matches the registered case
115
- * and the random part is uppercase.
116
+ * and the random part is uppercase. Note: This assumes the charset characters
117
+ * have a meaningful uppercase version if case-insensitivity is desired for the random part.
118
+ * For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
116
119
  * @param id - The ID to normalize (e.g., "proj_a6b3j0").
117
120
  * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
118
121
  * @returns The normalized ID (e.g., "PROJ_A6B3J0").
@@ -60,10 +60,18 @@ export class IdGenerator {
60
60
  * @returns The generated random string.
61
61
  */
62
62
  generateRandomString(length = IdGenerator.DEFAULT_LENGTH, charset = IdGenerator.DEFAULT_CHARSET) {
63
- const bytes = randomBytes(length);
64
63
  let result = "";
65
- for (let i = 0; i < length; i++) {
66
- result += charset[bytes[i] % charset.length];
64
+ // Determine the largest multiple of charset.length that is less than or equal to 256
65
+ // This is the threshold for rejection sampling to avoid bias.
66
+ const maxValidByteValue = Math.floor(256 / charset.length) * charset.length;
67
+ while (result.length < length) {
68
+ const byteBuffer = randomBytes(1); // Get one random byte
69
+ const byte = byteBuffer[0];
70
+ // If the byte is within the valid range (i.e., it won't introduce bias),
71
+ // use it to select a character from the charset. Otherwise, discard and try again.
72
+ if (byte < maxValidByteValue) {
73
+ result += charset[byte % charset.length];
74
+ }
67
75
  }
68
76
  return result;
69
77
  }
@@ -101,16 +109,21 @@ export class IdGenerator {
101
109
  * @param id - The ID string to validate.
102
110
  * @param entityType - The expected entity type of the ID.
103
111
  * @param options - Optional parameters used during generation for validation consistency.
112
+ * The `charset` from these options will be used for validation.
104
113
  * @returns `true` if the ID is valid, `false` otherwise.
105
114
  */
106
115
  isValid(id, entityType, options = {}) {
107
116
  const prefix = this.entityPrefixes[entityType];
108
- const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, } = options;
117
+ const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, charset = IdGenerator.DEFAULT_CHARSET, // Use charset from options or default
118
+ } = options;
109
119
  if (!prefix) {
110
120
  return false;
111
121
  }
112
- // Assumes default charset characters (uppercase letters and digits) for regex.
113
- const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}[A-Z0-9]{${length}}$`);
122
+ // Build regex character class from the charset
123
+ // Escape characters that have special meaning inside a regex character class `[]`
124
+ const escapedCharsetForClass = charset.replace(/[[\]\\^-]/g, "\\$&");
125
+ const charsetRegexPart = `[${escapedCharsetForClass}]`;
126
+ const pattern = new RegExp(`^${this.escapeRegex(prefix)}${this.escapeRegex(separator)}${charsetRegexPart}{${length}}$`);
114
127
  return pattern.test(id);
115
128
  }
116
129
  /**
@@ -153,7 +166,9 @@ export class IdGenerator {
153
166
  }
154
167
  /**
155
168
  * Normalizes an entity ID to ensure the prefix matches the registered case
156
- * and the random part is uppercase.
169
+ * and the random part is uppercase. Note: This assumes the charset characters
170
+ * have a meaningful uppercase version if case-insensitivity is desired for the random part.
171
+ * For default charset (A-Z0-9), this is fine. For custom charsets, behavior might vary.
157
172
  * @param id - The ID to normalize (e.g., "proj_a6b3j0").
158
173
  * @param separator - The separator used in the ID. Defaults to `IdGenerator.DEFAULT_SEPARATOR`.
159
174
  * @returns The normalized ID (e.g., "PROJ_A6B3J0").
@@ -164,6 +179,8 @@ export class IdGenerator {
164
179
  const registeredPrefix = this.entityPrefixes[entityType];
165
180
  const idParts = id.split(separator);
166
181
  const randomPart = idParts.slice(1).join(separator);
182
+ // Consider if randomPart.toUpperCase() is always correct for custom charsets.
183
+ // For now, maintaining existing behavior.
167
184
  return `${registeredPrefix}${separator}${randomPart.toUpperCase()}`;
168
185
  }
169
186
  }
@@ -148,6 +148,17 @@ export declare class Sanitization {
148
148
  * Sanitizes input for logging by redacting sensitive fields.
149
149
  * Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
150
150
  * (case-insensitive substring match) with "[REDACTED]".
151
+ *
152
+ * It uses `structuredClone` if available for a high-fidelity deep clone.
153
+ * If `structuredClone` is not available (e.g., in older Node.js environments),
154
+ * it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
155
+ * - `Date` objects are converted to ISO date strings.
156
+ * - `undefined` values within objects are removed.
157
+ * - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
158
+ * - Functions are removed.
159
+ * - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
160
+ * - Circular references will cause `JSON.stringify` to throw an error.
161
+ *
151
162
  * @param input - The input data to sanitize for logging.
152
163
  * @returns A sanitized (deep cloned) version of the input, safe for logging.
153
164
  * Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
@@ -204,8 +204,11 @@ export class Sanitization {
204
204
  })) {
205
205
  throw new Error("Invalid URL format or protocol not in allowed list.");
206
206
  }
207
- if (trimmedInput.toLowerCase().startsWith("javascript:")) {
208
- throw new Error("JavaScript pseudo-protocol is not allowed in URLs.");
207
+ const lowercasedInput = trimmedInput.toLowerCase();
208
+ if (lowercasedInput.startsWith("javascript:") ||
209
+ lowercasedInput.startsWith("data:") ||
210
+ lowercasedInput.startsWith("vbscript:")) {
211
+ throw new Error("Disallowed pseudo-protocol (javascript:, data:, or vbscript:) in URL.");
209
212
  }
210
213
  return trimmedInput;
211
214
  }
@@ -377,6 +380,17 @@ export class Sanitization {
377
380
  * Sanitizes input for logging by redacting sensitive fields.
378
381
  * Creates a deep clone and replaces values of fields matching `this.sensitiveFields`
379
382
  * (case-insensitive substring match) with "[REDACTED]".
383
+ *
384
+ * It uses `structuredClone` if available for a high-fidelity deep clone.
385
+ * If `structuredClone` is not available (e.g., in older Node.js environments),
386
+ * it falls back to `JSON.parse(JSON.stringify(input))`. This fallback has limitations:
387
+ * - `Date` objects are converted to ISO date strings.
388
+ * - `undefined` values within objects are removed.
389
+ * - `Map`, `Set`, `RegExp` objects are converted to empty objects (`{}`).
390
+ * - Functions are removed.
391
+ * - `BigInt` values will throw an error during `JSON.stringify` unless a `toJSON` method is provided.
392
+ * - Circular references will cause `JSON.stringify` to throw an error.
393
+ *
380
394
  * @param input - The input data to sanitize for logging.
381
395
  * @returns A sanitized (deep cloned) version of the input, safe for logging.
382
396
  * Returns original input if not object/array, or "[Log Sanitization Failed]" on error.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
+ "$schema": "http://json.schemastore.org/package",
2
3
  "name": "mcp-ts-template",
3
- "version": "1.4.4",
4
+ "version": "1.4.6",
4
5
  "description": "TypeScript template for building Model Context Protocol (MCP) Servers & Clients. Features extensive utilities (logger, requestContext, etc.), STDIO & Streamable HTTP (with authMiddleware), examples, and type safety. Ideal starting point for creating production-ready MCP Servers & Clients.",
5
6
  "main": "dist/index.js",
6
7
  "files": [