ntfy-mcp-server 1.0.0
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/LICENSE +201 -0
- package/README.md +423 -0
- package/dist/config/index.d.ts +23 -0
- package/dist/config/index.js +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +108 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +2 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +111 -0
- package/dist/mcp-server/resources/ntfyResource/index.d.ts +12 -0
- package/dist/mcp-server/resources/ntfyResource/index.js +72 -0
- package/dist/mcp-server/resources/ntfyResource/types.d.ts +27 -0
- package/dist/mcp-server/resources/ntfyResource/types.js +8 -0
- package/dist/mcp-server/server.d.ts +40 -0
- package/dist/mcp-server/server.js +245 -0
- package/dist/mcp-server/tools/ntfyTool/index.d.ts +11 -0
- package/dist/mcp-server/tools/ntfyTool/index.js +110 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +9 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +289 -0
- package/dist/mcp-server/tools/ntfyTool/types.d.ts +252 -0
- package/dist/mcp-server/tools/ntfyTool/types.js +144 -0
- package/dist/mcp-server/utils/registrationHelper.d.ts +48 -0
- package/dist/mcp-server/utils/registrationHelper.js +63 -0
- package/dist/services/ntfy/constants.d.ts +37 -0
- package/dist/services/ntfy/constants.js +37 -0
- package/dist/services/ntfy/errors.d.ts +79 -0
- package/dist/services/ntfy/errors.js +134 -0
- package/dist/services/ntfy/index.d.ts +33 -0
- package/dist/services/ntfy/index.js +56 -0
- package/dist/services/ntfy/publisher.d.ts +66 -0
- package/dist/services/ntfy/publisher.js +229 -0
- package/dist/services/ntfy/subscriber.d.ts +81 -0
- package/dist/services/ntfy/subscriber.js +502 -0
- package/dist/services/ntfy/types.d.ts +161 -0
- package/dist/services/ntfy/types.js +4 -0
- package/dist/services/ntfy/utils.d.ts +85 -0
- package/dist/services/ntfy/utils.js +410 -0
- package/dist/types-global/errors.d.ts +35 -0
- package/dist/types-global/errors.js +39 -0
- package/dist/types-global/mcp.d.ts +30 -0
- package/dist/types-global/mcp.js +25 -0
- package/dist/types-global/tool.d.ts +61 -0
- package/dist/types-global/tool.js +99 -0
- package/dist/utils/errorHandler.d.ts +98 -0
- package/dist/utils/errorHandler.js +271 -0
- package/dist/utils/idGenerator.d.ts +94 -0
- package/dist/utils/idGenerator.js +149 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +16 -0
- package/dist/utils/logger.d.ts +36 -0
- package/dist/utils/logger.js +92 -0
- package/dist/utils/rateLimiter.d.ts +115 -0
- package/dist/utils/rateLimiter.js +180 -0
- package/dist/utils/requestContext.d.ts +68 -0
- package/dist/utils/requestContext.js +91 -0
- package/dist/utils/sanitization.d.ts +224 -0
- package/dist/utils/sanitization.js +367 -0
- package/dist/utils/security.d.ts +26 -0
- package/dist/utils/security.js +27 -0
- package/package.json +47 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { BaseErrorCode, McpError } from '../types-global/errors.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Generic ID Generator class for creating and managing unique identifiers
|
|
6
|
+
*/
|
|
7
|
+
export class IdGenerator {
|
|
8
|
+
/**
|
|
9
|
+
* Constructor that accepts entity prefix configuration
|
|
10
|
+
* @param entityPrefixes Map of entity types to their prefixes
|
|
11
|
+
*/
|
|
12
|
+
constructor(entityPrefixes = {}) {
|
|
13
|
+
// Entity prefixes
|
|
14
|
+
this.entityPrefixes = {};
|
|
15
|
+
// Reverse mapping for prefix to entity type lookup
|
|
16
|
+
this.prefixToEntityType = {};
|
|
17
|
+
this.setEntityPrefixes(entityPrefixes);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Set or update entity prefixes and rebuild the reverse lookup
|
|
21
|
+
* @param entityPrefixes Map of entity types to their prefixes
|
|
22
|
+
*/
|
|
23
|
+
setEntityPrefixes(entityPrefixes) {
|
|
24
|
+
this.entityPrefixes = { ...entityPrefixes };
|
|
25
|
+
// Rebuild reverse mapping
|
|
26
|
+
this.prefixToEntityType = Object.entries(this.entityPrefixes).reduce((acc, [type, prefix]) => {
|
|
27
|
+
acc[prefix] = type;
|
|
28
|
+
acc[prefix.toLowerCase()] = type;
|
|
29
|
+
return acc;
|
|
30
|
+
}, {});
|
|
31
|
+
logger.debug('Entity prefixes updated', { entityPrefixes: this.entityPrefixes });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get all registered entity prefixes
|
|
35
|
+
* @returns The entity prefix configuration
|
|
36
|
+
*/
|
|
37
|
+
getEntityPrefixes() {
|
|
38
|
+
return { ...this.entityPrefixes };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a cryptographically secure random alphanumeric string
|
|
42
|
+
* @param length The length of the random string to generate
|
|
43
|
+
* @param charset Optional custom character set
|
|
44
|
+
* @returns Random alphanumeric string
|
|
45
|
+
*/
|
|
46
|
+
generateRandomString(length = IdGenerator.DEFAULT_LENGTH, charset = IdGenerator.DEFAULT_CHARSET) {
|
|
47
|
+
const bytes = randomBytes(length);
|
|
48
|
+
let result = '';
|
|
49
|
+
for (let i = 0; i < length; i++) {
|
|
50
|
+
result += charset[bytes[i] % charset.length];
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generates a unique ID with an optional prefix
|
|
56
|
+
* @param prefix Optional prefix to add to the ID
|
|
57
|
+
* @param options Optional generation options
|
|
58
|
+
* @returns A unique identifier string
|
|
59
|
+
*/
|
|
60
|
+
generate(prefix, options = {}) {
|
|
61
|
+
const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR, charset = IdGenerator.DEFAULT_CHARSET } = options;
|
|
62
|
+
const randomPart = this.generateRandomString(length, charset);
|
|
63
|
+
return prefix
|
|
64
|
+
? `${prefix}${separator}${randomPart}`
|
|
65
|
+
: randomPart;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Generates a custom ID for an entity with format PREFIX_XXXXXX
|
|
69
|
+
* @param entityType The type of entity to generate an ID for
|
|
70
|
+
* @param options Optional generation options
|
|
71
|
+
* @returns A unique identifier string (e.g., "PROJ_A6B3J0")
|
|
72
|
+
* @throws {McpError} If the entity type is not registered
|
|
73
|
+
*/
|
|
74
|
+
generateForEntity(entityType, options = {}) {
|
|
75
|
+
const prefix = this.entityPrefixes[entityType];
|
|
76
|
+
if (!prefix) {
|
|
77
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Unknown entity type: ${entityType}`);
|
|
78
|
+
}
|
|
79
|
+
return this.generate(prefix, options);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Validates if a given ID matches the expected format for an entity type
|
|
83
|
+
* @param id The ID to validate
|
|
84
|
+
* @param entityType The expected entity type
|
|
85
|
+
* @param options Optional validation options
|
|
86
|
+
* @returns boolean indicating if the ID is valid
|
|
87
|
+
*/
|
|
88
|
+
isValid(id, entityType, options = {}) {
|
|
89
|
+
const prefix = this.entityPrefixes[entityType];
|
|
90
|
+
const { length = IdGenerator.DEFAULT_LENGTH, separator = IdGenerator.DEFAULT_SEPARATOR } = options;
|
|
91
|
+
if (!prefix) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const pattern = new RegExp(`^${prefix}${separator}[A-Z0-9]{${length}}$`);
|
|
95
|
+
return pattern.test(id);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Strips the prefix from an ID
|
|
99
|
+
* @param id The ID to strip
|
|
100
|
+
* @param separator Optional custom separator
|
|
101
|
+
* @returns The ID without the prefix
|
|
102
|
+
*/
|
|
103
|
+
stripPrefix(id, separator = IdGenerator.DEFAULT_SEPARATOR) {
|
|
104
|
+
return id.split(separator)[1] || id;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Determines the entity type from an ID
|
|
108
|
+
* @param id The ID to get the entity type for
|
|
109
|
+
* @param separator Optional custom separator
|
|
110
|
+
* @returns The entity type
|
|
111
|
+
* @throws {McpError} If the ID format is invalid or entity type is unknown
|
|
112
|
+
*/
|
|
113
|
+
getEntityType(id, separator = IdGenerator.DEFAULT_SEPARATOR) {
|
|
114
|
+
const parts = id.split(separator);
|
|
115
|
+
if (parts.length !== 2 || !parts[0]) {
|
|
116
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid ID format: ${id}. Expected format: PREFIX${separator}XXXXXX`);
|
|
117
|
+
}
|
|
118
|
+
const prefix = parts[0];
|
|
119
|
+
const entityType = this.prefixToEntityType[prefix];
|
|
120
|
+
if (!entityType) {
|
|
121
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Unknown entity type prefix: ${prefix}`);
|
|
122
|
+
}
|
|
123
|
+
return entityType;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Normalizes an entity ID to ensure consistent uppercase format
|
|
127
|
+
* @param id The ID to normalize
|
|
128
|
+
* @param separator Optional custom separator
|
|
129
|
+
* @returns The normalized ID in uppercase format
|
|
130
|
+
*/
|
|
131
|
+
normalize(id, separator = IdGenerator.DEFAULT_SEPARATOR) {
|
|
132
|
+
const entityType = this.getEntityType(id, separator);
|
|
133
|
+
const idParts = id.split(separator);
|
|
134
|
+
return `${this.entityPrefixes[entityType]}${separator}${idParts[1].toUpperCase()}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Default charset
|
|
138
|
+
IdGenerator.DEFAULT_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
139
|
+
// Default separator
|
|
140
|
+
IdGenerator.DEFAULT_SEPARATOR = '_';
|
|
141
|
+
// Default random part length
|
|
142
|
+
IdGenerator.DEFAULT_LENGTH = 6;
|
|
143
|
+
// Create and export a default instance with an empty entity prefix configuration
|
|
144
|
+
export const idGenerator = new IdGenerator();
|
|
145
|
+
// For standalone use as a UUID generator
|
|
146
|
+
export const generateUUID = () => {
|
|
147
|
+
return crypto.randomUUID();
|
|
148
|
+
};
|
|
149
|
+
export default idGenerator;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './requestContext.js';
|
|
2
|
+
export * from './errorHandler.js';
|
|
3
|
+
export * from './idGenerator.js';
|
|
4
|
+
export * from './logger.js';
|
|
5
|
+
export * from './rateLimiter.js';
|
|
6
|
+
export * from './sanitization.js';
|
|
7
|
+
import { default as requestContext } from './requestContext.js';
|
|
8
|
+
import { default as errorHandler } from './errorHandler.js';
|
|
9
|
+
import { default as idGenerator } from './idGenerator.js';
|
|
10
|
+
import { logger } from './logger.js';
|
|
11
|
+
import { default as rateLimiter } from './rateLimiter.js';
|
|
12
|
+
import { default as sanitization } from './sanitization.js';
|
|
13
|
+
export { requestContext, errorHandler, idGenerator, logger, rateLimiter, sanitization };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Re-export all utilities
|
|
2
|
+
export * from './requestContext.js';
|
|
3
|
+
export * from './errorHandler.js';
|
|
4
|
+
export * from './idGenerator.js';
|
|
5
|
+
export * from './logger.js';
|
|
6
|
+
export * from './rateLimiter.js';
|
|
7
|
+
export * from './sanitization.js';
|
|
8
|
+
// Import named exports to re-export
|
|
9
|
+
import { default as requestContext } from './requestContext.js';
|
|
10
|
+
import { default as errorHandler } from './errorHandler.js';
|
|
11
|
+
import { default as idGenerator } from './idGenerator.js';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
import { default as rateLimiter } from './rateLimiter.js';
|
|
14
|
+
import { default as sanitization } from './sanitization.js';
|
|
15
|
+
// Export frequently used utilities directly
|
|
16
|
+
export { requestContext, errorHandler, idGenerator, logger, rateLimiter, sanitization };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ChildLogger = {
|
|
2
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
3
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
4
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
5
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
6
|
+
};
|
|
7
|
+
declare class Logger {
|
|
8
|
+
private static instance;
|
|
9
|
+
private logger;
|
|
10
|
+
private constructor();
|
|
11
|
+
static getInstance(): Logger;
|
|
12
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
13
|
+
info(message: string, context?: Record<string, unknown>): void;
|
|
14
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
15
|
+
error(message: string, context?: Record<string, unknown>): void;
|
|
16
|
+
createChildLogger(metadata: {
|
|
17
|
+
module: string;
|
|
18
|
+
service?: string;
|
|
19
|
+
serviceId?: string;
|
|
20
|
+
componentName?: string;
|
|
21
|
+
subscriberId?: string;
|
|
22
|
+
component?: string;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
subscriptionTime?: string;
|
|
25
|
+
environment?: string;
|
|
26
|
+
serverId?: string;
|
|
27
|
+
[key: string]: any;
|
|
28
|
+
}): {
|
|
29
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
30
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
31
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
32
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export declare const logger: Logger;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import winston from "winston";
|
|
5
|
+
// Handle ESM module dirname
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
// Resolve logs directory relative to project root (2 levels up from utils/)
|
|
9
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
10
|
+
const logsDir = path.join(projectRoot, 'logs');
|
|
11
|
+
class Logger {
|
|
12
|
+
constructor() {
|
|
13
|
+
const logLevel = process.env.LOG_LEVEL || "info";
|
|
14
|
+
// Ensure logs directory exists
|
|
15
|
+
if (!fs.existsSync(logsDir)) {
|
|
16
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
// Common format for all transports
|
|
19
|
+
const commonFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.printf(({ timestamp, level, message, context, stack }) => {
|
|
20
|
+
const contextStr = context ? `\n Context: ${JSON.stringify(context, null, 2)}` : "";
|
|
21
|
+
const stackStr = stack ? `\n Stack: ${stack}` : "";
|
|
22
|
+
return `[${timestamp}] ${level}: ${message}${contextStr}${stackStr}`;
|
|
23
|
+
}));
|
|
24
|
+
this.logger = winston.createLogger({
|
|
25
|
+
level: logLevel,
|
|
26
|
+
format: winston.format.json(),
|
|
27
|
+
transports: [
|
|
28
|
+
// Combined log file for all levels
|
|
29
|
+
new winston.transports.File({
|
|
30
|
+
filename: path.join(logsDir, 'combined.log'),
|
|
31
|
+
format: commonFormat
|
|
32
|
+
}),
|
|
33
|
+
// Separate log files for each level
|
|
34
|
+
new winston.transports.File({
|
|
35
|
+
filename: path.join(logsDir, 'error.log'),
|
|
36
|
+
level: 'error',
|
|
37
|
+
format: commonFormat
|
|
38
|
+
}),
|
|
39
|
+
new winston.transports.File({
|
|
40
|
+
filename: path.join(logsDir, 'warn.log'),
|
|
41
|
+
level: 'warn',
|
|
42
|
+
format: commonFormat
|
|
43
|
+
}),
|
|
44
|
+
new winston.transports.File({
|
|
45
|
+
filename: path.join(logsDir, 'info.log'),
|
|
46
|
+
level: 'info',
|
|
47
|
+
format: commonFormat
|
|
48
|
+
}),
|
|
49
|
+
new winston.transports.File({
|
|
50
|
+
filename: path.join(logsDir, 'debug.log'),
|
|
51
|
+
level: 'debug',
|
|
52
|
+
format: commonFormat
|
|
53
|
+
})
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
static getInstance() {
|
|
58
|
+
if (!Logger.instance) {
|
|
59
|
+
Logger.instance = new Logger();
|
|
60
|
+
}
|
|
61
|
+
return Logger.instance;
|
|
62
|
+
}
|
|
63
|
+
debug(message, context) {
|
|
64
|
+
this.logger.debug(message, { context });
|
|
65
|
+
}
|
|
66
|
+
info(message, context) {
|
|
67
|
+
this.logger.info(message, { context });
|
|
68
|
+
}
|
|
69
|
+
warn(message, context) {
|
|
70
|
+
this.logger.warn(message, { context });
|
|
71
|
+
}
|
|
72
|
+
error(message, context) {
|
|
73
|
+
this.logger.error(message, { context });
|
|
74
|
+
}
|
|
75
|
+
createChildLogger(metadata) {
|
|
76
|
+
return {
|
|
77
|
+
debug: (message, context) => {
|
|
78
|
+
this.debug(`[${metadata.module}] ${message}`, context);
|
|
79
|
+
},
|
|
80
|
+
info: (message, context) => {
|
|
81
|
+
this.info(`[${metadata.module}] ${message}`, context);
|
|
82
|
+
},
|
|
83
|
+
warn: (message, context) => {
|
|
84
|
+
this.warn(`[${metadata.module}] ${message}`, context);
|
|
85
|
+
},
|
|
86
|
+
error: (message, context) => {
|
|
87
|
+
this.error(`[${metadata.module}] ${message}`, context);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export const logger = Logger.getInstance();
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request context interface
|
|
3
|
+
*/
|
|
4
|
+
export interface RequestContext {
|
|
5
|
+
/** Unique request identifier */
|
|
6
|
+
requestId: string;
|
|
7
|
+
/** Request timestamp */
|
|
8
|
+
timestamp: string;
|
|
9
|
+
/** Request path/endpoint */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** HTTP method */
|
|
12
|
+
method?: string;
|
|
13
|
+
/** Request source IP */
|
|
14
|
+
ip?: string;
|
|
15
|
+
/** Custom request properties */
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Rate limiting configuration options
|
|
20
|
+
*/
|
|
21
|
+
export interface RateLimitConfig {
|
|
22
|
+
/** Time window in milliseconds */
|
|
23
|
+
windowMs: number;
|
|
24
|
+
/** Maximum number of requests allowed in the window */
|
|
25
|
+
maxRequests: number;
|
|
26
|
+
/** Custom error message template */
|
|
27
|
+
errorMessage?: string;
|
|
28
|
+
/** Whether to skip rate limiting in certain environments (e.g. development) */
|
|
29
|
+
skipInDevelopment?: boolean;
|
|
30
|
+
/** Custom key generator function */
|
|
31
|
+
keyGenerator?: (identifier: string, context?: RequestContext) => string;
|
|
32
|
+
/** How often to run cleanup of expired entries (in milliseconds) */
|
|
33
|
+
cleanupInterval?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Individual rate limit entry
|
|
37
|
+
*/
|
|
38
|
+
export interface RateLimitEntry {
|
|
39
|
+
/** Current request count */
|
|
40
|
+
count: number;
|
|
41
|
+
/** When the window resets (timestamp) */
|
|
42
|
+
resetTime: number;
|
|
43
|
+
/** Key for this entry, stored for faster deletion */
|
|
44
|
+
key: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Generic rate limiter that can be used across the application
|
|
48
|
+
*/
|
|
49
|
+
export declare class RateLimiter {
|
|
50
|
+
private config;
|
|
51
|
+
/** Map storing rate limit data */
|
|
52
|
+
private limits;
|
|
53
|
+
/** Cleanup interval timer */
|
|
54
|
+
private cleanupTimer;
|
|
55
|
+
/** Default configuration */
|
|
56
|
+
private static DEFAULT_CONFIG;
|
|
57
|
+
/**
|
|
58
|
+
* Create a new rate limiter
|
|
59
|
+
* @param config Rate limiting configuration
|
|
60
|
+
*/
|
|
61
|
+
constructor(config: RateLimitConfig);
|
|
62
|
+
/**
|
|
63
|
+
* Start the cleanup timer to periodically remove expired entries
|
|
64
|
+
*/
|
|
65
|
+
private startCleanupTimer;
|
|
66
|
+
/**
|
|
67
|
+
* Clean up expired rate limit entries to prevent memory leaks
|
|
68
|
+
*/
|
|
69
|
+
private cleanupExpiredEntries;
|
|
70
|
+
/**
|
|
71
|
+
* Update rate limiter configuration
|
|
72
|
+
* @param config New configuration options
|
|
73
|
+
*/
|
|
74
|
+
configure(config: Partial<RateLimitConfig>): void;
|
|
75
|
+
/**
|
|
76
|
+
* Get current configuration
|
|
77
|
+
* @returns Current rate limit configuration
|
|
78
|
+
*/
|
|
79
|
+
getConfig(): RateLimitConfig;
|
|
80
|
+
/**
|
|
81
|
+
* Reset all rate limits
|
|
82
|
+
*/
|
|
83
|
+
reset(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Check if a request exceeds the rate limit
|
|
86
|
+
* @param key Unique identifier for the request source
|
|
87
|
+
* @param context Optional request context
|
|
88
|
+
* @throws {McpError} If rate limit is exceeded
|
|
89
|
+
*/
|
|
90
|
+
check(key: string, context?: RequestContext): void;
|
|
91
|
+
/**
|
|
92
|
+
* Get rate limit information for a key
|
|
93
|
+
* @param key The rate limit key
|
|
94
|
+
* @returns Current rate limit status or null if no record exists
|
|
95
|
+
*/
|
|
96
|
+
getStatus(key: string): {
|
|
97
|
+
current: number;
|
|
98
|
+
limit: number;
|
|
99
|
+
remaining: number;
|
|
100
|
+
resetTime: number;
|
|
101
|
+
} | null;
|
|
102
|
+
/**
|
|
103
|
+
* Stop the cleanup timer when the limiter is no longer needed
|
|
104
|
+
*/
|
|
105
|
+
dispose(): void;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create and export a default rate limiter instance
|
|
109
|
+
*/
|
|
110
|
+
export declare const rateLimiter: RateLimiter;
|
|
111
|
+
declare const _default: {
|
|
112
|
+
RateLimiter: typeof RateLimiter;
|
|
113
|
+
rateLimiter: RateLimiter;
|
|
114
|
+
};
|
|
115
|
+
export default _default;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { BaseErrorCode, McpError } from '../types-global/errors.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Generic rate limiter that can be used across the application
|
|
5
|
+
*/
|
|
6
|
+
export class RateLimiter {
|
|
7
|
+
/**
|
|
8
|
+
* Create a new rate limiter
|
|
9
|
+
* @param config Rate limiting configuration
|
|
10
|
+
*/
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
/** Cleanup interval timer */
|
|
14
|
+
this.cleanupTimer = null;
|
|
15
|
+
this.config = { ...RateLimiter.DEFAULT_CONFIG, ...config };
|
|
16
|
+
this.limits = new Map();
|
|
17
|
+
this.startCleanupTimer();
|
|
18
|
+
// Log initialization
|
|
19
|
+
logger.debug('RateLimiter initialized', {
|
|
20
|
+
windowMs: this.config.windowMs,
|
|
21
|
+
maxRequests: this.config.maxRequests,
|
|
22
|
+
cleanupInterval: this.config.cleanupInterval
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Start the cleanup timer to periodically remove expired entries
|
|
27
|
+
*/
|
|
28
|
+
startCleanupTimer() {
|
|
29
|
+
if (this.cleanupTimer) {
|
|
30
|
+
clearInterval(this.cleanupTimer);
|
|
31
|
+
}
|
|
32
|
+
const interval = this.config.cleanupInterval ?? RateLimiter.DEFAULT_CONFIG.cleanupInterval;
|
|
33
|
+
if (interval) {
|
|
34
|
+
this.cleanupTimer = setInterval(() => {
|
|
35
|
+
this.cleanupExpiredEntries();
|
|
36
|
+
}, interval);
|
|
37
|
+
// Ensure the timer doesn't prevent the process from exiting
|
|
38
|
+
if (this.cleanupTimer.unref) {
|
|
39
|
+
this.cleanupTimer.unref();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clean up expired rate limit entries to prevent memory leaks
|
|
45
|
+
*/
|
|
46
|
+
cleanupExpiredEntries() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
let expiredCount = 0;
|
|
49
|
+
// Use a synchronized approach to avoid race conditions during cleanup
|
|
50
|
+
for (const [key, entry] of this.limits.entries()) {
|
|
51
|
+
if (now >= entry.resetTime) {
|
|
52
|
+
this.limits.delete(key);
|
|
53
|
+
expiredCount++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (expiredCount > 0) {
|
|
57
|
+
logger.debug(`Cleaned up ${expiredCount} expired rate limit entries`, {
|
|
58
|
+
totalRemaining: this.limits.size
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Update rate limiter configuration
|
|
64
|
+
* @param config New configuration options
|
|
65
|
+
*/
|
|
66
|
+
configure(config) {
|
|
67
|
+
this.config = { ...this.config, ...config };
|
|
68
|
+
// Restart cleanup timer if interval changed
|
|
69
|
+
if (config.cleanupInterval !== undefined) {
|
|
70
|
+
this.startCleanupTimer();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get current configuration
|
|
75
|
+
* @returns Current rate limit configuration
|
|
76
|
+
*/
|
|
77
|
+
getConfig() {
|
|
78
|
+
return { ...this.config };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reset all rate limits
|
|
82
|
+
*/
|
|
83
|
+
reset() {
|
|
84
|
+
this.limits.clear();
|
|
85
|
+
logger.debug('Rate limiter reset, all limits cleared');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check if a request exceeds the rate limit
|
|
89
|
+
* @param key Unique identifier for the request source
|
|
90
|
+
* @param context Optional request context
|
|
91
|
+
* @throws {McpError} If rate limit is exceeded
|
|
92
|
+
*/
|
|
93
|
+
check(key, context) {
|
|
94
|
+
// Skip in development if configured
|
|
95
|
+
if (this.config.skipInDevelopment && process.env.NODE_ENV === 'development') {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Generate key using custom generator if provided
|
|
99
|
+
const limitKey = this.config.keyGenerator
|
|
100
|
+
? this.config.keyGenerator(key, context)
|
|
101
|
+
: key;
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
// Use a mutex-like approach for thread safety by safely getting and
|
|
104
|
+
// manipulating the rate limit entry in an atomic operation
|
|
105
|
+
const limit = () => {
|
|
106
|
+
// Get current entry or create a new one if it doesn't exist or is expired
|
|
107
|
+
const entry = this.limits.get(limitKey);
|
|
108
|
+
// Create new entry or reset if expired
|
|
109
|
+
if (!entry || now >= entry.resetTime) {
|
|
110
|
+
const newEntry = {
|
|
111
|
+
count: 1,
|
|
112
|
+
resetTime: now + this.config.windowMs,
|
|
113
|
+
key: limitKey
|
|
114
|
+
};
|
|
115
|
+
this.limits.set(limitKey, newEntry);
|
|
116
|
+
return newEntry;
|
|
117
|
+
}
|
|
118
|
+
// Check if limit exceeded
|
|
119
|
+
if (entry.count >= this.config.maxRequests) {
|
|
120
|
+
const waitTime = Math.ceil((entry.resetTime - now) / 1000);
|
|
121
|
+
const errorMessage = this.config.errorMessage?.replace('{waitTime}', waitTime.toString()) ||
|
|
122
|
+
`Rate limit exceeded. Please try again in ${waitTime} seconds.`;
|
|
123
|
+
throw new McpError(BaseErrorCode.RATE_LIMITED, errorMessage, { waitTime, key: limitKey });
|
|
124
|
+
}
|
|
125
|
+
// Increment counter and return updated entry
|
|
126
|
+
entry.count++;
|
|
127
|
+
return entry;
|
|
128
|
+
};
|
|
129
|
+
// Execute the rate limiting logic
|
|
130
|
+
limit();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get rate limit information for a key
|
|
134
|
+
* @param key The rate limit key
|
|
135
|
+
* @returns Current rate limit status or null if no record exists
|
|
136
|
+
*/
|
|
137
|
+
getStatus(key) {
|
|
138
|
+
const entry = this.limits.get(key);
|
|
139
|
+
if (!entry) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
current: entry.count,
|
|
144
|
+
limit: this.config.maxRequests,
|
|
145
|
+
remaining: Math.max(0, this.config.maxRequests - entry.count),
|
|
146
|
+
resetTime: entry.resetTime
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Stop the cleanup timer when the limiter is no longer needed
|
|
151
|
+
*/
|
|
152
|
+
dispose() {
|
|
153
|
+
if (this.cleanupTimer) {
|
|
154
|
+
clearInterval(this.cleanupTimer);
|
|
155
|
+
this.cleanupTimer = null;
|
|
156
|
+
}
|
|
157
|
+
// Clear all entries
|
|
158
|
+
this.limits.clear();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Default configuration */
|
|
162
|
+
RateLimiter.DEFAULT_CONFIG = {
|
|
163
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
164
|
+
maxRequests: 100, // 100 requests per window
|
|
165
|
+
errorMessage: 'Rate limit exceeded. Please try again in {waitTime} seconds.',
|
|
166
|
+
skipInDevelopment: false,
|
|
167
|
+
cleanupInterval: 5 * 60 * 1000 // 5 minutes
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Create and export a default rate limiter instance
|
|
171
|
+
*/
|
|
172
|
+
export const rateLimiter = new RateLimiter({
|
|
173
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
174
|
+
maxRequests: 100 // 100 requests per window
|
|
175
|
+
});
|
|
176
|
+
// Export default
|
|
177
|
+
export default {
|
|
178
|
+
RateLimiter,
|
|
179
|
+
rateLimiter
|
|
180
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { RequestContext } from './rateLimiter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration interface for request context utilities
|
|
4
|
+
*/
|
|
5
|
+
export interface ContextConfig {
|
|
6
|
+
/** Custom configuration properties */
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Operation context with request data
|
|
11
|
+
*/
|
|
12
|
+
export interface OperationContext {
|
|
13
|
+
/** Request context data */
|
|
14
|
+
requestContext?: RequestContext;
|
|
15
|
+
/** Custom context properties */
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Request context utilities class
|
|
20
|
+
*/
|
|
21
|
+
export declare class RequestContextService {
|
|
22
|
+
private static instance;
|
|
23
|
+
private config;
|
|
24
|
+
/**
|
|
25
|
+
* Private constructor to enforce singleton pattern
|
|
26
|
+
*/
|
|
27
|
+
private constructor();
|
|
28
|
+
/**
|
|
29
|
+
* Get the singleton RequestContextService instance
|
|
30
|
+
* @returns RequestContextService instance
|
|
31
|
+
*/
|
|
32
|
+
static getInstance(): RequestContextService;
|
|
33
|
+
/**
|
|
34
|
+
* Configure service settings
|
|
35
|
+
* @param config New configuration
|
|
36
|
+
* @returns Updated configuration
|
|
37
|
+
*/
|
|
38
|
+
configure(config: Partial<ContextConfig>): ContextConfig;
|
|
39
|
+
/**
|
|
40
|
+
* Get current configuration
|
|
41
|
+
* @returns Current configuration
|
|
42
|
+
*/
|
|
43
|
+
getConfig(): ContextConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Create a request context with unique ID and timestamp
|
|
46
|
+
* @param additionalContext Additional context properties
|
|
47
|
+
* @returns Request context object
|
|
48
|
+
*/
|
|
49
|
+
createRequestContext(additionalContext?: Record<string, unknown>): RequestContext;
|
|
50
|
+
/**
|
|
51
|
+
* Generate a secure random string
|
|
52
|
+
* @param length Length of the string
|
|
53
|
+
* @param chars Character set to use
|
|
54
|
+
* @returns Random string
|
|
55
|
+
*/
|
|
56
|
+
generateSecureRandomString(length?: number, chars?: string): string;
|
|
57
|
+
}
|
|
58
|
+
export declare const requestContextService: RequestContextService;
|
|
59
|
+
export declare const configureContext: (config: Partial<ContextConfig>) => ContextConfig;
|
|
60
|
+
export declare const createRequestContext: (additionalContext?: Record<string, unknown>) => RequestContext;
|
|
61
|
+
export declare const generateSecureRandomString: (length?: number, chars?: string) => string;
|
|
62
|
+
declare const _default: {
|
|
63
|
+
requestContextService: RequestContextService;
|
|
64
|
+
configureContext: (config: Partial<ContextConfig>) => ContextConfig;
|
|
65
|
+
createRequestContext: (additionalContext?: Record<string, unknown>) => RequestContext;
|
|
66
|
+
generateSecureRandomString: (length?: number, chars?: string) => string;
|
|
67
|
+
};
|
|
68
|
+
export default _default;
|