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,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { EventEmitter } from "events";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { config } from "../config/index.js";
|
|
9
|
+
import { BaseErrorCode, McpError } from "../types-global/errors.js";
|
|
10
|
+
import { ErrorHandler } from "../utils/errorHandler.js";
|
|
11
|
+
import { idGenerator } from "../utils/idGenerator.js";
|
|
12
|
+
import { logger } from "../utils/logger.js";
|
|
13
|
+
import { createRequestContext } from "../utils/requestContext.js";
|
|
14
|
+
import { sanitizeInput } from "../utils/security.js";
|
|
15
|
+
// Calculate __dirname equivalent for ES modules
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
// Import tool and resource registrations
|
|
19
|
+
import { registerNtfyTool } from "./tools/ntfyTool/index.js";
|
|
20
|
+
import { registerNtfyResource } from "./resources/ntfyResource/index.js";
|
|
21
|
+
// Maximum file size for package.json (5MB) to prevent potential DoS
|
|
22
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* Load package information directly from package.json
|
|
25
|
+
*
|
|
26
|
+
* @returns A promise resolving to an object with the package name and version
|
|
27
|
+
*/
|
|
28
|
+
const loadPackageInfo = async () => {
|
|
29
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
30
|
+
// Use the globally defined __dirname from the top of the file
|
|
31
|
+
const pkgPath = path.resolve(__dirname, '../../package.json');
|
|
32
|
+
const safePath = sanitizeInput.path(pkgPath);
|
|
33
|
+
console.error(`Looking for package.json at: ${safePath}`);
|
|
34
|
+
// Get file stats to check size before reading
|
|
35
|
+
const stats = await fs.stat(safePath);
|
|
36
|
+
// Check file size to prevent DoS attacks
|
|
37
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
38
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `package.json file is too large (${stats.size} bytes)`, { path: safePath, maxSize: MAX_FILE_SIZE });
|
|
39
|
+
}
|
|
40
|
+
const pkgContent = await fs.readFile(safePath, 'utf-8');
|
|
41
|
+
const pkg = JSON.parse(pkgContent);
|
|
42
|
+
if (!pkg.name || typeof pkg.name !== 'string' ||
|
|
43
|
+
!pkg.version || typeof pkg.version !== 'string') {
|
|
44
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid package.json: missing name or version', { path: safePath });
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
name: pkg.name,
|
|
48
|
+
version: pkg.version
|
|
49
|
+
};
|
|
50
|
+
}, {
|
|
51
|
+
operation: 'LoadPackageInfo',
|
|
52
|
+
errorCode: BaseErrorCode.VALIDATION_ERROR,
|
|
53
|
+
rethrow: true, // Changed to true so errors propagate
|
|
54
|
+
includeStack: true,
|
|
55
|
+
errorMapper: (error) => {
|
|
56
|
+
if (error instanceof SyntaxError) {
|
|
57
|
+
return new McpError(BaseErrorCode.VALIDATION_ERROR, `Failed to parse package.json: ${error.message}`, { errorType: 'SyntaxError' });
|
|
58
|
+
}
|
|
59
|
+
return new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to load package info: ${error instanceof Error ? error.message : String(error)}`, { errorType: error instanceof Error ? error.name : typeof error });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Server event emitter for lifecycle events
|
|
65
|
+
*/
|
|
66
|
+
class ServerEvents extends EventEmitter {
|
|
67
|
+
constructor() {
|
|
68
|
+
super();
|
|
69
|
+
}
|
|
70
|
+
// Type-safe event emitters
|
|
71
|
+
emitStateChange(oldState, newState) {
|
|
72
|
+
this.emit('stateChange', oldState, newState);
|
|
73
|
+
this.emit(`state:${newState}`, oldState);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create and initialize an MCP server instance with all tools and resources
|
|
78
|
+
*
|
|
79
|
+
* This function configures the MCP server with security settings, tools, and resources.
|
|
80
|
+
* It connects the server to a transport (currently stdio) and returns the initialized
|
|
81
|
+
* server instance.
|
|
82
|
+
*
|
|
83
|
+
* @returns A promise that resolves to the initialized McpServer instance
|
|
84
|
+
* @throws {McpError} If the server fails to initialize
|
|
85
|
+
*/
|
|
86
|
+
export const createMcpServer = async () => {
|
|
87
|
+
// Initialize server variable outside try/catch
|
|
88
|
+
let server;
|
|
89
|
+
// Maximum registration retry attempts
|
|
90
|
+
const MAX_REGISTRATION_RETRIES = 3;
|
|
91
|
+
// Create a unique server instance ID
|
|
92
|
+
const serverId = idGenerator.generateRandomString(8);
|
|
93
|
+
// Initialize server state for tracking
|
|
94
|
+
const serverState = {
|
|
95
|
+
status: 'initializing',
|
|
96
|
+
startTime: new Date(),
|
|
97
|
+
lastHealthCheck: new Date(),
|
|
98
|
+
activeOperations: new Map(),
|
|
99
|
+
errors: [],
|
|
100
|
+
registeredTools: new Set(),
|
|
101
|
+
registeredResources: new Set(),
|
|
102
|
+
failedRegistrations: [],
|
|
103
|
+
requiredTools: new Set(['send_ntfy']), // Define tools that are required for the server to function properly
|
|
104
|
+
requiredResources: new Set([]) // Define resources that are required for the server to function properly
|
|
105
|
+
};
|
|
106
|
+
// Create operation context
|
|
107
|
+
const serverContext = createRequestContext({
|
|
108
|
+
operation: 'ServerStartup',
|
|
109
|
+
component: 'McpServer',
|
|
110
|
+
serverId
|
|
111
|
+
});
|
|
112
|
+
// Create server-specific logger with context
|
|
113
|
+
const serverLogger = logger.createChildLogger({
|
|
114
|
+
module: 'MCPServer',
|
|
115
|
+
service: 'MCPServer',
|
|
116
|
+
requestId: serverContext.requestId,
|
|
117
|
+
serverId,
|
|
118
|
+
environment: config.environment
|
|
119
|
+
});
|
|
120
|
+
// Create server events emitter
|
|
121
|
+
const serverEvents = new ServerEvents();
|
|
122
|
+
// Monitor state changes
|
|
123
|
+
serverEvents.on('stateChange', (oldState, newState) => {
|
|
124
|
+
serverLogger.info(`Server state changed from ${oldState} to ${newState}`, {
|
|
125
|
+
previousState: oldState,
|
|
126
|
+
newState
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
console.error("Initializing MCP server...");
|
|
130
|
+
serverLogger.info("Initializing server...");
|
|
131
|
+
const timers = [];
|
|
132
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
133
|
+
// Load package info asynchronously
|
|
134
|
+
const packageInfo = await loadPackageInfo();
|
|
135
|
+
// Update logger with package info
|
|
136
|
+
console.error("Loaded package info:", packageInfo.name, packageInfo.version);
|
|
137
|
+
serverLogger.info("Loaded package info", {
|
|
138
|
+
name: packageInfo.name,
|
|
139
|
+
version: packageInfo.version
|
|
140
|
+
});
|
|
141
|
+
// Create the MCP server instance
|
|
142
|
+
console.error("Creating MCP server instance...");
|
|
143
|
+
server = new McpServer({
|
|
144
|
+
name: packageInfo.name,
|
|
145
|
+
version: packageInfo.version
|
|
146
|
+
});
|
|
147
|
+
console.error("MCP server instance created");
|
|
148
|
+
const registerComponent = async (type, name, registerFn) => {
|
|
149
|
+
console.error(`Registering ${type}: ${name}`);
|
|
150
|
+
try {
|
|
151
|
+
await ErrorHandler.tryCatch(async () => await registerFn(), {
|
|
152
|
+
operation: `Register${type === 'tool' ? 'Tool' : 'Resource'}`,
|
|
153
|
+
context: { ...serverContext, componentName: name },
|
|
154
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR
|
|
155
|
+
});
|
|
156
|
+
// Update state based on component type
|
|
157
|
+
if (type === 'tool') {
|
|
158
|
+
serverState.registeredTools.add(name);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
serverState.registeredResources.add(name);
|
|
162
|
+
}
|
|
163
|
+
console.error(`Successfully registered ${type}: ${name}`);
|
|
164
|
+
return { success: true, type, name };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error(`Failed to register ${type}: ${name}`, error);
|
|
168
|
+
return { success: false, type, name, error };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
// Register components with proper error handling
|
|
172
|
+
console.error("Registering components...");
|
|
173
|
+
const registrationPromises = [
|
|
174
|
+
registerComponent('tool', 'send_ntfy', () => registerNtfyTool(server)),
|
|
175
|
+
registerComponent('resource', 'ntfy-resource', () => registerNtfyResource(server)),
|
|
176
|
+
];
|
|
177
|
+
const registrationResults = await Promise.allSettled(registrationPromises);
|
|
178
|
+
// Process the results to find failed registrations
|
|
179
|
+
const failedRegistrations = [];
|
|
180
|
+
registrationResults.forEach(result => {
|
|
181
|
+
if (result.status === 'rejected') {
|
|
182
|
+
failedRegistrations.push({
|
|
183
|
+
success: false,
|
|
184
|
+
type: 'unknown',
|
|
185
|
+
name: 'unknown',
|
|
186
|
+
error: result.reason
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (!result.value.success) {
|
|
190
|
+
failedRegistrations.push(result.value);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
// Process failed registrations
|
|
194
|
+
if (failedRegistrations.length > 0) {
|
|
195
|
+
console.error(`${failedRegistrations.length} registrations failed initially`, failedRegistrations.map(f => `${f.type}:${f.name}`));
|
|
196
|
+
serverLogger.warn(`${failedRegistrations.length} registrations failed initially`, {
|
|
197
|
+
failedComponents: failedRegistrations.map(f => `${f.type}:${f.name}`)
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Add debug logs to diagnose the connection issue
|
|
201
|
+
console.error("About to connect to stdio transport");
|
|
202
|
+
try {
|
|
203
|
+
// Connect using stdio transport
|
|
204
|
+
const transport = new StdioServerTransport();
|
|
205
|
+
console.error("Created StdioServerTransport instance");
|
|
206
|
+
// Set event handlers - using type assertion to avoid TS errors
|
|
207
|
+
server.onerror = (err) => {
|
|
208
|
+
console.error(`Server error: ${err.message}`);
|
|
209
|
+
};
|
|
210
|
+
// Skip setting onrequest since we don't have access to the type
|
|
211
|
+
await server.connect(transport);
|
|
212
|
+
console.error("Connected to transport successfully");
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
console.error("Error connecting to transport:", error);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
console.error("MCP server initialized and connected");
|
|
219
|
+
return server;
|
|
220
|
+
}, {
|
|
221
|
+
operation: 'CreateMcpServer',
|
|
222
|
+
context: serverContext,
|
|
223
|
+
critical: true,
|
|
224
|
+
errorMapper: (error) => new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to initialize MCP server: ${error instanceof Error ? error.message : String(error)}`, {
|
|
225
|
+
serverState: serverState.status,
|
|
226
|
+
startTime: serverState.startTime,
|
|
227
|
+
registeredTools: Array.from(serverState.registeredTools),
|
|
228
|
+
registeredResources: Array.from(serverState.registeredResources)
|
|
229
|
+
})
|
|
230
|
+
}).catch((error) => {
|
|
231
|
+
console.error("Fatal error in MCP server creation:", error);
|
|
232
|
+
// Attempt to close server
|
|
233
|
+
if (server) {
|
|
234
|
+
try {
|
|
235
|
+
server.close();
|
|
236
|
+
}
|
|
237
|
+
catch (closeError) {
|
|
238
|
+
// Already in error state, just log
|
|
239
|
+
console.error("Error while closing server during error recovery:", closeError);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Re-throw to communicate error to caller
|
|
243
|
+
throw error;
|
|
244
|
+
});
|
|
245
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* Register the send_ntfy tool with the MCP server
|
|
4
|
+
*
|
|
5
|
+
* This function registers a tool for sending notifications via ntfy.sh with
|
|
6
|
+
* comprehensive parameter support for all ntfy features.
|
|
7
|
+
*
|
|
8
|
+
* @param server - The MCP server instance to register the tool with
|
|
9
|
+
* @returns Promise resolving when registration is complete
|
|
10
|
+
*/
|
|
11
|
+
export declare const registerNtfyTool: (server: McpServer) => Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { config } from "../../../config/index.js";
|
|
2
|
+
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
3
|
+
import { ErrorHandler } from "../../../utils/errorHandler.js";
|
|
4
|
+
import { logger } from "../../../utils/logger.js";
|
|
5
|
+
import { createRequestContext } from "../../../utils/requestContext.js";
|
|
6
|
+
import { sanitizeInputForLogging } from "../../../utils/sanitization.js";
|
|
7
|
+
import { registerTool } from "../../utils/registrationHelper.js";
|
|
8
|
+
import { processNtfyMessage } from "./ntfyMessage.js";
|
|
9
|
+
import { SendNtfyToolInputSchema } from "./types.js";
|
|
10
|
+
// Create module logger
|
|
11
|
+
const moduleLogger = logger.createChildLogger({
|
|
12
|
+
module: 'NtfyToolRegistration'
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Register the send_ntfy tool with the MCP server
|
|
16
|
+
*
|
|
17
|
+
* This function registers a tool for sending notifications via ntfy.sh with
|
|
18
|
+
* comprehensive parameter support for all ntfy features.
|
|
19
|
+
*
|
|
20
|
+
* @param server - The MCP server instance to register the tool with
|
|
21
|
+
* @returns Promise resolving when registration is complete
|
|
22
|
+
*/
|
|
23
|
+
export const registerNtfyTool = async (server) => {
|
|
24
|
+
// Create a request context for tracking this registration operation
|
|
25
|
+
const requestCtx = createRequestContext({
|
|
26
|
+
operation: 'registerNtfyTool',
|
|
27
|
+
component: 'NtfyTool'
|
|
28
|
+
});
|
|
29
|
+
moduleLogger.info('Starting ntfy tool registration');
|
|
30
|
+
return registerTool(server, { name: "send_ntfy" }, async (server, toolLogger) => {
|
|
31
|
+
// Create a fresh schema with the latest config values
|
|
32
|
+
// This ensures we have the most up-to-date environment variables
|
|
33
|
+
const schemaWithLatestConfig = SendNtfyToolInputSchema();
|
|
34
|
+
// Log default topic info at registration time for verification
|
|
35
|
+
const ntfyConfig = config.ntfy;
|
|
36
|
+
toolLogger.info('Registering ntfy tool handler with config', {
|
|
37
|
+
defaultTopic: ntfyConfig.defaultTopic || '(not set)',
|
|
38
|
+
baseUrl: ntfyConfig.baseUrl,
|
|
39
|
+
apiKeyPresent: !!ntfyConfig.apiKey
|
|
40
|
+
});
|
|
41
|
+
// Register the tool using the simplified SDK pattern
|
|
42
|
+
server.tool("send_ntfy", schemaWithLatestConfig.shape, async (params) => {
|
|
43
|
+
// Create request context for tracking this invocation
|
|
44
|
+
const toolRequestCtx = createRequestContext({
|
|
45
|
+
operation: 'handleNtfyTool',
|
|
46
|
+
topic: params?.topic
|
|
47
|
+
});
|
|
48
|
+
toolLogger.debug('Received tool invocation', {
|
|
49
|
+
requestId: toolRequestCtx.requestId,
|
|
50
|
+
topic: params?.topic
|
|
51
|
+
});
|
|
52
|
+
// Use ErrorHandler for consistent error handling
|
|
53
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
54
|
+
// Process the notification
|
|
55
|
+
const response = await processNtfyMessage(params);
|
|
56
|
+
toolLogger.info('Successfully processed ntfy message', {
|
|
57
|
+
messageId: response.id,
|
|
58
|
+
topic: response.topic,
|
|
59
|
+
retries: response.retries
|
|
60
|
+
});
|
|
61
|
+
// Return in the standard MCP format
|
|
62
|
+
return {
|
|
63
|
+
content: [{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: JSON.stringify(response, null, 2)
|
|
66
|
+
}]
|
|
67
|
+
};
|
|
68
|
+
}, {
|
|
69
|
+
operation: 'sending ntfy notification',
|
|
70
|
+
context: {
|
|
71
|
+
requestId: toolRequestCtx.requestId,
|
|
72
|
+
topic: params?.topic
|
|
73
|
+
},
|
|
74
|
+
input: sanitizeInputForLogging(params),
|
|
75
|
+
// Map errors appropriately
|
|
76
|
+
errorMapper: (error) => {
|
|
77
|
+
// Log the error
|
|
78
|
+
toolLogger.error('Error processing ntfy tool request', {
|
|
79
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
80
|
+
errorType: error instanceof Error ? error.name : 'Unknown',
|
|
81
|
+
topic: params?.topic
|
|
82
|
+
});
|
|
83
|
+
// Pass through McpErrors, map others properly
|
|
84
|
+
if (error instanceof McpError) {
|
|
85
|
+
return error;
|
|
86
|
+
}
|
|
87
|
+
// Try to classify unknown errors
|
|
88
|
+
if (error instanceof Error) {
|
|
89
|
+
const errorMsg = error.message.toLowerCase();
|
|
90
|
+
if (errorMsg.includes('validation') || errorMsg.includes('invalid')) {
|
|
91
|
+
return new McpError(BaseErrorCode.VALIDATION_ERROR, `Validation error: ${error.message}`);
|
|
92
|
+
}
|
|
93
|
+
else if (errorMsg.includes('not found') || errorMsg.includes('missing')) {
|
|
94
|
+
return new McpError(BaseErrorCode.NOT_FOUND, `Resource not found: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
else if (errorMsg.includes('timeout')) {
|
|
97
|
+
return new McpError(BaseErrorCode.TIMEOUT, `Request timed out: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
else if (errorMsg.includes('rate limit')) {
|
|
100
|
+
return new McpError(BaseErrorCode.RATE_LIMITED, `Rate limit exceeded: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Default to service unavailable for network/connection issues
|
|
104
|
+
return new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Failed to send notification: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
toolLogger.info("Ntfy tool handler registered successfully");
|
|
109
|
+
});
|
|
110
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SendNtfyToolInput, SendNtfyToolResponse } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Process and send a notification via ntfy
|
|
4
|
+
* Includes rate limiting, message validation, and retry logic
|
|
5
|
+
*
|
|
6
|
+
* @param params - Parameters for the ntfy message
|
|
7
|
+
* @returns Response with notification details
|
|
8
|
+
*/
|
|
9
|
+
export declare const processNtfyMessage: (params: SendNtfyToolInput) => Promise<SendNtfyToolResponse>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
2
|
+
import { ErrorHandler } from "../../../utils/errorHandler.js";
|
|
3
|
+
import { publish, validateTopicSync } from "../../../services/ntfy/index.js";
|
|
4
|
+
import { config } from "../../../config/index.js";
|
|
5
|
+
import { logger } from "../../../utils/logger.js";
|
|
6
|
+
import { createRequestContext } from "../../../utils/requestContext.js";
|
|
7
|
+
import { sanitizeInput, sanitizeInputForLogging } from "../../../utils/sanitization.js";
|
|
8
|
+
import { idGenerator } from "../../../utils/idGenerator.js";
|
|
9
|
+
import { RateLimiter } from "../../../utils/rateLimiter.js";
|
|
10
|
+
// Create a module-specific logger
|
|
11
|
+
const ntfyToolLogger = logger.createChildLogger({
|
|
12
|
+
module: 'NtfyTool',
|
|
13
|
+
serviceId: idGenerator.generateRandomString(8)
|
|
14
|
+
});
|
|
15
|
+
// Create rate limiters for global and per-topic usage
|
|
16
|
+
const rateLimit = config.rateLimit;
|
|
17
|
+
const globalRateLimiter = new RateLimiter({
|
|
18
|
+
windowMs: rateLimit.windowMs,
|
|
19
|
+
maxRequests: rateLimit.maxRequests,
|
|
20
|
+
errorMessage: 'Global rate limit exceeded for ntfy notifications. Please try again in {waitTime} seconds.',
|
|
21
|
+
});
|
|
22
|
+
// Map to cache per-topic rate limiters
|
|
23
|
+
const topicRateLimiters = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Gets or creates a rate limiter for a specific topic
|
|
26
|
+
*
|
|
27
|
+
* @param topic - The ntfy topic
|
|
28
|
+
* @returns Rate limiter instance for the topic
|
|
29
|
+
*/
|
|
30
|
+
function getTopicRateLimiter(topic) {
|
|
31
|
+
const normalizedTopic = topic.toLowerCase().trim();
|
|
32
|
+
if (!topicRateLimiters.has(normalizedTopic)) {
|
|
33
|
+
// Make per-topic limit more restrictive than global
|
|
34
|
+
const perTopicLimit = Math.min(50, Math.floor(rateLimit.maxRequests / 2));
|
|
35
|
+
topicRateLimiters.set(normalizedTopic, new RateLimiter({
|
|
36
|
+
windowMs: rateLimit.windowMs,
|
|
37
|
+
maxRequests: perTopicLimit,
|
|
38
|
+
errorMessage: `Rate limit exceeded for topic '${normalizedTopic}'. Please try again in {waitTime} seconds.`,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
return topicRateLimiters.get(normalizedTopic);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Process and send a notification via ntfy
|
|
45
|
+
* Includes rate limiting, message validation, and retry logic
|
|
46
|
+
*
|
|
47
|
+
* @param params - Parameters for the ntfy message
|
|
48
|
+
* @returns Response with notification details
|
|
49
|
+
*/
|
|
50
|
+
export const processNtfyMessage = async (params) => {
|
|
51
|
+
return ErrorHandler.tryCatch(async () => {
|
|
52
|
+
// Create request context for tracking
|
|
53
|
+
const requestCtx = createRequestContext({
|
|
54
|
+
operation: 'processNtfyMessage',
|
|
55
|
+
messageId: idGenerator.generateRandomString(8),
|
|
56
|
+
hasTitle: !!params.title,
|
|
57
|
+
hasTags: !!params.tags && params.tags.length > 0,
|
|
58
|
+
priority: params.priority,
|
|
59
|
+
topic: params.topic
|
|
60
|
+
});
|
|
61
|
+
// Extract the necessary parameters
|
|
62
|
+
const { topic, message, ...options } = params;
|
|
63
|
+
ntfyToolLogger.info('Processing ntfy message request', {
|
|
64
|
+
topic,
|
|
65
|
+
hasTags: !!options.tags && options.tags.length > 0,
|
|
66
|
+
hasTitle: !!options.title,
|
|
67
|
+
messageLength: message?.length,
|
|
68
|
+
requestId: requestCtx.requestId
|
|
69
|
+
});
|
|
70
|
+
// Get the ntfy config
|
|
71
|
+
const ntfyConfig = config.ntfy;
|
|
72
|
+
// Use default topic from env if not provided
|
|
73
|
+
const finalTopic = topic || ntfyConfig.defaultTopic;
|
|
74
|
+
// Log the topic resolution (more visible INFO level)
|
|
75
|
+
ntfyToolLogger.info('Topic resolution', {
|
|
76
|
+
providedTopic: topic || '(not provided)',
|
|
77
|
+
defaultTopic: ntfyConfig.defaultTopic || '(not configured)',
|
|
78
|
+
finalTopic: finalTopic || '(none)',
|
|
79
|
+
requestId: requestCtx.requestId
|
|
80
|
+
});
|
|
81
|
+
// Validate topic is present
|
|
82
|
+
if (!finalTopic) {
|
|
83
|
+
ntfyToolLogger.error('Topic validation failed - missing topic', {
|
|
84
|
+
requestId: requestCtx.requestId
|
|
85
|
+
});
|
|
86
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Topic is required and no default topic is configured in the environment");
|
|
87
|
+
}
|
|
88
|
+
// Additional topic validation using our utility
|
|
89
|
+
if (!validateTopicSync(finalTopic)) {
|
|
90
|
+
ntfyToolLogger.error('Topic validation failed - invalid topic format', {
|
|
91
|
+
topic: finalTopic,
|
|
92
|
+
requestId: requestCtx.requestId
|
|
93
|
+
});
|
|
94
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Invalid topic format. Topics must be non-empty and cannot contain newlines");
|
|
95
|
+
}
|
|
96
|
+
// Apply rate limiting (both global and per-topic)
|
|
97
|
+
try {
|
|
98
|
+
// Check global rate limit first
|
|
99
|
+
globalRateLimiter.check('global');
|
|
100
|
+
// Then check per-topic rate limit
|
|
101
|
+
getTopicRateLimiter(finalTopic).check(finalTopic);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof McpError && error.code === BaseErrorCode.RATE_LIMITED) {
|
|
105
|
+
ntfyToolLogger.warn('Rate limit exceeded', {
|
|
106
|
+
requestId: requestCtx.requestId,
|
|
107
|
+
topic: finalTopic,
|
|
108
|
+
error: error.message
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Always throw rate limit errors
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
// Message size validation
|
|
115
|
+
const messageSize = Buffer.byteLength(message, 'utf8');
|
|
116
|
+
const maxSize = ntfyConfig.maxMessageSize || 4096;
|
|
117
|
+
if (messageSize > maxSize) {
|
|
118
|
+
ntfyToolLogger.error('Message size validation failed', {
|
|
119
|
+
messageSize,
|
|
120
|
+
maxSize,
|
|
121
|
+
requestId: requestCtx.requestId
|
|
122
|
+
});
|
|
123
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Message size (${messageSize} bytes) exceeds maximum allowed size (${maxSize} bytes)`);
|
|
124
|
+
}
|
|
125
|
+
// Prepare sanitized publish options
|
|
126
|
+
const publishOptions = {
|
|
127
|
+
// Message metadata
|
|
128
|
+
title: options.title ? sanitizeInput.string(options.title) : undefined,
|
|
129
|
+
tags: options.tags?.map(tag => sanitizeInput.string(tag)),
|
|
130
|
+
priority: options.priority,
|
|
131
|
+
markdown: options.markdown,
|
|
132
|
+
// Interactive elements
|
|
133
|
+
click: options.click ? sanitizeInput.url(options.click) : undefined,
|
|
134
|
+
actions: options.actions?.map(action => ({
|
|
135
|
+
id: sanitizeInput.string(action.id),
|
|
136
|
+
label: sanitizeInput.string(action.label),
|
|
137
|
+
action: sanitizeInput.string(action.action),
|
|
138
|
+
url: action.url ? sanitizeInput.url(action.url) : undefined,
|
|
139
|
+
method: action.method ? sanitizeInput.string(action.method) : undefined,
|
|
140
|
+
headers: action.headers,
|
|
141
|
+
body: action.body ? sanitizeInput.string(action.body) : undefined,
|
|
142
|
+
clear: action.clear
|
|
143
|
+
})),
|
|
144
|
+
// Media and attachments
|
|
145
|
+
attachment: options.attachment && {
|
|
146
|
+
url: sanitizeInput.url(options.attachment.url),
|
|
147
|
+
name: options.attachment.name
|
|
148
|
+
? sanitizeInput.string(options.attachment.name)
|
|
149
|
+
: sanitizeInput.string(options.attachment.url.split('/').pop() || 'attachment')
|
|
150
|
+
},
|
|
151
|
+
// Delivery options
|
|
152
|
+
email: options.email ? sanitizeInput.string(options.email) : undefined,
|
|
153
|
+
delay: options.delay ? sanitizeInput.string(options.delay) : undefined,
|
|
154
|
+
cache: options.cache ? sanitizeInput.string(options.cache) : undefined,
|
|
155
|
+
firebase: options.firebase ? sanitizeInput.string(options.firebase) : undefined,
|
|
156
|
+
expires: options.expires ? sanitizeInput.string(options.expires) : undefined,
|
|
157
|
+
id: options.id ? sanitizeInput.string(options.id) : undefined,
|
|
158
|
+
// Server configuration
|
|
159
|
+
baseUrl: options.baseUrl ? sanitizeInput.url(options.baseUrl) : ntfyConfig.baseUrl,
|
|
160
|
+
};
|
|
161
|
+
ntfyToolLogger.debug('Prepared publish options', {
|
|
162
|
+
topic: finalTopic,
|
|
163
|
+
hasTitle: !!publishOptions.title,
|
|
164
|
+
hasTags: !!publishOptions.tags && publishOptions.tags.length > 0,
|
|
165
|
+
baseUrl: publishOptions.baseUrl,
|
|
166
|
+
messageSize,
|
|
167
|
+
requestId: requestCtx.requestId
|
|
168
|
+
});
|
|
169
|
+
// Set authentication if API key is available
|
|
170
|
+
if (ntfyConfig.apiKey) {
|
|
171
|
+
publishOptions.auth = ntfyConfig.apiKey;
|
|
172
|
+
}
|
|
173
|
+
ntfyToolLogger.debug('Authentication configured', {
|
|
174
|
+
hasAuth: !!publishOptions.auth,
|
|
175
|
+
apiKeyAvailable: !!ntfyConfig.apiKey,
|
|
176
|
+
requestId: requestCtx.requestId
|
|
177
|
+
});
|
|
178
|
+
ntfyToolLogger.debug('Publishing with options', {
|
|
179
|
+
topic: finalTopic,
|
|
180
|
+
messageSize,
|
|
181
|
+
hasAuth: !!publishOptions.auth,
|
|
182
|
+
hasTitle: !!publishOptions.title,
|
|
183
|
+
hasTags: !!publishOptions.tags,
|
|
184
|
+
requestId: requestCtx.requestId
|
|
185
|
+
});
|
|
186
|
+
// Send with retry logic
|
|
187
|
+
const maxRetries = ntfyConfig.maxRetries || 3;
|
|
188
|
+
let retries = 0;
|
|
189
|
+
let result;
|
|
190
|
+
for (retries = 0; retries <= maxRetries; retries++) {
|
|
191
|
+
try {
|
|
192
|
+
// Apply exponential backoff for retries
|
|
193
|
+
if (retries > 0) {
|
|
194
|
+
const backoffMs = Math.min(100 * Math.pow(2, retries), 2000);
|
|
195
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
196
|
+
ntfyToolLogger.info(`Retry attempt ${retries}/${maxRetries}`, {
|
|
197
|
+
topic: finalTopic,
|
|
198
|
+
requestId: requestCtx.requestId
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
ntfyToolLogger.info(`Sending notification${retries > 0 ? ' (retry)' : ''}`, {
|
|
202
|
+
topic: finalTopic,
|
|
203
|
+
messageLength: messageSize,
|
|
204
|
+
retry: retries,
|
|
205
|
+
requestId: requestCtx.requestId
|
|
206
|
+
});
|
|
207
|
+
// Publish the message
|
|
208
|
+
result = await publish(finalTopic, message, publishOptions);
|
|
209
|
+
ntfyToolLogger.info('Notification sent successfully', {
|
|
210
|
+
messageId: result.id,
|
|
211
|
+
topic: result.topic,
|
|
212
|
+
retries,
|
|
213
|
+
requestId: requestCtx.requestId
|
|
214
|
+
});
|
|
215
|
+
// Success - exit retry loop
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// Determine if error is retriable
|
|
220
|
+
const errorMsg = error instanceof Error ? error.message.toLowerCase() : '';
|
|
221
|
+
const isNetworkError = errorMsg.includes('network') ||
|
|
222
|
+
errorMsg.includes('timeout') ||
|
|
223
|
+
errorMsg.includes('connection') ||
|
|
224
|
+
errorMsg.includes('econnrefused') ||
|
|
225
|
+
errorMsg.includes('econnreset');
|
|
226
|
+
if (!isNetworkError || retries >= maxRetries) {
|
|
227
|
+
ntfyToolLogger.error('Failed to send notification, giving up', {
|
|
228
|
+
topic: finalTopic,
|
|
229
|
+
error: error instanceof Error ? error.message : String(error),
|
|
230
|
+
retries,
|
|
231
|
+
requestId: requestCtx.requestId
|
|
232
|
+
});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
ntfyToolLogger.warn('Notification failed, will retry', {
|
|
236
|
+
topic: finalTopic,
|
|
237
|
+
error: error instanceof Error ? error.message : String(error),
|
|
238
|
+
retryCount: retries,
|
|
239
|
+
nextRetry: retries + 1,
|
|
240
|
+
requestId: requestCtx.requestId
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Verify we have a result
|
|
245
|
+
if (!result) {
|
|
246
|
+
throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Failed to send notification after ${maxRetries} retries`);
|
|
247
|
+
}
|
|
248
|
+
// Return the response
|
|
249
|
+
return {
|
|
250
|
+
success: true,
|
|
251
|
+
id: result.id,
|
|
252
|
+
topic: result.topic,
|
|
253
|
+
time: result.time,
|
|
254
|
+
expires: result.expires,
|
|
255
|
+
message: message,
|
|
256
|
+
title: options.title,
|
|
257
|
+
url: options.click,
|
|
258
|
+
retries: retries > 0 ? retries : undefined
|
|
259
|
+
};
|
|
260
|
+
}, {
|
|
261
|
+
operation: 'processNtfyMessage',
|
|
262
|
+
context: {
|
|
263
|
+
topic: params.topic || config.ntfy.defaultTopic,
|
|
264
|
+
hasTitle: !!params.title
|
|
265
|
+
},
|
|
266
|
+
input: sanitizeInputForLogging(params),
|
|
267
|
+
errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
|
|
268
|
+
errorMapper: (error) => {
|
|
269
|
+
if (error instanceof McpError) {
|
|
270
|
+
return error;
|
|
271
|
+
}
|
|
272
|
+
// Map common errors to more specific error codes
|
|
273
|
+
if (error instanceof Error) {
|
|
274
|
+
const errorMsg = error.message.toLowerCase();
|
|
275
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('too many requests')) {
|
|
276
|
+
return new McpError(BaseErrorCode.RATE_LIMITED, `Rate limit exceeded: ${error.message}`);
|
|
277
|
+
}
|
|
278
|
+
if (errorMsg.includes('timeout')) {
|
|
279
|
+
return new McpError(BaseErrorCode.TIMEOUT, `Request timed out: ${error.message}`);
|
|
280
|
+
}
|
|
281
|
+
if (errorMsg.includes('validation') || errorMsg.includes('invalid')) {
|
|
282
|
+
return new McpError(BaseErrorCode.VALIDATION_ERROR, `Validation error: ${error.message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Failed to send ntfy notification: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
286
|
+
},
|
|
287
|
+
rethrow: true
|
|
288
|
+
});
|
|
289
|
+
};
|