ntfy-mcp-server
Version:
An MCP (Model Context Protocol) server designed to interact with the ntfy push notification service. It enables LLMs and AI agents to send notifications to your devices with extensive customization options.
181 lines (180 loc) • 6.15 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();
// Use a mutex-like approach for thread safety by safely getting and
// manipulating the rate limit entry in an atomic operation
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,
key: limitKey
};
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
});
// Export default
export default {
RateLimiter,
rateLimiter
};