docwriter-mcp-server
Version:
A Model Context Protocol (MCP) server for programmatic creation, modification, and compilation of structured LaTeX documents. Enables AI agents and automated workflows to generate reports, articles, and papers from templates, with secure, structured conte
180 lines (179 loc) • 6.24 kB
JavaScript
/**
* @fileoverview Provides a generic `RateLimiter` class for implementing rate limiting logic.
* It supports configurable time windows, request limits, and automatic cleanup of expired entries.
* @module src/utils/security/rateLimiter
*/
import { environment } from "../../config/index.js";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, requestContextService } from "../index.js";
/**
* A generic rate limiter class using an in-memory store.
* Controls frequency of operations based on unique keys.
*/
export class RateLimiter {
/**
* Creates a new `RateLimiter` instance.
* @param config - Configuration options, merged with defaults.
*/
constructor(config) {
this.config = config;
/**
* Timer ID for periodic cleanup.
* @private
*/
this.cleanupTimer = null;
this.config = { ...RateLimiter.DEFAULT_CONFIG, ...config };
this.limits = new Map();
this.startCleanupTimer();
}
/**
* Starts the periodic timer to clean up expired rate limit entries.
* @private
*/
startCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
const interval = this.config.cleanupInterval ?? RateLimiter.DEFAULT_CONFIG.cleanupInterval;
if (interval && interval > 0) {
this.cleanupTimer = setInterval(() => {
this.cleanupExpiredEntries();
}, interval);
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref(); // Allow Node.js process to exit if only timer active
}
}
}
/**
* Removes expired rate limit entries from the store.
* @private
*/
cleanupExpiredEntries() {
const now = Date.now();
let expiredCount = 0;
for (const [key, entry] of this.limits.entries()) {
if (now >= entry.resetTime) {
this.limits.delete(key);
expiredCount++;
}
}
if (expiredCount > 0) {
const logContext = requestContextService.createRequestContext({
operation: "RateLimiter.cleanupExpiredEntries",
cleanedCount: expiredCount,
totalRemainingAfterClean: this.limits.size,
});
logger.debug(`Cleaned up ${expiredCount} expired rate limit entries`, logContext);
}
}
/**
* Updates the configuration of the rate limiter instance.
* @param config - New configuration options to merge.
*/
configure(config) {
this.config = { ...this.config, ...config };
if (config.cleanupInterval !== undefined) {
this.startCleanupTimer();
}
}
/**
* Retrieves a copy of the current rate limiter configuration.
* @returns The current configuration.
*/
getConfig() {
return { ...this.config };
}
/**
* Resets all rate limits by clearing the internal store.
*/
reset() {
this.limits.clear();
const logContext = requestContextService.createRequestContext({
operation: "RateLimiter.reset",
});
logger.debug("Rate limiter reset, all limits cleared", logContext);
}
/**
* Checks if a request exceeds the configured rate limit.
* Throws an `McpError` if the limit is exceeded.
*
* @param key - A unique identifier for the request source.
* @param context - Optional request context for custom key generation.
* @throws {McpError} If the rate limit is exceeded.
*/
check(key, context) {
if (this.config.skipInDevelopment && environment === "development") {
return;
}
const limitKey = this.config.keyGenerator
? this.config.keyGenerator(key, context)
: key;
const now = Date.now();
const entry = this.limits.get(limitKey);
if (!entry || now >= entry.resetTime) {
this.limits.set(limitKey, {
count: 1,
resetTime: now + this.config.windowMs,
});
return;
}
if (entry.count >= this.config.maxRequests) {
const waitTime = Math.ceil((entry.resetTime - now) / 1000);
const errorMessage = (this.config.errorMessage || RateLimiter.DEFAULT_CONFIG.errorMessage).replace("{waitTime}", waitTime.toString());
throw new McpError(BaseErrorCode.RATE_LIMITED, errorMessage, {
waitTimeSeconds: waitTime,
key: limitKey,
limit: this.config.maxRequests,
windowMs: this.config.windowMs,
});
}
entry.count++;
}
/**
* Retrieves the current rate limit status for a specific key.
* @param key - The rate limit key.
* @returns Status object or `null` if no entry exists.
*/
getStatus(key) {
const entry = this.limits.get(key);
if (!entry) {
return null;
}
return {
current: entry.count,
limit: this.config.maxRequests,
remaining: Math.max(0, this.config.maxRequests - entry.count),
resetTime: entry.resetTime,
};
}
/**
* Stops the cleanup timer and clears all rate limit entries.
* Call when the rate limiter is no longer needed.
*/
dispose() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.limits.clear();
}
}
/**
* Default configuration values.
* @private
*/
RateLimiter.DEFAULT_CONFIG = {
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 100,
errorMessage: "Rate limit exceeded. Please try again in {waitTime} seconds.",
skipInDevelopment: false,
cleanupInterval: 5 * 60 * 1000, // 5 minutes
};
/**
* Default singleton instance of the `RateLimiter`.
* Initialized with default configuration. Use `rateLimiter.configure({})` to customize.
*/
export const rateLimiter = new RateLimiter({
windowMs: 15 * 60 * 1000, // Default: 15 minutes
maxRequests: 100, // Default: 100 requests per window
});