perplexity-mcp-server
Version:
A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with
175 lines (174 loc) • 6.07 kB
JavaScript
import { BaseErrorCode, McpError } from '../types-global/errors.js';
import { logger } from './logger.js';
/**
* Generic rate limiter that can be used across the application
*/
export class RateLimiter {
/**
* Create a new rate limiter
* @param config Rate limiting configuration
*/
constructor(config) {
this.config = config;
/** Cleanup interval timer */
this.cleanupTimer = null;
this.config = { ...RateLimiter.DEFAULT_CONFIG, ...config };
this.limits = new Map();
this.startCleanupTimer();
// Log initialization
logger.debug('RateLimiter initialized', {
windowMs: this.config.windowMs,
maxRequests: this.config.maxRequests,
cleanupInterval: this.config.cleanupInterval
});
}
/**
* Start the cleanup timer to periodically remove expired entries
*/
startCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
const interval = this.config.cleanupInterval ?? RateLimiter.DEFAULT_CONFIG.cleanupInterval;
if (interval) {
this.cleanupTimer = setInterval(() => {
this.cleanupExpiredEntries();
}, interval);
// Ensure the timer doesn't prevent the process from exiting
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref();
}
}
}
/**
* Clean up expired rate limit entries to prevent memory leaks
*/
cleanupExpiredEntries() {
const now = Date.now();
let expiredCount = 0;
// Use a synchronized approach to avoid race conditions during cleanup
for (const [key, entry] of this.limits.entries()) {
if (now >= entry.resetTime) {
this.limits.delete(key);
expiredCount++;
}
}
if (expiredCount > 0) {
logger.debug(`Cleaned up ${expiredCount} expired rate limit entries`, {
totalRemaining: this.limits.size
});
}
}
/**
* Update rate limiter configuration
* @param config New configuration options
*/
configure(config) {
this.config = { ...this.config, ...config };
// Restart cleanup timer if interval changed
if (config.cleanupInterval !== undefined) {
this.startCleanupTimer();
}
}
/**
* Get current configuration
* @returns Current rate limit configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Reset all rate limits
*/
reset() {
this.limits.clear();
logger.debug('Rate limiter reset, all limits cleared');
}
/**
* Check if a request exceeds the rate limit
* @param key Unique identifier for the request source
* @param context Optional request context
* @throws {McpError} If rate limit is exceeded
*/
check(key, context) {
// Skip in development if configured
if (this.config.skipInDevelopment && process.env.NODE_ENV === 'development') {
return;
}
// Generate key using custom generator if provided
const limitKey = this.config.keyGenerator
? this.config.keyGenerator(key, context)
: key;
const now = Date.now();
// Accessing and updating the limit entry within a single function scope
// ensures atomicity in Node.js's single-threaded event loop for Map operations.
const limit = () => {
// Get current entry or create a new one if it doesn't exist or is expired
const entry = this.limits.get(limitKey);
// Create new entry or reset if expired
if (!entry || now >= entry.resetTime) {
const newEntry = {
count: 1,
resetTime: now + this.config.windowMs
};
this.limits.set(limitKey, newEntry);
return newEntry;
}
// Check if limit exceeded
if (entry.count >= this.config.maxRequests) {
const waitTime = Math.ceil((entry.resetTime - now) / 1000);
const errorMessage = this.config.errorMessage?.replace('{waitTime}', waitTime.toString()) ||
`Rate limit exceeded. Please try again in ${waitTime} seconds.`;
throw new McpError(BaseErrorCode.RATE_LIMITED, errorMessage, { waitTime, key: limitKey });
}
// Increment counter and return updated entry
entry.count++;
return entry;
};
// Execute the rate limiting logic
limit();
}
/**
* Get rate limit information for a key
* @param key The rate limit key
* @returns Current rate limit status or null if no record 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
};
}
/**
* Stop the cleanup timer when the limiter is no longer needed
*/
dispose() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
// Clear all entries
this.limits.clear();
}
}
/** Default configuration */
RateLimiter.DEFAULT_CONFIG = {
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 100, // 100 requests per window
errorMessage: 'Rate limit exceeded. Please try again in {waitTime} seconds.',
skipInDevelopment: false,
cleanupInterval: 5 * 60 * 1000 // 5 minutes
};
/**
* Create and export a default rate limiter instance
*/
export const rateLimiter = new RateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 100 // 100 requests per window
});