diff --git a/CHANGELOG.md b/CHANGELOG.md index b57f25e..51abea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2025-05-31 ### Added ✨ +- **Visual Notifications**: Added cross-platform desktop notification support 📱 + - Linux: `notify-send` (works with notify-osd, dunst, mako, etc.) + - macOS: `osascript` display notification + - Windows: PowerShell MessageBox +- **Enhanced Tool Parameters**: Added comprehensive styling options to `play_notification` + - `urgency`: low, normal, critical notification levels + - `timeout`: Custom notification duration in milliseconds + - `icon`: Custom notification icons (dialog-information, dialog-error, etc.) + - `audio_only`: Visual-only mode for headless environments +- **Qubes OS Support**: Added detection and multiple fallback mechanisms for Qubes OS environments 🔒 + - Automatic Qubes detection via `/proc/xen` and `qrexec-client-vm` + - Multiple notification fallback methods (qrexec, notify-send, log file, console) + - Graceful audio handling with multiple audio player attempts +- **Linux Support**: Added mpg123 support for Linux platforms alongside existing Windows and macOS support 🐧 - **Random Sound Option**: Set `MCP_NOTIFICATION_SOUND=random` to randomly play one of the 5 bundled sounds 🎲 - **Bundled Sound Assets**: MP3 files now included in npm package for out-of-the-box functionality - **NPX Compatibility**: Full support for `npx @pinkpixel/notification-mcp` with bundled sounds @@ -44,6 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Notification MCP Server - Basic `play_notification` tool implementation - Support for custom MP3 file paths via `MCP_NOTIFICATION_SOUND_PATH` -- Cross-platform audio playback (Windows and macOS) +- Cross-platform audio playback (Windows, macOS, and Linux) - TypeScript implementation with MCP SDK - Basic documentation and setup instructions \ No newline at end of file diff --git a/OVERVIEW.md b/OVERVIEW.md index b6e26c1..19e919a 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -255,7 +255,7 @@ await client.request({ - **MCP Inspector:** Interactive debugging interface - **Stdio Transport:** Standard protocol debugging - **Error Logging:** Comprehensive error reporting -- **Platform Detection:** Cross-platform compatibility checks +- **Platform Detection:** Cross-platform compatibility checks (Windows, macOS, Linux) - **Asset Resolution:** Debug bundled file paths --- @@ -263,7 +263,6 @@ await client.request({ ## 🛠️ Extensibility & Future Enhancements ### **Potential Improvements** -- **Linux support** (pulseaudio/aplay) - **Volume control** configuration - **Multiple notification types** - **Integration with system notifications** diff --git a/README.md b/README.md index 87e4359..0f826c1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![NPM Downloads](https://img.shields.io/npm/dm/@pinkpixel/notification-mcp?style=flat-square&color=ff69b4)](https://www.npmjs.com/package/@pinkpixel/notification-mcp) [![GitHub Stars](https://img.shields.io/github/stars/pinkpixel-dev/notification-mcp?style=flat-square&color=ff69b4)](https://github.com/pinkpixel-dev/notification-mcp/stargazers) [![GitHub Issues](https://img.shields.io/github/issues/pinkpixel-dev/notification-mcp?style=flat-square&color=ff69b4)](https://github.com/pinkpixel-dev/notification-mcp/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg?style=flat-square)](http://makeapullrequest.com) - [![Platform Support](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey?style=flat-square)](https://github.com/pinkpixel-dev/notification-mcp) [![Built with Love](https://img.shields.io/badge/Built%20with-❤️-ff1744.svg?style=flat-square)](https://pinkpixel.dev) [![Pink Pixel](https://img.shields.io/badge/Pink-Pixel-ff69b4?style=flat-square&logo=sparkles)](https://pinkpixel.dev) + [![Platform Support](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/pinkpixel-dev/notification-mcp) [![Built with Love](https://img.shields.io/badge/Built%20with-❤️-ff1744.svg?style=flat-square)](https://pinkpixel.dev) [![Pink Pixel](https://img.shields.io/badge/Pink-Pixel-ff69b4?style=flat-square&logo=sparkles)](https://pinkpixel.dev) @@ -22,9 +22,17 @@ A Model Context Protocol server that allows AI agents to play notification sound ## ✨ Features ### 🔧 Tools -- `play_notification` - Play a notification sound to indicate task completion - - Takes an optional `message` parameter to display with the notification - - Supports cross-platform sound playback (Windows and macOS) +- `play_notification` - Play a notification sound and optionally show visual notifications + - `message` (optional): Message to display with the notification + - `show_visual` (optional): Whether to show a desktop notification (default: false) + - `title` (optional): Title for the visual notification (default: "Task Complete") + - `audio_only` (optional): Skip audio and only show visual notification (default: false) + - `urgency` (optional): Notification urgency level - low, normal, critical (default: normal) + - `timeout` (optional): Notification timeout in milliseconds (default: 5000) + - `icon` (optional): Icon name or path for the notification (default: dialog-information) + - Supports cross-platform audio playback (Windows, macOS, and Linux) + - Supports cross-platform visual notifications with styling (notify-send, osascript, PowerShell) + - **Enhanced Qubes OS support** with fallback mechanisms - **Works with bundled sounds** - no manual downloads required! ### 🎵 Built-in Sound Library diff --git a/package-lock.json b/package-lock.json index 3d1db10..ddf5a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@pinkpixel/notification-mcp", "version": "0.1.2", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.12.1" }, diff --git a/src/index.ts b/src/index.ts index f57f082..e4b1712 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { NOTIFICATION_TEMPLATES, type NotificationTemplate } from './templates/index.js'; const execAsync = promisify(exec); @@ -38,11 +39,16 @@ function getRandomSound(): string { } // Determine which sound file to use -function getSoundFile(): string { +function getSoundFile(templateSound?: string): string { if (USER_CONFIGURED_SOUND_PATH) { return USER_CONFIGURED_SOUND_PATH; } + // Use template-specific sound if provided + if (templateSound && SOUND_FILES[templateSound as keyof typeof SOUND_FILES]) { + return path.join(__dirname, '..', SOUND_FILES[templateSound as keyof typeof SOUND_FILES]); + } + if (DEFAULT_SOUND_NAME === 'random') { return path.join(__dirname, '..', getRandomSound()); } @@ -51,6 +57,75 @@ function getSoundFile(): string { return path.join(__dirname, '..', DEFAULT_SOUND_FILE); } +// Templates are now imported from ./templates/index.js + +// Function to detect if running in Qubes OS +function isQubesOS(): boolean { + try { + // Check for Qubes-specific indicators + const fs = require('fs'); + return fs.existsSync('/proc/xen') && + fs.existsSync('/usr/bin/qrexec-client-vm') && + fs.existsSync('/etc/qubes-rpc/'); + } catch { + return false; + } +} + +// Function to show visual notification +async function showVisualNotification(title: string, message: string, urgency: string = "normal", timeout: number = 5000, icon: string = "dialog-information"): Promise { + let command: string; + + switch (process.platform) { + case 'linux': + if (isQubesOS()) { + // Qubes OS: Try multiple notification methods with styling + const methods = [ + // Method 1: Try qrexec notification (may be restricted) + () => execAsync(`echo "${title}|${message}" | qrexec-client-vm dom0 qubes.NotifyAll`), + // Method 2: Fallback to local notify-send with full styling + () => execAsync(`notify-send -u ${urgency} -t ${timeout} -i ${icon} "${title}" "${message}"`), + // Method 3: Try notify-send with basic styling (fallback if icon fails) + () => execAsync(`notify-send -u ${urgency} -t ${timeout} "${title}" "${message}"`), + // Method 4: Basic notify-send as fallback + () => execAsync(`notify-send "${title}" "${message}"`), + // Method 5: Write to a styled log file that could be monitored + () => execAsync(`echo "$(date) [${urgency.toUpperCase()}] ${title}: ${message}" >> /tmp/mcp-notifications.log`), + // Method 6: Console output as last resort + () => Promise.resolve(console.log(`NOTIFICATION [${urgency.toUpperCase()}]: [${title}] ${message}`)) + ]; + + for (const method of methods) { + try { + await method(); + return; // Success, exit early + } catch (error) { + // Continue to next method + continue; + } + } + // If all methods fail, throw the last error + throw new Error(`All Qubes notification methods failed for: [${title}] ${message}`); + } else { + // Regular Linux: Use notify-send with full styling + command = `notify-send -u ${urgency} -t ${timeout} -i ${icon} "${title}" "${message}"`; + } + break; + case 'darwin': + // Use osascript for macOS + command = `osascript -e 'display notification "${message}" with title "${title}"'`; + break; + case 'win32': + // Use PowerShell for Windows toast notifications + command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${message}', '${title}')"`; + break; + default: + throw new Error(`Visual notifications not supported on platform: ${process.platform}`); + } + + await execAsync(command); +} + /** * Create an MCP server with tool capability for playing notifications */ @@ -82,6 +157,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { message: { type: "string", description: "Optional message to display with notification" + }, + show_visual: { + type: "boolean", + description: "Whether to show a visual desktop notification (default: false)" + }, + title: { + type: "string", + description: "Title for the visual notification (default: 'Task Complete')" + }, + audio_only: { + type: "boolean", + description: "Skip audio and only show visual notification (default: false)" + }, + urgency: { + type: "string", + description: "Notification urgency level: low, normal, critical (default: normal)", + enum: ["low", "normal", "critical"] + }, + timeout: { + type: "number", + description: "Notification timeout in milliseconds (default: 5000)" + }, + icon: { + type: "string", + description: "Icon name or path for the notification (default: dialog-information)" + }, + template: { + type: "string", + description: "Use a predefined notification template: success, error, warning, info, build, deploy, test, backup", + enum: ["success", "error", "warning", "info", "build", "deploy", "test", "backup"] } } } @@ -100,22 +205,114 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } try { - // Get the sound file to play (potentially random) - const SOUND_FILE_TO_PLAY = getSoundFile(); + const args = request.params.arguments as any || {}; + const template = args.template; + + // Apply template if specified + let templateDefaults: NotificationTemplate | {} = {}; + if (template && NOTIFICATION_TEMPLATES[template as keyof typeof NOTIFICATION_TEMPLATES]) { + templateDefaults = NOTIFICATION_TEMPLATES[template as keyof typeof NOTIFICATION_TEMPLATES]; + } + + // Extract parameters with template defaults + const message: string = args.message || (templateDefaults as any).message || ""; + const showVisual: boolean = args.show_visual || false; + const title: string = args.title || (templateDefaults as any).title || "Task Complete"; + const audioOnly: boolean = args.audio_only || false; + const urgency: string = args.urgency || (templateDefaults as any).urgency || "normal"; + const timeout: number = args.timeout || (templateDefaults as any).timeout || 5000; + const icon: string = args.icon || (templateDefaults as any).icon || "dialog-information"; + const templateSound: string | undefined = (templateDefaults as any).sound; - // Play sound using platform-specific command - const command = process.platform === 'win32' - ? `start "" "${SOUND_FILE_TO_PLAY}"` - : `afplay "${SOUND_FILE_TO_PLAY}"`; + // Execute audio notification (unless audio_only is true) + if (!audioOnly) { + try { + const SOUND_FILE_TO_PLAY = getSoundFile(templateSound); + + switch (process.platform) { + case 'win32': + await execAsync(`start "" "${SOUND_FILE_TO_PLAY}"`); + break; + case 'darwin': + await execAsync(`afplay "${SOUND_FILE_TO_PLAY}"`); + break; + case 'linux': + if (isQubesOS()) { + // Qubes OS: Try multiple audio methods with proper error handling + const audioMethods = [ + // Method 1: Try paplay (PulseAudio) which often works better in Qubes + () => execAsync(`paplay "${SOUND_FILE_TO_PLAY}"`), + // Method 2: Try mpg123 + () => execAsync(`mpg123 -q "${SOUND_FILE_TO_PLAY}"`), + // Method 3: Try ffplay + () => execAsync(`ffplay -nodisp -autoexit "${SOUND_FILE_TO_PLAY}" 2>/dev/null`), + // Method 4: Try aplay (may not work with MP3 but worth trying) + () => execAsync(`aplay "${SOUND_FILE_TO_PLAY}" 2>/dev/null`) + ]; + + let audioSuccess = false; + for (const method of audioMethods) { + try { + await method(); + audioSuccess = true; + break; // Success, exit early + } catch (error) { + // Continue to next method + continue; + } + } + + if (!audioSuccess) { + console.log(`Audio playback failed in Qubes VM, but continuing...`); + // Don't throw error for audio failure in Qubes - it's often expected + } + } else { + // Regular Linux + await execAsync(`mpg123 -q "${SOUND_FILE_TO_PLAY}"`); + } + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } catch (error) { + // Log audio failure but don't fail the entire operation in Qubes + if (isQubesOS()) { + console.log(`Audio notification failed in Qubes: ${error instanceof Error ? error.message : String(error)}`); + } else { + throw error; // Re-throw for non-Qubes systems + } + } + } + + // Execute visual notification if requested + if (showVisual) { + const notificationMessage = message || "Task completed successfully!"; + try { + await showVisualNotification(title, notificationMessage, urgency, timeout, icon); + } catch (error) { + // Log visual notification failure but don't fail the entire operation + console.log(`Visual notification failed: ${error instanceof Error ? error.message : String(error)}`); + } + } - await execAsync(command); + // Prepare response message + let responseText = audioOnly ? "Visual notification shown successfully" : "Notification played successfully"; + if (message) { + responseText = audioOnly ? `Visual notification: ${message}` : `Notification played: ${message}`; + } + if (showVisual && !audioOnly) { + responseText += ` (with ${urgency} visual notification)`; + } + + // Add Qubes-specific messaging + if (isQubesOS()) { + responseText += " (Qubes OS detected - using fallback methods)"; + } return { content: [{ type: "text", - text: request.params.arguments?.message - ? `Notification played: ${request.params.arguments.message}` - : "Notification played successfully" + text: responseText }] }; } catch (error) { diff --git a/src/templates/backup.ts b/src/templates/backup.ts new file mode 100644 index 0000000..06df38e --- /dev/null +++ b/src/templates/backup.ts @@ -0,0 +1,8 @@ +export const backupTemplate = { + title: "Backup", + message: "Backup completed!", + urgency: "low", + timeout: 4000, + icon: "drive-harddisk", + sound: "pleasant" // Maps to pleasant_chime.mp3 - reassuring for backups +}; \ No newline at end of file diff --git a/src/templates/build.ts b/src/templates/build.ts new file mode 100644 index 0000000..eb09177 --- /dev/null +++ b/src/templates/build.ts @@ -0,0 +1,8 @@ +export const buildTemplate = { + title: "Build Status", + message: "Build completed successfully!", + urgency: "normal", + timeout: 6000, + icon: "emblem-default", + sound: "fairy" // Maps to fairy_chime.mp3 - magical completion sound +}; \ No newline at end of file diff --git a/src/templates/deploy.ts b/src/templates/deploy.ts new file mode 100644 index 0000000..bf82f7d --- /dev/null +++ b/src/templates/deploy.ts @@ -0,0 +1,8 @@ +export const deployTemplate = { + title: "Deployment", + message: "Deployment finished!", + urgency: "normal", + timeout: 6000, + icon: "network-workgroup", + sound: "cosmic" // Maps to cosmic_chime.mp3 - space-themed for deployment +}; \ No newline at end of file diff --git a/src/templates/error.ts b/src/templates/error.ts new file mode 100644 index 0000000..9e70073 --- /dev/null +++ b/src/templates/error.ts @@ -0,0 +1,8 @@ +export const errorTemplate = { + title: "Error", + message: "An error occurred!", + urgency: "critical", + timeout: 10000, + icon: "dialog-error", + sound: "retro" // Maps to retro_chime.mp3 - distinctive for errors +}; \ No newline at end of file diff --git a/src/templates/index.ts b/src/templates/index.ts new file mode 100644 index 0000000..c10d53d --- /dev/null +++ b/src/templates/index.ts @@ -0,0 +1,30 @@ +import { successTemplate } from './success.js'; +import { errorTemplate } from './error.js'; +import { warningTemplate } from './warning.js'; +import { infoTemplate } from './info.js'; +import { buildTemplate } from './build.js'; +import { deployTemplate } from './deploy.js'; +import { testTemplate } from './test.js'; +import { backupTemplate } from './backup.js'; + +export const NOTIFICATION_TEMPLATES = { + success: successTemplate, + error: errorTemplate, + warning: warningTemplate, + info: infoTemplate, + build: buildTemplate, + deploy: deployTemplate, + test: testTemplate, + backup: backupTemplate +}; + +export type TemplateType = keyof typeof NOTIFICATION_TEMPLATES; + +export interface NotificationTemplate { + title: string; + message: string; + urgency: string; + timeout: number; + icon: string; + sound: string; +} \ No newline at end of file diff --git a/src/templates/info.ts b/src/templates/info.ts new file mode 100644 index 0000000..a37a92a --- /dev/null +++ b/src/templates/info.ts @@ -0,0 +1,8 @@ +export const infoTemplate = { + title: "Information", + message: "Information update", + urgency: "low", + timeout: 5000, + icon: "dialog-information", + sound: "gentle" // Maps to gentle_chime.mp3 - soft and unobtrusive +}; \ No newline at end of file diff --git a/src/templates/success.ts b/src/templates/success.ts new file mode 100644 index 0000000..c6b7391 --- /dev/null +++ b/src/templates/success.ts @@ -0,0 +1,8 @@ +export const successTemplate = { + title: "Success", + message: "Task completed successfully!", + urgency: "normal", + timeout: 5000, + icon: "dialog-information", + sound: "pleasant" // Maps to pleasant_chime.mp3 +}; \ No newline at end of file diff --git a/src/templates/test.ts b/src/templates/test.ts new file mode 100644 index 0000000..302109b --- /dev/null +++ b/src/templates/test.ts @@ -0,0 +1,8 @@ +export const testTemplate = { + title: "Test Results", + message: "Tests completed!", + urgency: "low", + timeout: 4000, + icon: "applications-science", + sound: "gentle" // Maps to gentle_chime.mp3 - quiet for test completion +}; \ No newline at end of file diff --git a/src/templates/warning.ts b/src/templates/warning.ts new file mode 100644 index 0000000..e8e3def --- /dev/null +++ b/src/templates/warning.ts @@ -0,0 +1,8 @@ +export const warningTemplate = { + title: "Warning", + message: "Warning: Action required", + urgency: "normal", + timeout: 8000, + icon: "dialog-warning", + sound: "cosmic" // Maps to cosmic_chime.mp3 - attention-grabbing +}; \ No newline at end of file