UNPKG

ntfy-me-mcp

Version:

An ntfy MCP server for sending ntfy notifications to your self-hosted ntfy server from AI Agents 📤 (supports secure token auth & more - use with npx or docker!)

175 lines (174 loc) • 7.09 kB
import fetch from "node-fetch"; import { toolHandlerConfigSchema, } from "../schemas/toolHandlerConfig.schema.js"; import { Logger } from "./logger.js"; import { detectMarkdown } from "./markdown.js"; import { fetchMessages } from "./messages.js"; import { processActions } from "./actions.js"; import { sanitizeErrorMessage, validateNtfyTopic, validateNtfyUrl, } from "./validation.js"; const logger = Logger.getInstance(); export function createToolHandlers(config = {}) { const parsedConfig = toolHandlerConfigSchema.parse(config); const getDefaultTopic = parsedConfig.getDefaultTopic ?? (() => undefined); const getDefaultUrl = parsedConfig.getDefaultUrl ?? (() => "https://ntfy.sh"); const getDefaultToken = parsedConfig.getDefaultToken ?? (() => undefined); function resolveTopic(topic) { if (topic) { return validateNtfyTopic(topic, "topic"); } const defaultTopic = getDefaultTopic(); if (defaultTopic) { return validateNtfyTopic(defaultTopic, "NTFY_TOPIC"); } throw new Error("NTFY_TOPIC environment variable is required. Please ensure it's added to your .env file or passed as an environment variable."); } async function handleNotifyTool({ title, message, url: customUrl, topic: customTopic, accessToken, priority, tags, markdown, actions, }) { try { const url = customUrl || getDefaultUrl(); const topic = resolveTopic(customTopic); const token = accessToken || getDefaultToken(); validateNtfyUrl(url, "url"); const baseUrl = url.endsWith("/") ? url.slice(0, -1) : url; const endpoint = `${baseUrl}/${topic}`; const headers = { Title: title, }; if (token) { headers.Authorization = `Bearer ${token}`; } if (priority) { headers.Priority = priority; } const viewActions = actions || processActions(message); const shouldUseMarkdown = markdown !== undefined ? markdown : detectMarkdown(message); if (shouldUseMarkdown) { headers["X-Markdown"] = "true"; } if (tags && tags.length > 0) { headers.Tags = tags.join(","); } if (viewActions.length > 0) { headers["X-Actions"] = JSON.stringify(viewActions); } const cleanEndpoint = endpoint.trim(); logger.info(`Sending notification to ${cleanEndpoint}` + `${shouldUseMarkdown ? " with Markdown formatting" : ""}` + `${viewActions.length > 0 ? ` and ${viewActions.length} view action(s)` : ""}`); const response = await fetch(cleanEndpoint, { method: "POST", body: message, headers, }); if (!response.ok) { if (response.status === 401 || response.status === 403) { throw new Error("Authentication failed when sending notification. " + "This ntfy topic requires an access token. Please provide a token using the 'accessToken' parameter " + "or set the NTFY_TOKEN environment variable."); } throw new Error(`Failed to send ntfy notification. Status code: ${response.status}`); } return { content: [ { type: "text", text: `Notification sent successfully to ${cleanEndpoint}!`, }, ], structuredContent: { success: true, endpoint: cleanEndpoint, }, }; } catch (error) { const message = sanitizeErrorMessage(error, "Failed to send ntfy notification"); return { content: [ { type: "text", text: message, }, ], structuredContent: { success: false, error: message, }, isError: true, }; } } async function handleFetchTool({ url: customUrl, topic: customTopic, accessToken, since, messageId, messageText, messageTitle, priorities, tags, }) { try { const url = customUrl || getDefaultUrl(); const topic = resolveTopic(customTopic); const token = accessToken || getDefaultToken(); const sinceSetting = since === null ? undefined : since || "10m"; validateNtfyUrl(url, "url"); const messageRecords = await fetchMessages({ url, topic, token, since: sinceSetting, messageId, messageText, messageTitle, priorities, tags, }); if (!messageRecords) { return { content: [ { type: "text", text: `No messages found in topic ${topic}`, }, ], structuredContent: { success: true, topic, messages: [], }, }; } const messagesCount = Object.values(messageRecords).reduce((sum, messages) => sum + messages.length, 0); const formattedMessages = Object.entries(messageRecords).map(([recordTopic, messages]) => ({ type: "text", text: `Topic: ${recordTopic}\nMessages: ${messages.length}\n${JSON.stringify(messages, null, 2)}`, })); return { content: [ { type: "text", text: `Successfully fetched ${messagesCount} message(s) from ${Object.keys(messageRecords).length} topic(s)`, }, ...formattedMessages, ], structuredContent: { success: true, messageCount: messagesCount, topics: messageRecords, }, }; } catch (error) { const message = sanitizeErrorMessage(error, "Failed to fetch ntfy messages"); return { content: [ { type: "text", text: message, }, ], structuredContent: { success: false, error: message, }, isError: true, }; } } return { resolveTopic, handleNotifyTool, handleFetchTool, }; }