Skip to main content
Glama

NTFY MCP Server

server.ts15.5 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, ChildLogger } from "../utils/logger.js"; import { createRequestContext } from "../utils/requestContext.js"; import { configureContext, 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?: ChildLogger): Promise<{ name: string; version: string }> => { const pkgLogger = loggerInstance || logger.createChildLogger({ module: 'PackageInfo' }); return await ErrorHandler.tryCatch( async () => { // Use the globally defined __dirname from the top of the file 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 state management interface */ export interface ServerState { status: 'initializing' | 'running' | 'error' | 'degraded' | 'shutting_down' | 'shutdown'; startTime: Date; lastHealthCheck: Date; activeOperations: Map<string, { operation: string; startTime: Date }>; errors: Array<{ timestamp: Date; message: string; code?: string }>; registeredTools: Set<string>; registeredResources: Set<string>; failedRegistrations: Array<{ type: 'tool' | 'resource'; name: string; error: any; // Ensure error is always present attempts: number; // Track attempts if retry logic is added }>; requiredTools: Set<string>; // Tools essential for basic functionality requiredResources: Set<string>; // Resources essential for basic functionality } /** * Server event emitter for lifecycle events */ class ServerEvents extends EventEmitter { constructor() { super(); } // Type-safe event emitters emitStateChange(oldState: ServerState['status'], newState: ServerState['status']) { 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: McpServer | undefined; // Maximum registration retry attempts (currently not implemented, but placeholder) const MAX_REGISTRATION_RETRIES = 1; // Create a unique server instance ID const serverId = idGenerator.generateRandomString(8); // Initialize server state for tracking const serverState: 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: Array<NodeJS.Timeout> = []; 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"); // Register tools and resources in parallel with error handling type RegistrationResult = { success: boolean; type: 'tool' | 'resource'; name: string; error?: any; // Error is optional here as success=true means no error }; const registerComponent = async ( type: 'tool' | 'resource', name: string, registerFn: () => Promise<void> ): Promise<RegistrationResult> => { 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 }; // No error on success } catch (error) { serverLogger.error(`Failed to register ${type}: ${name}`, { error }); return { success: false, type, name, error }; // Error included on failure } }; // Register components with proper error handling serverLogger.debug("Registering components..."); const registrationPromises: Promise<RegistrationResult>[] = [ 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 let hasRequiredFailure = false; registrationResults.forEach(result => { if (result.status === 'rejected') { // This indicates an unexpected error during the registerComponent wrapper itself const failure = { type: 'unknown' as 'tool' | 'resource', name: 'unknown', error: result.reason ?? new Error('Unknown registration wrapper error'), // Ensure error exists attempts: 1 // Assuming 1 attempt for now }; serverState.failedRegistrations.push(failure); serverLogger.error("Unexpected error during component registration wrapper", { failure }); // Assume any unknown failure could be critical hasRequiredFailure = true; } else if (!result.value.success) { // This indicates a failure within the specific registerFn (result.value.error should exist) const failure = { type: result.value.type, name: result.value.name, // Provide a fallback error just in case, though logic implies error exists error: result.value.error ?? new Error(`Unknown error registering ${result.value.type} ${result.value.name}`), attempts: 1 // Assuming 1 attempt for now }; serverState.failedRegistrations.push(failure); serverLogger.warn(`Registration failed for ${failure.type}: ${failure.name}`, { error: failure.error }); // Check if the failed component was required if ((failure.type === 'tool' && serverState.requiredTools.has(failure.name)) || (failure.type === 'resource' && serverState.requiredResources.has(failure.name))) { serverLogger.error(`Required ${failure.type} '${failure.name}' failed to register. Server will be degraded.`, { error: failure.error }); hasRequiredFailure = true; } } }); // Update server status based on registration results const previousStatus = serverState.status; if (hasRequiredFailure) { serverState.status = 'degraded'; } else { serverState.status = 'running'; // Move to running only if all required components registered } // Emit state change if status updated if (serverState.status !== previousStatus) { serverEvents.emitStateChange(previousStatus, serverState.status); } serverLogger.info(`Component registration complete. Status: ${serverState.status}`, { registeredTools: Array.from(serverState.registeredTools), registeredResources: Array.from(serverState.registeredResources), failedCount: serverState.failedRegistrations.length, failedComponents: serverState.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 'as any' for onerror as the type might not be directly exposed or stable in the SDK. // This bypasses TypeScript checks but allows attaching the handler. // TODO: Revisit if future SDK versions provide a type-safe way to attach error handlers. (server as any).onerror = (err: Error) => { serverLogger.error(`Server transport error: ${err.message}`, { stack: err.stack }); // Optionally update server state on transport errors if (serverState.status !== 'error' && serverState.status !== 'shutting_down') { const oldStatus = serverState.status; serverState.status = 'error'; serverEvents.emitStateChange(oldStatus, 'error'); } serverState.errors.push({ timestamp: new Date(), message: err.message, code: 'TRANSPORT_ERROR' }); }; // 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 }); // Update state on connection failure const oldStatus = serverState.status; serverState.status = 'error'; serverEvents.emitStateChange(oldStatus, 'error'); throw error; // Re-throw connection 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; }); };

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/ntfy-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server