Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 1 addition & 2 deletions OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,14 @@ 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

---

## 🛠️ Extensibility & Future Enhancements

### **Potential Improvements**
- **Linux support** (pulseaudio/aplay)
- **Volume control** configuration
- **Multiple notification types**
- **Integration with system notifications**
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</div>

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

219 changes: 208 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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());
}
Expand All @@ -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<void> {
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
*/
Expand Down Expand Up @@ -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"]
}
}
}
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/templates/backup.ts
Original file line number Diff line number Diff line change
@@ -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
};
8 changes: 8 additions & 0 deletions src/templates/build.ts
Original file line number Diff line number Diff line change
@@ -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
};
8 changes: 8 additions & 0 deletions src/templates/deploy.ts
Original file line number Diff line number Diff line change
@@ -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
};
8 changes: 8 additions & 0 deletions src/templates/error.ts
Original file line number Diff line number Diff line change
@@ -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
};
Loading