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.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +423 -0
  3. package/dist/config/index.d.ts +23 -0
  4. package/dist/config/index.js +111 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +108 -0
  7. package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +2 -0
  8. package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +111 -0
  9. package/dist/mcp-server/resources/ntfyResource/index.d.ts +12 -0
  10. package/dist/mcp-server/resources/ntfyResource/index.js +72 -0
  11. package/dist/mcp-server/resources/ntfyResource/types.d.ts +27 -0
  12. package/dist/mcp-server/resources/ntfyResource/types.js +8 -0
  13. package/dist/mcp-server/server.d.ts +40 -0
  14. package/dist/mcp-server/server.js +245 -0
  15. package/dist/mcp-server/tools/ntfyTool/index.d.ts +11 -0
  16. package/dist/mcp-server/tools/ntfyTool/index.js +110 -0
  17. package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +9 -0
  18. package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +289 -0
  19. package/dist/mcp-server/tools/ntfyTool/types.d.ts +252 -0
  20. package/dist/mcp-server/tools/ntfyTool/types.js +144 -0
  21. package/dist/mcp-server/utils/registrationHelper.d.ts +48 -0
  22. package/dist/mcp-server/utils/registrationHelper.js +63 -0
  23. package/dist/services/ntfy/constants.d.ts +37 -0
  24. package/dist/services/ntfy/constants.js +37 -0
  25. package/dist/services/ntfy/errors.d.ts +79 -0
  26. package/dist/services/ntfy/errors.js +134 -0
  27. package/dist/services/ntfy/index.d.ts +33 -0
  28. package/dist/services/ntfy/index.js +56 -0
  29. package/dist/services/ntfy/publisher.d.ts +66 -0
  30. package/dist/services/ntfy/publisher.js +229 -0
  31. package/dist/services/ntfy/subscriber.d.ts +81 -0
  32. package/dist/services/ntfy/subscriber.js +502 -0
  33. package/dist/services/ntfy/types.d.ts +161 -0
  34. package/dist/services/ntfy/types.js +4 -0
  35. package/dist/services/ntfy/utils.d.ts +85 -0
  36. package/dist/services/ntfy/utils.js +410 -0
  37. package/dist/types-global/errors.d.ts +35 -0
  38. package/dist/types-global/errors.js +39 -0
  39. package/dist/types-global/mcp.d.ts +30 -0
  40. package/dist/types-global/mcp.js +25 -0
  41. package/dist/types-global/tool.d.ts +61 -0
  42. package/dist/types-global/tool.js +99 -0
  43. package/dist/utils/errorHandler.d.ts +98 -0
  44. package/dist/utils/errorHandler.js +271 -0
  45. package/dist/utils/idGenerator.d.ts +94 -0
  46. package/dist/utils/idGenerator.js +149 -0
  47. package/dist/utils/index.d.ts +13 -0
  48. package/dist/utils/index.js +16 -0
  49. package/dist/utils/logger.d.ts +36 -0
  50. package/dist/utils/logger.js +92 -0
  51. package/dist/utils/rateLimiter.d.ts +115 -0
  52. package/dist/utils/rateLimiter.js +180 -0
  53. package/dist/utils/requestContext.d.ts +68 -0
  54. package/dist/utils/requestContext.js +91 -0
  55. package/dist/utils/sanitization.d.ts +224 -0
  56. package/dist/utils/sanitization.js +367 -0
  57. package/dist/utils/security.d.ts +26 -0
  58. package/dist/utils/security.js +27 -0
  59. 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
+ };