UNPKG

ntfy-mcp-server

Version:

An MCP (Model Context Protocol) server designed to interact with the ntfy push notification service. It enables LLMs and AI agents to send notifications to your devices with extensive customization options.

230 lines (229 loc) 9.92 kB
/** * Ntfy publisher implementation for sending notifications */ import { DEFAULT_NTFY_BASE_URL, DEFAULT_REQUEST_TIMEOUT, ERROR_MESSAGES } from './constants.js'; import { NtfyAuthenticationError, NtfyConnectionError, NtfyInvalidTopicError, ntfyErrorMapper } from './errors.js'; import { createTimeout, validateTopicSync, createRequestHeadersSync } from './utils.js'; import { BaseErrorCode, McpError } from '../../types-global/errors.js'; import { ErrorHandler } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { sanitizeInput, sanitizeInputForLogging } from '../../utils/sanitization.js'; import { createRequestContext } from '../../utils/requestContext.js'; import { idGenerator } from '../../utils/idGenerator.js'; // Create a module-specific logger const publisherLogger = logger.createChildLogger({ module: 'NtfyPublisher', serviceId: idGenerator.generateRandomString(8) }); /** * Publish a message to a ntfy topic * * @param topic - Topic to publish to * @param message - Message to publish * @param options - Publishing options * @returns Promise resolving to the publish response * @throws NtfyInvalidTopicError if the topic name is invalid * @throws NtfyConnectionError if the connection fails */ export async function publish(topic, message, options = {}) { return ErrorHandler.tryCatch(async () => { // Create request context for tracking const requestCtx = createRequestContext({ operation: 'publishNtfyMessage', topic, messageLength: message?.length, hasTitle: !!options.title, hasTags: Array.isArray(options.tags) && options.tags.length > 0, priority: options.priority, publishId: idGenerator.generateRandomString(8) }); publisherLogger.info('Publishing message', { topic, messageLength: message?.length, hasTitle: !!options.title, hasTags: Array.isArray(options.tags) && options.tags.length > 0, priority: options.priority, requestId: requestCtx.requestId }); // Validate topic synchronously for better performance if (!validateTopicSync(topic)) { publisherLogger.error('Invalid topic name', { topic, requestId: requestCtx.requestId }); throw new NtfyInvalidTopicError(ERROR_MESSAGES.INVALID_TOPIC, topic); } // Build URL const baseUrl = sanitizeInput.url(options.baseUrl || DEFAULT_NTFY_BASE_URL); const url = `${baseUrl}/${sanitizeInput.string(topic)}`; publisherLogger.debug('Publishing to URL', { url, requestId: requestCtx.requestId }); // Prepare headers - using sync version for performance const initialHeaders = createRequestHeadersSync({ auth: options.auth, username: options.username, password: options.password, headers: options.headers, }); // Convert HeadersInit to a Record for easier manipulation const headers = {}; // Copy initial headers to our record object if (initialHeaders instanceof Headers) { initialHeaders.forEach((value, key) => { headers[key] = value; }); } else if (Array.isArray(initialHeaders)) { for (const [key, value] of initialHeaders) { headers[key] = value; } } else if (initialHeaders) { Object.assign(headers, initialHeaders); } // Set content type headers['Content-Type'] = 'text/plain'; // Add special headers for ntfy features if (options.title) { headers['X-Title'] = sanitizeInput.string(options.title); } if (options.tags && options.tags.length > 0) { // Sanitize each tag const sanitizedTags = options.tags.map(tag => sanitizeInput.string(tag)); headers['X-Tags'] = sanitizedTags.join(','); } if (options.priority) { headers['X-Priority'] = options.priority.toString(); } if (options.click) { headers['X-Click'] = sanitizeInput.url(options.click); } if (options.actions && options.actions.length > 0) { // We need to sanitize the actions const sanitizedActions = options.actions.map(action => ({ id: sanitizeInput.string(action.id), label: sanitizeInput.string(action.label), action: sanitizeInput.string(action.action), url: action.url ? sanitizeInput.url(action.url) : undefined, method: action.method ? sanitizeInput.string(action.method) : undefined, headers: action.headers, body: action.body ? sanitizeInput.string(action.body) : undefined, clear: action.clear })); headers['X-Actions'] = JSON.stringify(sanitizedActions); } if (options.attachment) { headers['X-Attach'] = sanitizeInput.url(options.attachment.url); if (options.attachment.name) { headers['X-Filename'] = sanitizeInput.string(options.attachment.name); } } if (options.email) { headers['X-Email'] = sanitizeInput.string(options.email); } if (options.delay) { headers['X-Delay'] = sanitizeInput.string(options.delay); } if (options.cache) { headers['X-Cache'] = sanitizeInput.string(options.cache); } if (options.firebase) { headers['X-Firebase'] = sanitizeInput.string(options.firebase); } if (options.id) { headers['X-ID'] = sanitizeInput.string(options.id); } if (options.expires) { headers['X-Expires'] = sanitizeInput.string(options.expires); } if (options.markdown) { headers['X-Markdown'] = 'true'; } // Send request with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), DEFAULT_REQUEST_TIMEOUT); try { publisherLogger.debug('Sending HTTP request', { url, method: 'POST', requestId: requestCtx.requestId }); const response = await Promise.race([ fetch(url, { method: 'POST', headers, body: message, signal: controller.signal, }), createTimeout(DEFAULT_REQUEST_TIMEOUT), ]); clearTimeout(timeoutId); // Check response status if (!response.ok) { publisherLogger.error('HTTP error from ntfy server', { status: response.status, statusText: response.statusText, url, requestId: requestCtx.requestId }); // Provide more specific error messages based on status code let errorMessage = `HTTP Error: ${response.status} ${response.statusText}`; switch (response.status) { case 401: errorMessage = 'Authentication failed: invalid credentials'; throw new NtfyAuthenticationError(errorMessage); case 403: errorMessage = 'Access forbidden: insufficient permissions'; throw new McpError(BaseErrorCode.FORBIDDEN, errorMessage, { url, statusCode: response.status }); case 404: errorMessage = 'Topic or resource not found'; throw new McpError(BaseErrorCode.NOT_FOUND, errorMessage, { url, statusCode: response.status, topic }); case 429: errorMessage = 'Too many requests: rate limit exceeded'; throw new McpError(BaseErrorCode.RATE_LIMITED, errorMessage, { url, statusCode: response.status }); case 500: case 502: case 503: case 504: errorMessage = `Server error: ${response.statusText}`; // Fall through to default error handling default: throw new NtfyConnectionError(errorMessage, url); } } // Parse response const result = await response.json(); publisherLogger.info('Message published successfully', { messageId: result.id, topic: result.topic, requestId: requestCtx.requestId }); return result; } catch (error) { clearTimeout(timeoutId); if (error instanceof NtfyInvalidTopicError) { throw error; } publisherLogger.error('Failed to publish message', { error: error instanceof Error ? error.message : String(error), topic, url, requestId: requestCtx.requestId }); throw new NtfyConnectionError(`Error publishing to topic: ${error instanceof Error ? error.message : String(error)}`, url); } }, { operation: 'publishNtfyMessage', context: { topic }, input: { message: message?.length > 100 ? `${message.substring(0, 100)}...` : message, options: sanitizeInputForLogging(options) }, errorCode: BaseErrorCode.SERVICE_UNAVAILABLE, errorMapper: ntfyErrorMapper, rethrow: true }); }