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.

255 lines (254 loc) 11.4 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { EventEmitter } from "events"; import { promises as fs } from "fs"; import path from "path"; import { fileURLToPath } from 'url'; import { config } from "../config/index.js"; import { BaseErrorCode, McpError } from "../types-global/errors.js"; import { ErrorHandler } from "../utils/errorHandler.js"; import { idGenerator } from "../utils/idGenerator.js"; import { logger } from "../utils/logger.js"; import { createRequestContext } from "../utils/requestContext.js"; import { sanitizeInput } from "../utils/security.js"; // Calculate __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Import tool and resource registrations import { registerNtfyTool } from "./tools/ntfyTool/index.js"; import { registerNtfyResource } from "./resources/ntfyResource/index.js"; // Maximum file size for package.json (5MB) to prevent potential DoS const MAX_FILE_SIZE = 5 * 1024 * 1024; /** * Load package information directly from package.json * * @param logger - The logger instance to use for logging * @returns A promise resolving to an object with the package name and version */ const loadPackageInfo = async (loggerInstance) => { const pkgLogger = loggerInstance || logger.createChildLogger({ module: 'PackageInfo' }); return await ErrorHandler.tryCatch(async () => { // Use the globally defined __dirname from the top of the file // Go up three levels from the compiled file location (e.g., dist/mcp-server/server.js) const pkgPath = path.resolve(__dirname, '../../../package.json'); const safePath = sanitizeInput.path(pkgPath); pkgLogger.debug(`Looking for package.json at: ${safePath}`); // Get file stats to check size before reading const stats = await fs.stat(safePath); // Check file size to prevent DoS attacks if (stats.size > MAX_FILE_SIZE) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `package.json file is too large (${stats.size} bytes)`, { path: safePath, maxSize: MAX_FILE_SIZE }); } const pkgContent = await fs.readFile(safePath, 'utf-8'); const pkg = JSON.parse(pkgContent); if (!pkg.name || typeof pkg.name !== 'string' || !pkg.version || typeof pkg.version !== 'string') { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid package.json: missing name or version', { path: safePath }); } return { name: pkg.name, version: pkg.version }; }, { operation: 'LoadPackageInfo', errorCode: BaseErrorCode.VALIDATION_ERROR, rethrow: true, // Changed to true so errors propagate includeStack: true, errorMapper: (error) => { if (error instanceof SyntaxError) { return new McpError(BaseErrorCode.VALIDATION_ERROR, `Failed to parse package.json: ${error.message}`, { errorType: 'SyntaxError' }); } 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 }); } }); }; /** * Server event emitter for lifecycle events */ class ServerEvents extends EventEmitter { constructor() { super(); } // Type-safe event emitters emitStateChange(oldState, newState) { this.emit('stateChange', oldState, newState); this.emit(`state:${newState}`, oldState); } } /** * Create and initialize an MCP server instance with all tools and resources * * This function configures the MCP server with security settings, tools, and resources. * It connects the server to a transport (currently stdio) and returns the initialized * server instance. * * @returns A promise that resolves to the initialized McpServer instance * @throws {McpError} If the server fails to initialize */ export const createMcpServer = async () => { // Initialize server variable outside try/catch let server; // Maximum registration retry attempts const MAX_REGISTRATION_RETRIES = 3; // Create a unique server instance ID const serverId = idGenerator.generateRandomString(8); // Initialize server state for tracking const serverState = { status: 'initializing', startTime: new Date(), lastHealthCheck: new Date(), activeOperations: new Map(), errors: [], registeredTools: new Set(), registeredResources: new Set(), failedRegistrations: [], requiredTools: new Set(['send_ntfy']), // Define tools that are required for the server to function properly requiredResources: new Set([]) // Define resources that are required for the server to function properly }; // Create operation context const serverContext = createRequestContext({ operation: 'ServerStartup', component: 'McpServer', serverId }); // Create server-specific logger with context const serverLogger = logger.createChildLogger({ module: 'MCPServer', service: 'MCPServer', requestId: serverContext.requestId, serverId, environment: config.environment }); // Create server events emitter const serverEvents = new ServerEvents(); // Monitor state changes serverEvents.on('stateChange', (oldState, newState) => { serverLogger.info(`Server state changed from ${oldState} to ${newState}`, { previousState: oldState, newState }); }); serverLogger.info("Initializing server..."); const timers = []; return await ErrorHandler.tryCatch(async () => { // Load package info asynchronously const packageInfo = await loadPackageInfo(serverLogger); // Update logger with package info serverLogger.info("Loaded package info", { name: packageInfo.name, version: packageInfo.version }); // Create the MCP server instance serverLogger.debug("Creating MCP server instance..."); server = new McpServer({ name: packageInfo.name, version: packageInfo.version }); serverLogger.debug("MCP server instance created"); const registerComponent = async (type, name, registerFn) => { serverLogger.debug(`Registering ${type}: ${name}`); try { await ErrorHandler.tryCatch(async () => await registerFn(), { operation: `Register${type === 'tool' ? 'Tool' : 'Resource'}`, context: { ...serverContext, componentName: name }, errorCode: BaseErrorCode.INTERNAL_ERROR }); // Update state based on component type if (type === 'tool') { serverState.registeredTools.add(name); } else { serverState.registeredResources.add(name); } serverLogger.debug(`Successfully registered ${type}: ${name}`); return { success: true, type, name }; } catch (error) { serverLogger.error(`Failed to register ${type}: ${name}`, { error }); return { success: false, type, name, error }; } }; // Register components with proper error handling serverLogger.debug("Registering components..."); const registrationPromises = [ registerComponent('tool', 'send_ntfy', () => registerNtfyTool(server)), registerComponent('resource', 'ntfy-resource', () => registerNtfyResource(server)), ]; const registrationResults = await Promise.allSettled(registrationPromises); // Process the results to find failed registrations const failedRegistrations = []; registrationResults.forEach(result => { if (result.status === 'rejected') { failedRegistrations.push({ success: false, type: 'unknown', name: 'unknown', error: result.reason }); } else if (!result.value.success) { failedRegistrations.push(result.value); } }); // Process failed registrations if (failedRegistrations.length > 0) { serverLogger.warn(`${failedRegistrations.length} registrations failed initially`, { failedComponents: failedRegistrations.map(f => `${f.type}:${f.name}`) }); } // Add debug logs to diagnose the connection issue serverLogger.debug("About to connect to stdio transport"); try { // Connect using stdio transport const transport = new StdioServerTransport(); serverLogger.debug("Created StdioServerTransport instance"); // Set event handlers - using type assertion to avoid TS errors server.onerror = (err) => { serverLogger.error(`Server error: ${err.message}`, { stack: err.stack }); }; // Skip setting onrequest since we don't have access to the type await server.connect(transport); serverLogger.debug("Connected to transport successfully"); } catch (error) { serverLogger.error("Error connecting to transport", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); throw error; } serverLogger.info("MCP server initialized and connected"); return server; }, { operation: 'CreateMcpServer', context: serverContext, critical: true, errorMapper: (error) => new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to initialize MCP server: ${error instanceof Error ? error.message : String(error)}`, { serverState: serverState.status, startTime: serverState.startTime, registeredTools: Array.from(serverState.registeredTools), registeredResources: Array.from(serverState.registeredResources) }) }).catch((error) => { serverLogger.error("Fatal error in MCP server creation", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); // Attempt to close server if (server) { try { server.close(); } catch (closeError) { // Already in error state, just log serverLogger.error("Error while closing server during error recovery", { error: closeError instanceof Error ? closeError.message : String(closeError), stack: closeError instanceof Error ? closeError.stack : undefined }); } } // Re-throw to communicate error to caller throw error; }); };