# Directory Structure ``` ├── .gitignore ├── .npmignore ├── biome.json ├── CLAUDE.md ├── examples │ ├── terminal-detection.js │ └── tmux-client-tracking.sh ├── macos-notify-mcp-icon.svg ├── MacOSNotifyMCP │ ├── build-app.sh │ ├── MacOSNotifyMCP.app │ │ └── Contents │ │ ├── _CodeSignature │ │ │ └── CodeResources │ │ ├── Info.plist │ │ ├── MacOS │ │ │ └── MacOSNotifyMCP │ │ └── Resources │ │ └── MacOSNotifyMCP.icns │ ├── MacOSNotifyMCP.icns │ ├── main.swift │ └── notify-swift ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── cli-wrapper.cjs │ ├── cli.ts │ ├── index.ts │ ├── mcp-wrapper.cjs │ └── notifier.ts ├── test │ ├── cli.test.ts │ ├── index.test.ts │ └── notifier.test.ts ├── tsconfig.json └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Build output dist/ # IDE .vscode/ .idea/ # OS .DS_Store # Logs *.log npm-debug.log* # Environment .env .env.local # TypeScript *.tsbuildinfo # Temporary files *.tmp tmp/ temp/ ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` # Source files src/ tsconfig.json # Development files .gitignore .git/ # Swift source files (but keep the built app) NotifyTmux/main.swift NotifyTmux/build-app.sh NotifyTmux/notify-swift # Old Deno files bin/ # IDE .vscode/ .idea/ # OS .DS_Store # Temporary *.tmp tmp/ temp/ # Keep NotifyTmux.app !NotifyTmux/NotifyTmux.app/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # macOS Notify MCP A Model Context Protocol (MCP) server for macOS notifications with tmux integration. This tool allows AI assistants like Claude to send native macOS notifications that can focus specific tmux sessions when clicked. ## Features - 🔔 Native macOS notifications using UserNotifications API - 🖱️ Clickable notifications that focus tmux sessions - 🎯 Direct navigation to specific tmux session, window, and pane - 🔊 Customizable notification sounds - 🚀 Support for multiple concurrent notifications - 🤖 MCP server for AI assistant integration - 🖥️ Terminal emulator detection (VSCode, Cursor, iTerm2, Terminal.app) ## Installation ### Prerequisites - macOS (required for notifications) - Node.js >= 18.0.0 - tmux (optional, for tmux integration) ### Install from npm ```bash npm install -g macos-notify-mcp ``` ### Build from source ```bash git clone https://github.com/yuki-yano/macos-notify-mcp.git cd macos-notify-mcp npm install npm run build npm run build-app # Build the macOS app bundle (only needed for development) ``` ## Usage ### As MCP Server First, install the package globally: ```bash npm install -g macos-notify-mcp ``` #### Quick Setup with Claude Code Use the `claude mcp add` command: ```bash claude mcp add macos-notify -s user -- macos-notify-mcp ``` Then restart Claude Code. #### Manual Setup for Claude Desktop Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "macos-notify": { "command": "macos-notify-mcp" } } } ``` ### Available MCP Tools - `send_notification` - Send a macOS notification - `message` (required): Notification message - `title`: Notification title (default: "Claude Code") - `sound`: Notification sound (default: "Glass") - `session`: tmux session name - `window`: tmux window number - `pane`: tmux pane number - `useCurrent`: Use current tmux location - `list_tmux_sessions` - List available tmux sessions - `get_current_tmux_info` - Get current tmux session information ### As CLI Tool ```bash # Basic notification macos-notify-cli -m "Build completed" # With title macos-notify-cli -t "Development" -m "Tests passed" # With tmux integration macos-notify-cli -m "Task finished" -s my-session -w 1 -p 0 # Use current tmux location macos-notify-cli -m "Check this pane" --current-tmux # Detect current terminal emulator macos-notify-cli --detect-terminal # List tmux sessions macos-notify-cli --list-sessions ``` ### Terminal Detection The tool automatically detects which terminal emulator you're using and uses this information when you click on notifications to focus the correct application. You can test terminal detection with: ```bash # Test terminal detection macos-notify-cli --detect-terminal ``` #### Supported Terminal Detection The tool detects terminals using various methods: 1. **Cursor**: Via `CURSOR_TRACE_ID` environment variable 2. **VSCode**: Via `VSCODE_IPC_HOOK_CLI` or `VSCODE_REMOTE` environment variables 3. **alacritty**: Via `ALACRITTY_WINDOW_ID` or `ALACRITTY_SOCKET` environment variables 4. **iTerm2**: Via `TERM_PROGRAM=iTerm.app` 5. **Terminal.app**: Via `TERM_PROGRAM=Apple_Terminal` #### Terminal Detection in tmux When running inside tmux, the tool attempts to detect which terminal emulator the active tmux client is using: 1. **Active Client Detection**: Identifies the most recently active tmux client 2. **TTY Process Analysis**: Traces processes using the client's TTY 3. **Environment Preservation**: Checks preserved environment variables 4. **Process Tree Fallback**: Analyzes the process tree as a last resort For advanced tmux client tracking, see `examples/tmux-client-tracking.sh`. ## How it Works 1. **Notification Delivery**: Uses a native macOS app bundle (MacOSNotifyMCP.app) to send UserNotifications API notifications 2. **Click Handling**: When a notification is clicked, the app activates the detected terminal emulator (VSCode, Cursor, iTerm2, alacritty, or Terminal.app) and switches to the specified tmux session 3. **Terminal Support**: Automatically detects and activates the correct terminal application 4. **Multiple Instances**: Each notification runs as a separate process, allowing multiple concurrent notifications ## Architecture The project consists of two main components: 1. **MCP Server/CLI** (TypeScript/Node.js) - Implements the Model Context Protocol - Provides a command-line interface - Manages tmux session detection and validation 2. **MacOSNotifyMCP.app** (Swift/macOS) - Native macOS application for notifications - Handles notification clicks to focus tmux sessions - Runs as a background process for each notification ## MacOSNotifyMCP.app The MacOSNotifyMCP.app is bundled with the npm package and is automatically available after installation. No additional setup is required. ## Troubleshooting ### Notifications not appearing 1. Check System Settings → Notifications → MacOSNotifyMCP 2. Ensure notifications are allowed 3. Run `macos-notify-mcp -m "test"` to verify ### tmux integration not working 1. Ensure tmux is installed and running 2. Check session names with `macos-notify-mcp --list-sessions` 3. Verify terminal app is supported (Alacritty, iTerm2, WezTerm, or Terminal) ## Development ```bash # Install dependencies npm install # Build TypeScript npm run build # Run in development npm run dev # Lint and format code npm run lint npm run format # Build macOS app (only if modifying Swift code) npm run build-app ``` ## License MIT ## Author Yuki Yano ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is a Model Context Protocol (MCP) server for macOS notifications with tmux integration. The project consists of: - A TypeScript-based MCP server and CLI tool - A native macOS Swift application (MacOSNotifyMCP.app) that handles notifications ## Key Commands ### Development ```bash npm install # Install dependencies npm run build # Build TypeScript to dist/ npm run dev # Run MCP server in watch mode npm run build-app # Build the macOS app bundle (MacOSNotifyMCP.app) ``` ### Linting & Formatting ```bash npm run lint # Run biome linter with auto-fix npm run format # Format code with biome npm run check # Check code without modifications ``` ### Testing ```bash npm test # Build and test CLI help output node dist/cli.js -m "test" --current-tmux # Test notification with current tmux session ``` ## Architecture ### Core Components 1. **MCP Server** (`src/index.ts`) - Implements Model Context Protocol server - Provides tools: `send_notification`, `list_tmux_sessions`, `get_current_tmux_info` - Uses StdioServerTransport for communication 2. **Notifier Core** (`src/notifier.ts`) - Main notification logic - Searches for MacOSNotifyMCP.app in multiple locations - Handles tmux session detection and validation - Uses `spawn` for subprocess management (not `exec`) 3. **CLI Interface** (`src/cli.js`) - Command-line tool for direct notification sending - Argument parsing for tmux session/window/pane - Session validation before sending notifications 4. **MacOSNotifyMCP.app** (`MacOSNotifyMCP/main.swift`) - Native macOS app using UserNotifications API - Handles notification clicks to focus tmux sessions - Runs as background process (no Dock icon) - Supports multiple concurrent notifications via `-n` flag ### Key Design Decisions 1. **Single Notification Method**: All notifications go through MacOSNotifyMCP.app (no osascript fallbacks) 2. **App Bundling**: MacOSNotifyMCP.app is included in the npm package, no post-install scripts 3. **App Discovery**: MacOSNotifyMCP.app is searched in order: - Package installation directory (primary) - Current working directory (development) 4. **Process Management**: Each notification spawns a new MacOSNotifyMCP.app instance 5. **Error Handling**: Commands use `spawn` to properly handle arguments with special characters ## Important Notes - The project uses ES modules (`"type": "module"` in package.json) - MacOSNotifyMCP.app is pre-built and included in the npm package - The app uses ad-hoc signing - Biome is configured for linting/formatting (config embedded in package.json) - tmux integration requires tmux to be installed and running - The app path is resolved from the npm package installation directory ## MCP Tools Usage When this project is installed as an MCP server, use these tools for notifications: ### Available MCP Tools 1. **send_notification** - Send macOS notifications - Required: `message` (string) - Optional: `title`, `sound`, `session`, `window`, `pane`, `useCurrent` - Example: "Send a notification with message 'Build completed'" 2. **list_tmux_sessions** - List available tmux sessions - No parameters required - Returns list of active tmux sessions 3. **get_current_tmux_info** - Get current tmux location - No parameters required - Returns current session, window, and pane ### Usage Examples When users ask about notifications or tmux, actively use these tools: - "Notify me when done" → Use `send_notification` with appropriate message - "Send notification to current tmux" → Use `send_notification` with `useCurrent: true` - "What tmux sessions are available?" → Use `list_tmux_sessions` - "Where am I in tmux?" → Use `get_current_tmux_info` ### Interactive Patterns When waiting for user input, always send a notification first: 1. **Before asking for confirmation**: ``` send_notification("Build complete. Waiting for deployment confirmation") // Then ask: "Deploy to production?" ``` 2. **When presenting options**: ``` send_notification("Multiple matches found. Please choose in terminal") // Then show options ``` 3. **On errors requiring user decision**: ``` send_notification("Error encountered. Need your input to proceed") // Then present error and options ``` ### Testing Commands For development testing, use these CLI commands: ```bash # Test notification directly node dist/cli.js -m "Test message" # Test with current tmux session node dist/cli.js -m "Test message" --current-tmux # List tmux sessions node dist/cli.js --list-sessions ``` ``` -------------------------------------------------------------------------------- /src/mcp-wrapper.cjs: -------------------------------------------------------------------------------- ``` #!/usr/bin/env node // CommonJS wrapper for ES module MCP server // This ensures compatibility with npm global installs async function main() { try { await import('./index.js') } catch (error) { console.error('Failed to start MCP server:', error) process.exit(1) } } main() ``` -------------------------------------------------------------------------------- /src/cli-wrapper.cjs: -------------------------------------------------------------------------------- ``` #!/usr/bin/env node // CommonJS wrapper for ES module CLI // This ensures compatibility with npm global installs async function main() { try { const { main } = await import('./cli.js') await main() } catch (error) { console.error('Failed to load CLI:', error) process.exit(1) } } main() ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- ```typescript import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, environment: 'node', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/**', 'dist/**', 'MacOSNotifyMCP/**', 'test/**', '**/*.d.ts', '**/*.config.*', '**/mockData.ts', 'scripts/**', ], }, include: ['test/**/*.test.ts'], testTimeout: 10000, }, }) ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "node", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], "ts-node": { "compilerOptions": { "allowJs": true } } } ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { "recommended": true, "style": { "noParameterAssign": "error", "useAsConstAssertion": "error", "useDefaultParameterLast": "error", "useEnumInitializers": "error", "useSelfClosingElements": "error", "useSingleVarDeclarator": "error", "noUnusedTemplateLiteral": "error", "useNumberNamespace": "error", "noInferrableTypes": "error", "noUselessElse": "error" } } }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, "lineWidth": 80, "lineEnding": "lf" }, "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded", "trailingCommas": "all" } }, "files": { "includes": [ "**/src/**/*.ts", "**/bin/**/*", "!**/node_modules", "!**/dist", "!**/build", "!**/MacOSNotifyMCP" ] } } ``` -------------------------------------------------------------------------------- /examples/terminal-detection.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import { TmuxNotifier } from '../dist/notifier.js' async function demonstrateTerminalDetection() { const notifier = new TmuxNotifier() console.log('Terminal Detection Example') console.log('==========================\n') // Show environment variables console.log('Environment variables:') console.log(` VSCODE_IPC_HOOK_CLI: ${process.env.VSCODE_IPC_HOOK_CLI ? 'Set' : 'Not set'}`) console.log(` TERM_PROGRAM: ${process.env.TERM_PROGRAM || 'Not set'}`) console.log(` TMUX: ${process.env.TMUX ? 'Set' : 'Not set'}\n`) // Detect terminal const terminal = await notifier.getTerminalEmulator() console.log(`Detected Terminal: ${terminal}\n`) // Send notification without terminal info console.log('Sending notification without terminal info...') await notifier.sendNotification({ message: 'Standard notification', title: 'Test' }) // Send notification with terminal info console.log('Sending notification with terminal info...') await notifier.sendNotification({ message: 'Notification with terminal detection', title: 'Test', includeTerminalInfo: true }) console.log('\nDone! Check your notifications.') } demonstrateTerminalDetection().catch(console.error) ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "macos-notify-mcp", "version": "0.0.8", "description": "MCP server for macOS notifications with tmux integration", "keywords": [ "mcp", "macos", "notification", "tmux", "claude" ], "author": "Yuki Yano", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/yuki-yano/macos-notify-mcp.git" }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { "macos-notify-cli": "dist/cli-wrapper.cjs", "macos-notify-mcp": "dist/mcp-wrapper.cjs" }, "files": [ "dist", "MacOSNotifyMCP" ], "scripts": { "build": "tsc", "postbuild": "cp src/cli-wrapper.cjs dist/cli-wrapper.cjs && chmod +x dist/cli-wrapper.cjs && cp src/mcp-wrapper.cjs dist/mcp-wrapper.cjs && chmod +x dist/mcp-wrapper.cjs", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "test:watch": "vitest --watch", "cli:test": "npm run build && node dist/cli.js --help", "prepare": "npm run build", "build-app": "cd MacOSNotifyMCP && ./build-app.sh", "lint": "biome check --write", "format": "biome format --write", "check": "biome check" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4" }, "devDependencies": { "@biomejs/biome": "^2.0.0", "@types/node": "^22.15.32", "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^3.2.4" }, "engines": { "node": ">=18.0.0" } } ``` -------------------------------------------------------------------------------- /MacOSNotifyMCP/build-app.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Create complete macOS app bundle APP_NAME="MacOSNotifyMCP" APP_DIR="${APP_NAME}.app" CONTENTS_DIR="${APP_DIR}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" # Create directory structure mkdir -p "${MACOS_DIR}" mkdir -p "${RESOURCES_DIR}" # Create Info.plist cat > "${CONTENTS_DIR}/Info.plist" << EOF <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleIdentifier</key> <string>com.macos-notify-mcp.app</string> <key>CFBundleName</key> <string>MacOSNotifyMCP</string> <key>CFBundleDisplayName</key> <string>MacOSNotifyMCP</string> <key>CFBundleVersion</key> <string>1.0</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleExecutable</key> <string>MacOSNotifyMCP</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>LSMinimumSystemVersion</key> <string>10.15</string> <key>NSUserNotificationAlertStyle</key> <string>alert</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>NSSupportsAutomaticTermination</key> <true/> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict> <key>CFBundleIconFile</key> <string>MacOSNotifyMCP</string> <key>LSUIElement</key> <true/> </dict> </plist> EOF # Copy icon file to Resources directory if [ -f "${APP_NAME}.icns" ]; then cp "${APP_NAME}.icns" "${RESOURCES_DIR}/" echo "Icon file copied: ${APP_NAME}.icns" else echo "Warning: Icon file ${APP_NAME}.icns not found" fi # Compile Swift binary and place it in the app swiftc -o "${MACOS_DIR}/${APP_NAME}" main.swift # Add signature to app (ad-hoc signing) echo "Signing app..." codesign --force --deep --sign - "${APP_DIR}" echo "App bundle created: ${APP_DIR}" echo "Usage: open ${APP_DIR} --args -m \"Test message\"" echo "Bundle ID: com.macos-notify-mcp.app" ``` -------------------------------------------------------------------------------- /macos-notify-mcp-icon.svg: -------------------------------------------------------------------------------- ``` <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <!-- Background - macOS style rounded square --> <defs> <!-- Gradient for background --> <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <stop offset="0%" style="stop-color:#007AFF;stop-opacity:1" /> <stop offset="100%" style="stop-color:#0051D5;stop-opacity:1" /> </linearGradient> <!-- Shadow filter --> <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> <feGaussianBlur in="SourceAlpha" stdDeviation="20"/> <feOffset dx="0" dy="10" result="offsetblur"/> <feFlood flood-color="#000000" flood-opacity="0.25"/> <feComposite in2="offsetblur" operator="in"/> <feMerge> <feMergeNode/> <feMergeNode in="SourceGraphic"/> </feMerge> </filter> <!-- Glow effect --> <filter id="glow"> <feGaussianBlur stdDeviation="4" result="coloredBlur"/> <feMerge> <feMergeNode in="coloredBlur"/> <feMergeNode in="SourceGraphic"/> </feMerge> </filter> </defs> <!-- App icon background --> <rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="url(#bgGradient)" filter="url(#shadow)"/> <!-- Main bell shape --> <g transform="translate(512, 440)"> <!-- Bell body --> <path d="M-180,-140 C-180,-260 -100,-340 0,-340 C100,-340 180,-260 180,-140 L180,40 C180,80 160,100 160,100 L-160,100 C-160,100 -180,80 -180,40 Z" fill="#FFFFFF" opacity="0.95"/> <!-- Bell clapper --> <circle cx="0" cy="140" r="40" fill="#FFFFFF" opacity="0.95"/> <!-- Notification indicator --> <circle cx="140" cy="-120" r="60" fill="#FF3B30" filter="url(#glow)"/> </g> <!-- Terminal/Code brackets to represent tmux --> <g transform="translate(512, 720)"> <!-- Left bracket --> <path d="M-240,-80 L-200,-80 L-200,-60 L-220,-60 L-220,60 L-200,60 L-200,80 L-240,80 L-240,-80 Z" fill="#FFFFFF" opacity="0.7"/> <!-- Right bracket --> <path d="M240,-80 L200,-80 L200,-60 L220,-60 L220,60 L200,60 L200,80 L240,80 L240,-80 Z" fill="#FFFFFF" opacity="0.7"/> <!-- Underscore cursor --> <rect x="-40" y="40" width="80" height="20" rx="10" fill="#00FF00" opacity="0.9"/> </g> <!-- AI circuit dots --> <g transform="translate(512, 512)"> <!-- Connection lines --> <circle cx="-320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/> <circle cx="320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/> <circle cx="0" cy="-320" r="20" fill="#00D4FF" opacity="0.6"/> <circle cx="0" cy="320" r="20" fill="#00D4FF" opacity="0.6"/> <!-- Diagonal dots --> <circle cx="-220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/> <circle cx="220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/> <circle cx="-220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/> <circle cx="220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/> </g> </svg> ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { TmuxNotifier } from './notifier.js' interface CliOptions { message: string title?: string sound?: string session?: string window?: string pane?: string } export async function main() { const notifier = new TmuxNotifier() // Parse command line arguments const args = process.argv.slice(2) if (args.includes('--help') || args.includes('-h')) { console.log(` Usage: macos-notify-cli [options] Options: -m, --message <text> Notification message (required) -t, --title <text> Notification title (default: git repository name) -s, --session <name> tmux session name -w, --window <number> tmux window number -p, --pane <number> tmux pane number --sound <name> Notification sound (default: "Glass") --current-tmux Use current tmux location --list-sessions List available tmux sessions --detect-terminal Detect and display the current terminal emulator -h, --help Show this help message Examples: # Basic notification macos-notify-cli -m "Build completed" # Navigate to specific session macos-notify-cli -m "Tests passed" -s development -w 1 -p 0 # Use current tmux location macos-notify-cli -m "Task finished" --current-tmux `) process.exit(0) } if (args.includes('--list-sessions')) { const sessions = await notifier.listSessions() console.log('Available tmux sessions:') sessions.forEach((s) => console.log(` ${s}`)) process.exit(0) } if (args.includes('--detect-terminal')) { const terminal = await notifier.getTerminalEmulator() console.log(`Detected terminal: ${terminal}`) process.exit(0) } // Parse arguments const options: CliOptions = { message: '', } for (let i = 0; i < args.length; i++) { switch (args[i]) { case '-m': case '--message': options.message = args[++i] break case '-t': case '--title': options.title = args[++i] break case '-s': case '--session': options.session = args[++i] break case '-w': case '--window': options.window = args[++i] break case '-p': case '--pane': options.pane = args[++i] break case '--sound': options.sound = args[++i] break case '--current-tmux': { const current = await notifier.getCurrentTmuxInfo() if (current) { options.session = current.session options.window = current.window options.pane = current.pane } else { console.error('Error: Not in a tmux session') process.exit(1) } break } } } if (!options.message) { console.error('Error: Message is required (-m option)') process.exit(1) } // Check if session exists if (options.session) { const exists = await notifier.sessionExists(options.session) if (!exists) { console.error(`Error: Session '${options.session}' does not exist`) const sessions = await notifier.listSessions() if (sessions.length > 0) { console.log('\nAvailable sessions:') sessions.forEach((s) => console.log(` ${s}`)) } process.exit(1) } } // Send notification try { await notifier.sendNotification(options) console.log('Notification sent successfully') process.exit(0) } catch (error) { console.error('Failed to send notification:', error) process.exit(1) } } // Export for testing (already exported as function declaration) // Only run if called directly (not when imported as a module) if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { console.error('Unexpected error:', error) process.exit(1) }) } ``` -------------------------------------------------------------------------------- /examples/tmux-client-tracking.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # tmux client tracking example # This script demonstrates how to track which terminal emulator is attached to tmux # Method 1: Setup tmux hooks to track client information setup_tmux_hooks() { # Track when clients attach tmux set-hook -g client-attached 'run-shell "echo \"Client attached: #{client_tty} at $(date)\" >> ~/.tmux-client.log"' # Track when clients change sessions tmux set-hook -g client-session-changed 'run-shell "echo \"Client #{client_tty} switched to #{session_name} at $(date)\" >> ~/.tmux-client.log"' # Track client activity tmux set-hook -g after-select-pane 'run-shell "echo \"Pane selected by #{client_tty} in #{session_name}:#{window_index}.#{pane_index} at $(date)\" >> ~/.tmux-client.log"' } # Method 2: Get terminal info from active client get_active_client_terminal() { # Get the most recently active client local active_client=$(tmux list-clients -F '#{client_tty}:#{client_activity}' | sort -t: -k2 -nr | head -1 | cut -d: -f1) if [ -n "$active_client" ]; then echo "Active client TTY: $active_client" # Try to find the terminal process if command -v lsof >/dev/null 2>&1; then echo "Processes using TTY:" lsof "$active_client" 2>/dev/null | grep -E '(Cursor|Code|iTerm|Terminal|alacritty)' | head -5 fi # Alternative: Check parent processes local pids=$(ps aux | grep "$active_client" | grep -v grep | awk '{print $2}') for pid in $pids; do if [ -f "/proc/$pid/environ" ]; then echo "Environment for PID $pid:" tr '\0' '\n' < "/proc/$pid/environ" | grep -E '(TERM_PROGRAM|CURSOR_TRACE_ID|VSCODE_IPC_HOOK_CLI|ALACRITTY)' fi done fi } # Method 3: Store client info in tmux environment track_client_terminal() { # This would be called when Claude Code starts local terminal_type="Unknown" # Detect terminal type if [ -n "$CURSOR_TRACE_ID" ]; then terminal_type="Cursor" elif [ -n "$VSCODE_IPC_HOOK_CLI" ]; then terminal_type="VSCode" elif [ "$TERM_PROGRAM" = "iTerm.app" ]; then terminal_type="iTerm2" elif [ "$TERM_PROGRAM" = "Apple_Terminal" ]; then terminal_type="Terminal" elif [ -n "$ALACRITTY_WINDOW_ID" ]; then terminal_type="alacritty" fi # Store in tmux environment tmux set-environment -g "CLAUDE_CODE_TERMINAL_${TMUX_PANE}" "$terminal_type" tmux set-environment -g "CLAUDE_CODE_STARTED_$(date +%s)" "$terminal_type:$TMUX_PANE" } # Method 4: Real-time client detection detect_current_client() { # Get current pane local current_pane=$(tmux display-message -p '#{pane_id}') # Find which client is currently viewing this pane tmux list-clients -F '#{client_tty}:#{client_session}:#{session_id}:#{window_id}:#{pane_id}' | while IFS=: read -r tty session session_id window_id pane_id; do # Check if this client is viewing our pane local client_pane=$(tmux display-message -t "$tty" -p '#{pane_id}' 2>/dev/null) if [ "$client_pane" = "$current_pane" ]; then echo "Client $tty is viewing this pane" # Now detect terminal from this TTY fi done } # Example usage case "${1:-}" in "setup") setup_tmux_hooks echo "tmux hooks configured" ;; "track") track_client_terminal echo "Terminal tracked: $(tmux show-environment -g | grep CLAUDE_CODE_TERMINAL)" ;; "detect") get_active_client_terminal ;; "current") detect_current_client ;; *) echo "Usage: $0 {setup|track|detect|current}" echo " setup - Configure tmux hooks for client tracking" echo " track - Track current terminal in tmux environment" echo " detect - Detect active client's terminal" echo " current - Detect which client is viewing current pane" ;; esac ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import { TmuxNotifier } from './notifier.js' interface NotificationOptions { message: string title?: string sound?: string session?: string window?: string pane?: string } // Get version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)) const packageJson = JSON.parse( readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'), ) const server = new Server( { name: 'macos-notify-mcp', version: packageJson.version, }, { capabilities: { tools: {}, }, }, ) const notifier = new TmuxNotifier() // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'send_notification', description: 'Send a macOS notification with optional tmux integration', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'The notification message', }, title: { type: 'string', description: 'The notification title (default: "Claude Code")', }, sound: { type: 'string', description: 'The notification sound (default: "Glass")', }, session: { type: 'string', description: 'tmux session name', }, window: { type: 'string', description: 'tmux window number', }, pane: { type: 'string', description: 'tmux pane number', }, useCurrent: { type: 'boolean', description: 'Use current tmux location', }, }, required: ['message'], }, }, { name: 'list_tmux_sessions', description: 'List available tmux sessions', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_current_tmux_info', description: 'Get current tmux session information', inputSchema: { type: 'object', properties: {}, }, }, ], } }) // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params if (!args) { throw new Error('No arguments provided') } try { switch (name) { case 'send_notification': { // Safely extract properties from args const notificationArgs = args as Record<string, unknown> // Validate message is provided if (!notificationArgs.message) { throw new Error('Message is required') } const options: NotificationOptions = { message: String(notificationArgs.message), title: notificationArgs.title ? String(notificationArgs.title) : undefined, sound: notificationArgs.sound ? String(notificationArgs.sound) : undefined, } if (notificationArgs.useCurrent) { const current = await notifier.getCurrentTmuxInfo() if (current) { options.session = current.session options.window = current.window options.pane = current.pane } } else { if (notificationArgs.session) options.session = String(notificationArgs.session) if (notificationArgs.window) options.window = String(notificationArgs.window) if (notificationArgs.pane) options.pane = String(notificationArgs.pane) } // Validate session if specified if (options.session) { const exists = await notifier.sessionExists(options.session) if (!exists) { const sessions = await notifier.listSessions() return { content: [ { type: 'text', text: `Error: Session '${options.session}' does not exist. Available sessions: ${sessions.join(', ')}`, }, ], } } } await notifier.sendNotification(options) return { content: [ { type: 'text', text: `Notification sent: "${options.message}"${options.session ? ` (tmux: ${options.session})` : ''}`, }, ], } } case 'list_tmux_sessions': { const sessions = await notifier.listSessions() return { content: [ { type: 'text', text: sessions.length > 0 ? `Available tmux sessions:\n${sessions.map((s) => `- ${s}`).join('\n')}` : 'No tmux sessions found', }, ], } } case 'get_current_tmux_info': { const info = await notifier.getCurrentTmuxInfo() if (info) { return { content: [ { type: 'text', text: `Current tmux location:\n- Session: ${info.session}\n- Window: ${info.window}\n- Pane: ${info.pane}`, }, ], } } return { content: [ { type: 'text', text: 'Not in a tmux session', }, ], } } default: throw new Error(`Unknown tool: ${name}`) } } catch (error) { return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], } } }) // Start the server async function main() { const transport = new StdioServerTransport() await server.connect(transport) console.error('macOS Notify MCP server started') } main().catch((error) => { console.error('Server error:', error) process.exit(1) }) ``` -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- ```typescript import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Mock the notifier module before importing cli let mockNotifier: any vi.mock('../src/notifier.js', () => ({ TmuxNotifier: vi.fn(() => mockNotifier), })) describe('CLI', () => { let originalArgv: string[] let originalExit: typeof process.exit let exitCode: number | undefined let consoleLogSpy: ReturnType<typeof vi.spyOn> let consoleErrorSpy: ReturnType<typeof vi.spyOn> beforeEach(() => { // Save original values originalArgv = process.argv originalExit = process.exit // Mock process.exit exitCode = undefined process.exit = ((code?: number) => { exitCode = code throw new Error(`Process.exit(${code})`) }) as never // Mock console methods consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Reset module cache vi.resetModules() // Create mock notifier mockNotifier = { sendNotification: vi.fn().mockResolvedValue(undefined), listSessions: vi.fn().mockResolvedValue(['session1', 'session2']), sessionExists: vi.fn().mockResolvedValue(true), getCurrentTmuxInfo: vi .fn() .mockResolvedValue({ session: 'current', window: '1', pane: '0' }), } }) afterEach(() => { // Restore original values process.argv = originalArgv process.exit = originalExit consoleLogSpy.mockRestore() consoleErrorSpy.mockRestore() vi.clearAllMocks() }) async function runCli() { // Import fresh copy and get main function const cliModule = await import('../src/cli.js') const main = (cliModule as any).main || cliModule.default if (typeof main === 'function') { try { await main() } catch (e: any) { if (!e.message.startsWith('Process.exit')) { throw e } } } } describe('--help flag', () => { it('should display help message with --help', async () => { process.argv = ['node', 'cli.js', '--help'] await runCli() expect(consoleLogSpy).toHaveBeenCalled() const output = consoleLogSpy.mock.calls.join('\n') expect(output).toContain('Usage:') expect(output).toContain('Options:') expect(output).toContain('--help') expect(output).toContain('--list-sessions') expect(exitCode).toBe(0) }) it('should display help message with -h', async () => { process.argv = ['node', 'cli.js', '-h'] await runCli() expect(consoleLogSpy).toHaveBeenCalled() expect(exitCode).toBe(0) }) }) describe('--list-sessions', () => { it('should list tmux sessions', async () => { process.argv = ['node', 'cli.js', '--list-sessions'] await runCli() expect(mockNotifier.listSessions).toHaveBeenCalled() expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') expect(consoleLogSpy).toHaveBeenCalledWith(' session1') expect(consoleLogSpy).toHaveBeenCalledWith(' session2') expect(exitCode).toBe(0) }) it('should handle no sessions gracefully', async () => { mockNotifier.listSessions.mockResolvedValue([]) process.argv = ['node', 'cli.js', '--list-sessions'] await runCli() expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') expect(exitCode).toBe(0) }) }) describe('notification sending', () => { it.skip('should send basic notification with message', async () => { process.argv = ['node', 'cli.js', '-m', 'Hello World'] await runCli() // Check what errors were logged if (consoleErrorSpy.mock.calls.length > 0) { console.log('Console errors found:', consoleErrorSpy.mock.calls) } expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Hello World', }) expect(consoleLogSpy).toHaveBeenCalledWith('Notification sent successfully') expect(consoleErrorSpy).not.toHaveBeenCalled() expect(exitCode).toBe(0) }) it('should send notification with title', async () => { process.argv = [ 'node', 'cli.js', '-m', 'Test message', '-t', 'Test Title', ] await runCli() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Test message', title: 'Test Title', }) }) it('should send notification with sound', async () => { process.argv = [ 'node', 'cli.js', '-m', 'Alert!', '--sound', 'Glass', ] await runCli() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Alert!', sound: 'Glass', }) }) it('should send notification to specific tmux location', async () => { process.argv = [ 'node', 'cli.js', '-m', 'Tmux notification', '-s', 'my-session', '-w', '2', '-p', '1', ] await runCli() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Tmux notification', session: 'my-session', window: '2', pane: '1', }) }) it('should use current tmux location with --current-tmux flag', async () => { process.argv = ['node', 'cli.js', '-m', 'Current location', '--current-tmux'] await runCli() expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Current location', session: 'current', window: '1', pane: '0', }) }) it('should handle missing current tmux info', async () => { mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null) process.argv = ['node', 'cli.js', '-m', 'Test', '--current-tmux'] await runCli() // When --current returns null, it should exit with error expect(exitCode).toBe(1) expect(mockNotifier.sendNotification).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Not in a tmux session') }) it('should validate session exists', async () => { mockNotifier.sessionExists.mockResolvedValue(false) process.argv = [ 'node', 'cli.js', '-m', 'Test', '-s', 'nonexistent', ] await runCli() expect(mockNotifier.sessionExists).toHaveBeenCalledWith('nonexistent') expect(consoleErrorSpy).toHaveBeenCalledWith( "Error: Session 'nonexistent' does not exist", ) expect(exitCode).toBe(1) }) }) describe('argument parsing', () => { it('should handle long form arguments', async () => { process.argv = [ 'node', 'cli.js', '--message', 'Long form', '--title', 'Title', '--session', 'session1', '--window', '1', '--pane', '0', ] await runCli() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Long form', title: 'Title', session: 'session1', window: '1', pane: '0', }) }) it('should error when no message provided', async () => { process.argv = ['node', 'cli.js'] await runCli() expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error: Message is required (-m option)', ) expect(exitCode).toBe(1) }) it('should handle spaces in arguments', async () => { process.argv = [ 'node', 'cli.js', '-m', 'Message with spaces', '-t', 'Title with spaces', ] await runCli() expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Message with spaces', title: 'Title with spaces', }) }) }) describe('error handling', () => { it('should handle notification sending errors', async () => { const error = new Error('Failed to send notification') mockNotifier.sendNotification.mockRejectedValue(error) process.argv = ['node', 'cli.js', '-m', 'Test'] await runCli() expect(consoleErrorSpy).toHaveBeenCalledWith( 'Failed to send notification:', error, ) expect(exitCode).toBe(1) }) it('should handle list sessions errors', async () => { // The notifier catches errors and returns empty array mockNotifier.listSessions.mockResolvedValue([]) process.argv = ['node', 'cli.js', '--list-sessions'] await runCli() expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') expect(exitCode).toBe(0) }) }) }) ``` -------------------------------------------------------------------------------- /MacOSNotifyMCP/main.swift: -------------------------------------------------------------------------------- ```swift #!/usr/bin/env swift import Foundation import UserNotifications import Cocoa class MacOSNotifyMCP: NSObject, UNUserNotificationCenterDelegate { private let center = UNUserNotificationCenter.current() override init() { super.init() center.delegate = self } func requestPermissionAndSendNotification( title: String, message: String, sound: String = "default", session: String? = nil, window: String? = nil, pane: String? = nil, terminal: String? = nil ) { center.requestAuthorization(options: [.alert, .sound]) { granted, error in if granted { self.sendNotification( title: title, message: message, sound: sound, session: session, window: window, pane: pane, terminal: terminal ) } else { print("Notification permission denied") exit(1) } } } private func sendNotification( title: String, message: String, sound: String, session: String?, window: String?, pane: String?, terminal: String? ) { let content = UNMutableNotificationContent() content.title = title content.body = message if sound == "default" { content.sound = .default } else { content.sound = UNNotificationSound(named: UNNotificationSoundName(sound + ".aiff")) } // tmux情報とターミナル情報をuserInfoに格納 var userInfo: [String: Any] = [:] if let session = session { userInfo["session"] = session if let window = window { userInfo["window"] = window } if let pane = pane { userInfo["pane"] = pane } } if let terminal = terminal { userInfo["terminal"] = terminal } content.userInfo = userInfo let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: nil ) center.add(request) { error in if let error = error { print("Notification error: \(error)") exit(1) } print("Notification sent") } } // Handle notification click func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo let terminal = userInfo["terminal"] as? String if let session = userInfo["session"] as? String { focusToTmux( session: session, window: userInfo["window"] as? String, pane: userInfo["pane"] as? String, terminal: terminal ) } else if let terminal = terminal { // tmuxセッションがない場合でもターミナルをアクティブ化 activateTerminal(preferredTerminal: terminal) } completionHandler() // Exit after handling click DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApplication.shared.terminate(nil) } } private func focusToTmux(session: String, window: String?, pane: String?, terminal: String?) { // Activate terminal activateTerminal(preferredTerminal: terminal) // Execute tmux commands DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { let tmuxPath = self.findTmuxPath() guard !tmuxPath.isEmpty else { return } // Switch to session var tmuxTarget = session if let window = window { tmuxTarget += ":\(window)" if let pane = pane { tmuxTarget += ".\(pane)" } } self.runCommand(tmuxPath, args: ["switch-client", "-t", tmuxTarget]) } } private func activateTerminal(preferredTerminal: String? = nil) { // ターミナルタイプからアプリケーション名へのマッピング let terminalMap: [String: String] = [ "VSCode": "Visual Studio Code", "Cursor": "Cursor", "iTerm2": "iTerm2", "Terminal": "Terminal", "alacritty": "Alacritty" ] // 検出されたターミナルを優先的に使用 if let preferred = preferredTerminal, let appName = terminalMap[preferred] { if isAppRunning(appName) { runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(appName)\" to activate"]) return } } // フォールバック: 実行中のターミナルを探す let terminals = ["Alacritty", "iTerm2", "WezTerm", "Terminal", "Visual Studio Code", "Cursor"] for terminal in terminals { if isAppRunning(terminal) { runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(terminal)\" to activate"]) return } } // Default to Terminal.app runCommand("/usr/bin/osascript", args: ["-e", "tell application \"Terminal\" to activate"]) } private func isAppRunning(_ appName: String) -> Bool { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") task.arguments = ["-f", appName] task.standardOutput = Pipe() do { try task.run() task.waitUntilExit() return task.terminationStatus == 0 } catch { return false } } private func findTmuxPath() -> String { let paths = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] for path in paths { if FileManager.default.fileExists(atPath: path) { return path } } // Search using which let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/which") task.arguments = ["tmux"] let pipe = Pipe() task.standardOutput = pipe do { try task.run() task.waitUntilExit() if task.terminationStatus == 0 { let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { return output } } } catch {} return "" } @discardableResult private func runCommand(_ path: String, args: [String]) -> Bool { let task = Process() task.executableURL = URL(fileURLWithPath: path) task.arguments = args do { try task.run() task.waitUntilExit() return task.terminationStatus == 0 } catch { return false } } } // Main process let app = NSApplication.shared app.setActivationPolicy(.accessory) // Run in background (no Dock icon, but can show notification icons) // Parse arguments var title = "Claude Code" var message = "" var session: String? var window: String? var pane: String? var sound = "default" var terminal: String? var i = 1 let args = CommandLine.arguments while i < args.count { switch args[i] { case "-t", "--title": if i + 1 < args.count { title = args[i + 1] i += 1 } case "-m", "--message": if i + 1 < args.count { message = args[i + 1] i += 1 } case "-s", "--session": if i + 1 < args.count { session = args[i + 1] i += 1 } case "-w", "--window": if i + 1 < args.count { window = args[i + 1] i += 1 } case "-p", "--pane": if i + 1 < args.count { pane = args[i + 1] i += 1 } case "--sound": if i + 1 < args.count { sound = args[i + 1] i += 1 } case "--terminal": if i + 1 < args.count { terminal = args[i + 1] i += 1 } case "-h", "--help": print(""" Usage: MacOSNotifyMCP [options] Options: -t, --title <text> Notification title (default: "Claude Code") -m, --message <text> Notification message (required) -s, --session <name> tmux session name -w, --window <number> tmux window number -p, --pane <number> tmux pane number --sound <name> Notification sound (default: "default") --terminal <type> Terminal type (VSCode, Cursor, iTerm2, etc.) Examples: MacOSNotifyMCP -m "Build completed" MacOSNotifyMCP -t "Build" -m "Success" -s development -w 1 -p 0 """) exit(0) default: break } i += 1 } // Message is required if message.isEmpty { print("Error: Message is required (-m option)") exit(1) } // Create MacOSNotifyMCP instance and send notification let notifier = MacOSNotifyMCP() // Send notification and wait in RunLoop notifier.requestPermissionAndSendNotification( title: title, message: message, sound: sound, session: session, window: window, pane: pane, terminal: terminal ) // Run the app app.run() ``` -------------------------------------------------------------------------------- /test/notifier.test.ts: -------------------------------------------------------------------------------- ```typescript import { afterEach, beforeEach, describe, expect, it, type Mock, vi, } from 'vitest' import { TmuxNotifier } from '../src/notifier' import type { ChildProcess } from 'node:child_process' // Mock modules vi.mock('node:child_process') vi.mock('node:fs') vi.mock('node:url', () => ({ fileURLToPath: vi.fn(() => '/mocked/path/notifier.js'), })) describe('TmuxNotifier', () => { let notifier: TmuxNotifier let mockSpawn: Mock let mockExistsSync: Mock beforeEach(async () => { // Reset mocks vi.clearAllMocks() // Get mocked functions const childProcess = await import('node:child_process') const fs = await import('node:fs') mockSpawn = childProcess.spawn as unknown as Mock mockExistsSync = fs.existsSync as unknown as Mock // Default mock implementations mockExistsSync.mockReturnValue(true) }) afterEach(() => { vi.restoreAllMocks() }) describe('constructor', () => { it('should use custom app path when provided', () => { const customPath = '/custom/path/to/MacOSNotifyMCP.app' notifier = new TmuxNotifier(customPath) expect(notifier['appPath']).toBe(customPath) }) it('should find app in default locations when no custom path provided', () => { mockExistsSync.mockImplementation((path: string) => { return path.includes('MacOSNotifyMCP/MacOSNotifyMCP.app') }) notifier = new TmuxNotifier() expect(notifier['appPath']).toContain('MacOSNotifyMCP.app') }) it('should handle missing app gracefully', () => { mockExistsSync.mockReturnValue(false) notifier = new TmuxNotifier() // Should default to first possible path even if it doesn't exist expect(notifier['appPath']).toContain('MacOSNotifyMCP.app') }) }) describe('runCommand', () => { beforeEach(() => { notifier = new TmuxNotifier('/test/app/path') }) it('should execute command successfully', async () => { const mockProcess = createMockProcess() mockSpawn.mockReturnValue(mockProcess) const result = notifier['runCommand']('echo', ['hello']) // Simulate successful execution mockProcess.stdout.emit('data', Buffer.from('hello')) mockProcess.emit('close', 0) expect(await result).toBe('hello') expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello']) }) it('should handle command failure with non-zero exit code', async () => { const mockProcess = createMockProcess() mockSpawn.mockReturnValue(mockProcess) const promise = notifier['runCommand']('false', []) // Simulate failure mockProcess.stderr.emit('data', Buffer.from('Command failed')) mockProcess.emit('close', 1) await expect(promise).rejects.toThrow('Command failed: false') }) it('should handle spawn errors', async () => { const mockProcess = createMockProcess() mockSpawn.mockReturnValue(mockProcess) const promise = notifier['runCommand']('nonexistent', []) // Simulate spawn error mockProcess.emit('error', new Error('spawn ENOENT')) await expect(promise).rejects.toThrow('spawn ENOENT') }) it('should accumulate stdout and stderr data', async () => { const mockProcess = createMockProcess() mockSpawn.mockReturnValue(mockProcess) const result = notifier['runCommand']('test', []) // Emit multiple data chunks mockProcess.stdout.emit('data', Buffer.from('Hello ')) mockProcess.stdout.emit('data', Buffer.from('World')) mockProcess.stderr.emit('data', Buffer.from('Warning')) mockProcess.emit('close', 0) expect(await result).toBe('Hello World') }) }) describe('getCurrentTmuxInfo', () => { beforeEach(() => { notifier = new TmuxNotifier('/test/app/path') }) it('should return current tmux session info', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValueOnce('my-session') .mockResolvedValueOnce('1') .mockResolvedValueOnce('0') const result = await notifier.getCurrentTmuxInfo() expect(result).toEqual({ session: 'my-session', window: '1', pane: '0', }) expect(runCommandSpy).toHaveBeenCalledTimes(3) }) it('should return null when not in tmux session', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockRejectedValue(new Error('not in tmux')) const result = await notifier.getCurrentTmuxInfo() expect(result).toBeNull() expect(runCommandSpy).toHaveBeenCalledTimes(1) }) }) describe('listSessions', () => { beforeEach(() => { notifier = new TmuxNotifier('/test/app/path') }) it('should return list of tmux sessions', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('session1\nsession2\nsession3\n') const result = await notifier.listSessions() expect(result).toEqual(['session1', 'session2', 'session3']) expect(runCommandSpy).toHaveBeenCalledWith('tmux', [ 'list-sessions', '-F', '#{session_name}', ]) }) it('should return empty array when no sessions exist', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockRejectedValue(new Error('no sessions')) const result = await notifier.listSessions() expect(result).toEqual([]) }) it('should filter out empty lines', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('session1\n\nsession2\n\n') const result = await notifier.listSessions() expect(result).toEqual(['session1', 'session2']) }) }) describe('sessionExists', () => { beforeEach(() => { notifier = new TmuxNotifier('/test/app/path') }) it('should return true when session exists', async () => { const listSessionsSpy = vi .spyOn(notifier, 'listSessions') .mockResolvedValue(['session1', 'session2', 'my-session']) const result = await notifier.sessionExists('my-session') expect(result).toBe(true) expect(listSessionsSpy).toHaveBeenCalled() }) it('should return false when session does not exist', async () => { const listSessionsSpy = vi .spyOn(notifier, 'listSessions') .mockResolvedValue(['session1', 'session2']) const result = await notifier.sessionExists('nonexistent') expect(result).toBe(false) }) it('should handle empty session list', async () => { const listSessionsSpy = vi .spyOn(notifier, 'listSessions') .mockResolvedValue([]) const result = await notifier.sessionExists('any-session') expect(result).toBe(false) }) }) describe('sendNotification', () => { beforeEach(() => { notifier = new TmuxNotifier('/test/app/path') }) it('should send basic notification with message only', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('') await notifier.sendNotification({ message: 'Hello World' }) expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [ '-n', '/test/app/path', '--args', '-t', 'macos-notify-mcp', '-m', 'Hello World', '--sound', 'Glass', ]) }) it('should send notification with all options', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('') await notifier.sendNotification({ message: 'Test message', title: 'Test Title', sound: 'Glass', session: 'my-session', window: '2', pane: '1', }) expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [ '-n', '/test/app/path', '--args', '-t', 'Test Title', '-m', 'Test message', '--sound', 'Glass', '-s', 'my-session', '-w', '2', '-p', '1', ]) }) it('should handle empty app path', async () => { notifier = new TmuxNotifier() notifier['appPath'] = '' await expect( notifier.sendNotification({ message: 'Test' }), ).rejects.toThrow('MacOSNotifyMCP.app not found') }) it('should escape special characters in arguments', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('') await notifier.sendNotification({ message: 'Message with "quotes"', title: "Title with 'apostrophes'", }) const call = runCommandSpy.mock.calls[0] expect(call[1]).toContain('Message with "quotes"') expect(call[1]).toContain('-t') expect(call[1]).toContain("Title with 'apostrophes'") }) it('should omit undefined optional parameters', async () => { const runCommandSpy = vi .spyOn(notifier as any, 'runCommand') .mockResolvedValue('') await notifier.sendNotification({ message: 'Simple message', title: 'Title', // Other options undefined }) const args = runCommandSpy.mock.calls[0][1] expect(args).toEqual([ '-n', '/test/app/path', '--args', '-t', 'Title', '-m', 'Simple message', '--sound', 'Glass', ]) expect(args).not.toContain('-s') expect(args).not.toContain('-w') expect(args).not.toContain('-p') }) }) }) // Helper function to create a mock child process function createMockProcess(): Partial<ChildProcess> { const EventEmitter = require('node:events').EventEmitter const stdout = new EventEmitter() const stderr = new EventEmitter() const process = new EventEmitter() return Object.assign(process, { stdout, stderr, stdin: { end: vi.fn(), }, kill: vi.fn(), pid: 12345, }) as unknown as ChildProcess } ``` -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- ```typescript import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' describe('MCP Server', () => { let mockNotifier: any let mockServer: any let handlers: Map<string, any> beforeEach(() => { // Reset module cache vi.resetModules() // Create handlers map handlers = new Map() // Create mock notifier mockNotifier = { sendNotification: vi.fn().mockResolvedValue(undefined), listSessions: vi.fn().mockResolvedValue(['session1', 'session2']), sessionExists: vi.fn().mockResolvedValue(true), getCurrentTmuxInfo: vi .fn() .mockResolvedValue({ session: 'current', window: '1', pane: '0' }), } // Mock the notifier module vi.doMock('../src/notifier', () => ({ TmuxNotifier: vi.fn(() => mockNotifier), })) // Create mock server mockServer = { name: 'macos-notify-mcp', version: '0.1.0', setRequestHandler: vi.fn((schema: any, handler: any) => { handlers.set(schema, handler) }), connect: vi.fn(), } // Mock MCP SDK vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({ Server: vi.fn(() => mockServer), })) vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: vi.fn(() => ({ start: vi.fn(), close: vi.fn(), })), })) }) afterEach(() => { vi.clearAllMocks() }) async function loadServer() { await import('../src/index') return { handlers } } describe('Tool Registration', () => { it('should register all tools on initialization', async () => { const { handlers } = await loadServer() const listToolsHandler = handlers.get(ListToolsRequestSchema) expect(listToolsHandler).toBeDefined() const response = await listToolsHandler({ method: 'tools/list' }) expect(response.tools).toHaveLength(3) const toolNames = response.tools.map((tool: any) => tool.name) expect(toolNames).toContain('send_notification') expect(toolNames).toContain('list_tmux_sessions') expect(toolNames).toContain('get_current_tmux_info') }) it('should provide correct schema for send_notification tool', async () => { const { handlers } = await loadServer() const listToolsHandler = handlers.get(ListToolsRequestSchema) const response = await listToolsHandler({ method: 'tools/list' }) const sendNotificationTool = response.tools.find( (tool: any) => tool.name === 'send_notification', ) expect(sendNotificationTool).toBeDefined() expect(sendNotificationTool.description).toContain( 'Send a macOS notification', ) expect(sendNotificationTool.inputSchema.type).toBe('object') expect(sendNotificationTool.inputSchema.required).toContain('message') expect( sendNotificationTool.inputSchema.properties.message, ).toBeDefined() expect(sendNotificationTool.inputSchema.properties.title).toBeDefined() expect(sendNotificationTool.inputSchema.properties.sound).toBeDefined() }) }) describe('Tool Execution', () => { let callToolHandler: any beforeEach(async () => { const { handlers } = await loadServer() callToolHandler = handlers.get(CallToolRequestSchema) }) describe('send_notification', () => { it('should send notification with message only', async () => { const request = { method: 'tools/call', params: { name: 'send_notification', arguments: { message: 'Test notification', }, }, } const response = await callToolHandler(request) expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Test notification', }) expect(response.content).toHaveLength(1) expect(response.content[0].type).toBe('text') expect(response.content[0].text).toBe('Notification sent: "Test notification"') }) it('should send notification with all parameters', async () => { const request = { method: 'tools/call', params: { name: 'send_notification', arguments: { message: 'Full notification', title: 'Important', sound: 'Glass', session: 'work', window: '2', pane: '1', }, }, } const response = await callToolHandler(request) expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: 'Full notification', title: 'Important', sound: 'Glass', session: 'work', window: '2', pane: '1', }) expect(response.content[0].text).toBe('Notification sent: "Full notification" (tmux: work)') }) it('should handle missing required message parameter', async () => { const request = { method: 'tools/call', params: { name: 'send_notification', arguments: { title: 'No message', }, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Error: Message is required') }) it('should handle notification sending errors', async () => { mockNotifier.sendNotification.mockRejectedValue( new Error('Failed to send'), ) const request = { method: 'tools/call', params: { name: 'send_notification', arguments: { message: 'Will fail', }, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Error: Failed to send') }) it('should convert non-string parameters to strings', async () => { const request = { method: 'tools/call', params: { name: 'send_notification', arguments: { message: 123, title: true, window: 456, pane: null, } as any, }, } const response = await callToolHandler(request) expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ message: '123', title: 'true', window: '456', // pane is not included because null is filtered out }) }) }) describe('list_tmux_sessions', () => { it('should list tmux sessions', async () => { const request = { method: 'tools/call', params: { name: 'list_tmux_sessions', arguments: {}, }, } const response = await callToolHandler(request) expect(mockNotifier.listSessions).toHaveBeenCalled() expect(response.content).toHaveLength(1) expect(response.content[0].type).toBe('text') expect(response.content[0].text).toContain('session1') expect(response.content[0].text).toContain('session2') }) it('should handle empty session list', async () => { mockNotifier.listSessions.mockResolvedValue([]) const request = { method: 'tools/call', params: { name: 'list_tmux_sessions', arguments: {}, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('No tmux sessions found') }) it('should handle errors when listing sessions', async () => { mockNotifier.listSessions.mockRejectedValue( new Error('Tmux not available'), ) const request = { method: 'tools/call', params: { name: 'list_tmux_sessions', arguments: {}, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Error: Tmux not available') }) }) describe('get_current_tmux_info', () => { it('should get current tmux info', async () => { const request = { method: 'tools/call', params: { name: 'get_current_tmux_info', arguments: {}, }, } const response = await callToolHandler(request) expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled() expect(response.content).toHaveLength(1) expect(response.content[0].type).toBe('text') const text = response.content[0].text expect(text).toContain('Session: current') expect(text).toContain('Window: 1') expect(text).toContain('Pane: 0') }) it('should handle when not in tmux session', async () => { mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null) const request = { method: 'tools/call', params: { name: 'get_current_tmux_info', arguments: {}, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Not in a tmux session') }) it('should handle errors when getting tmux info', async () => { mockNotifier.getCurrentTmuxInfo.mockRejectedValue( new Error('Tmux error'), ) const request = { method: 'tools/call', params: { name: 'get_current_tmux_info', arguments: {}, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Error: Tmux error') }) }) describe('unknown tool', () => { it('should handle unknown tool name', async () => { const request = { method: 'tools/call', params: { name: 'unknown_tool', arguments: {}, }, } const response = await callToolHandler(request) expect(response.content[0].text).toBe('Error: Unknown tool: unknown_tool') }) }) }) describe('Server Lifecycle', () => { it('should create server with correct configuration', async () => { await loadServer() const { Server } = await import('@modelcontextprotocol/sdk/server/index.js') expect(Server).toHaveBeenCalledWith({ name: 'macos-notify-mcp', version: expect.any(String), }, expect.any(Object)) }) it('should connect transport', async () => { await loadServer() const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js') expect(StdioServerTransport).toHaveBeenCalled() }) it('should set up error handling', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await loadServer() // Simulate an error event const errorHandler = mockServer.onerror || mockServer.setRequestHandler.mock.calls.find( (call: any) => call[0] === 'error' )?.[1] if (errorHandler) { const testError = new Error('Test error') errorHandler(testError) expect(consoleErrorSpy).toHaveBeenCalledWith('Server error:', testError) } consoleErrorSpy.mockRestore() }) }) }) ``` -------------------------------------------------------------------------------- /src/notifier.ts: -------------------------------------------------------------------------------- ```typescript import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' interface NotificationOptions { title?: string message: string sound?: string session?: string window?: string pane?: string } interface TmuxInfo { session: string window: string pane: string } interface CommandError extends Error { code?: number stderr?: string stdout?: string } export type TerminalType = | 'VSCode' | 'Cursor' | 'iTerm2' | 'Terminal' | 'alacritty' | 'Unknown' export class TmuxNotifier { private appPath = '' private defaultTitle = 'macos-notify-mcp' constructor(customAppPath?: string) { if (customAppPath) { this.appPath = customAppPath } else { // Try multiple locations const possiblePaths = [ // Relative to the package installation (primary location) join( dirname(fileURLToPath(import.meta.url)), '..', 'MacOSNotifyMCP', 'MacOSNotifyMCP.app', ), // Development path join(process.cwd(), 'MacOSNotifyMCP', 'MacOSNotifyMCP.app'), ] // Find the first existing path for (const path of possiblePaths) { if (existsSync(path)) { this.appPath = path break } } // Default to package-relative path if (!this.appPath) { this.appPath = possiblePaths[0] } } // Get repository name as default title this.initializeDefaultTitle() } /** * Initialize default title from git repository name */ private async initializeDefaultTitle(): Promise<void> { try { const repoName = await this.getGitRepoName() if (repoName) { this.defaultTitle = repoName } } catch (_error) { // Keep default title if git command fails } } /** * Get the active tmux client information */ private async getActiveClientInfo(): Promise<{ tty: string session: string activity: string } | null> { try { // Get list of all clients attached to the current session const currentSession = process.env.TMUX_PANE ? await this.runCommand('tmux', [ 'display-message', '-p', '#{session_name}', ]) : null if (!currentSession) return null // Get all clients attached to this session const clientsOutput = await this.runCommand('tmux', [ 'list-clients', '-t', currentSession.trim(), '-F', '#{client_tty}|#{client_session}|#{client_activity}', ]) const clients = clientsOutput .trim() .split('\n') .filter(Boolean) .map((line) => { const [tty, session, activity] = line.split('|') return { tty, session, activity: Number(activity) } }) // Get the most recently active client const activeClient = clients.reduce((prev, curr) => curr.activity > prev.activity ? curr : prev, ) return { tty: activeClient.tty, session: activeClient.session, activity: activeClient.activity.toString(), } } catch (_error) { return null } } /** * Detect terminal emulator from client TTY */ private async detectTerminalFromClient( clientTty: string, ): Promise<TerminalType> { try { // Find processes using this TTY const lsofOutput = await this.runCommand('lsof', [clientTty]) const lines = lsofOutput.trim().split('\n').slice(1) // Skip header for (const line of lines) { const parts = line.split(/\s+/) if (parts.length < 2) continue const pid = parts[1] // Get process info const psOutput = await this.runCommand('ps', ['-p', pid, '-o', 'comm=']) const command = psOutput.trim() // Check for known terminal emulators if (command.includes('Cursor')) return 'Cursor' if (command.includes('Code')) return 'VSCode' if (command.includes('iTerm2')) return 'iTerm2' if (command.includes('Terminal')) return 'Terminal' if (command.includes('alacritty')) return 'alacritty' } } catch (_error) { // lsof might fail, continue with other methods } return 'Unknown' } /** * Detect the parent terminal emulator */ private async detectTerminalEmulator(): Promise<TerminalType> { // 1. Check for Cursor via CURSOR_TRACE_ID if (process.env.CURSOR_TRACE_ID) { return 'Cursor' } // 2. Check for VSCode/Cursor via VSCODE_IPC_HOOK_CLI if (process.env.VSCODE_IPC_HOOK_CLI) { // Check if it's Cursor by looking for cursor-specific paths if (process.env.VSCODE_IPC_HOOK_CLI.includes('Cursor')) { return 'Cursor' } return 'VSCode' } // 3. Check for VSCode Remote (for tmux attached from VSCode) if (process.env.VSCODE_REMOTE || process.env.VSCODE_PID) { return 'VSCode' } // 4. Check for alacritty via specific environment variables if (process.env.ALACRITTY_WINDOW_ID || process.env.ALACRITTY_SOCKET) { return 'alacritty' } // 5. Check TERM_PROGRAM for iTerm2 and Terminal.app if (process.env.TERM_PROGRAM) { if (process.env.TERM_PROGRAM === 'iTerm.app') { return 'iTerm2' } if (process.env.TERM_PROGRAM === 'Apple_Terminal') { return 'Terminal' } if (process.env.TERM_PROGRAM === 'alacritty') { return 'alacritty' } } // 6. If we're in tmux, try to detect the active client's terminal if (process.env.TMUX) { try { // Get active client info const clientInfo = await this.getActiveClientInfo() if (clientInfo) { const detectedTerminal = await this.detectTerminalFromClient( clientInfo.tty, ) if (detectedTerminal !== 'Unknown') { return detectedTerminal } } // Fallback: Get the tmux client's terminal info const clientTerm = await this.runCommand('tmux', [ 'display-message', '-p', '#{client_termname}', ]) // Check for specific terminal indicators in the client termname if (clientTerm.includes('iterm') || clientTerm.includes('iTerm')) { return 'iTerm2' } if (clientTerm.includes('Apple_Terminal')) { return 'Terminal' } // Also check tmux client environment variables try { const clientEnv = await this.runCommand('tmux', [ 'show-environment', '-g', 'TERM_PROGRAM', ]) if (clientEnv.includes('TERM_PROGRAM=iTerm.app')) { return 'iTerm2' } if (clientEnv.includes('TERM_PROGRAM=Apple_Terminal')) { return 'Terminal' } } catch (_) { // Ignore if show-environment fails } } catch (_) { // Ignore tmux command failures } } // 3. Fallback: Check process tree try { // Get the parent process ID chain let currentPid = process.pid const maxDepth = 10 // Prevent infinite loops for (let i = 0; i < maxDepth; i++) { // Get parent process info using ps command const psOutput = await this.runCommand('ps', [ '-p', currentPid.toString(), '-o', 'ppid=,comm=', ]) const [ppidStr, command] = psOutput.trim().split(/\s+/, 2) const ppid = Number.parseInt(ppidStr) if (!ppid || ppid === 1) { break // Reached init process } // Check if the command matches known terminal emulators if (command) { if (command.includes('Cursor')) { return 'Cursor' } if (command.includes('Code') || command.includes('code-insiders')) { return 'VSCode' } if (command.includes('iTerm2')) { return 'iTerm2' } if (command.includes('Terminal')) { return 'Terminal' } } currentPid = ppid } } catch (_error) { // Ignore errors in process tree detection } return 'Unknown' } /** * Get git repository name from current directory */ private async getGitRepoName(): Promise<string | null> { try { // Get the remote URL const remoteUrl = ( await this.runCommand('git', ['config', '--get', 'remote.origin.url']) ).trim() if (!remoteUrl) { // If no remote, try to get the directory name of the git root const gitRoot = ( await this.runCommand('git', ['rev-parse', '--show-toplevel']) ).trim() return gitRoot.split('/').pop() || null } // Extract repo name from URL // Handle both HTTPS and SSH formats // https://github.com/user/repo.git // [email protected]:user/repo.git const match = remoteUrl.match(/[/:]([\w-]+)\/([\w-]+?)(\.git)?$/) if (match) { return match[2] } // Fallback to directory name const gitRoot = ( await this.runCommand('git', ['rev-parse', '--show-toplevel']) ).trim() return gitRoot.split('/').pop() || null } catch (_error) { return null } } /** * Run a command and return the output */ private async runCommand(command: string, args: string[]): Promise<string> { return new Promise((resolve, reject) => { const proc = spawn(command, args) let stdout = '' let stderr = '' proc.stdout.on('data', (data) => { stdout += data.toString() }) proc.stderr.on('data', (data) => { stderr += data.toString() }) proc.on('close', (code) => { if (code === 0) { resolve(stdout) } else { const error = new Error( `Command failed: ${command} ${args.join(' ')}\n${stderr}`, ) as CommandError error.code = code ?? undefined error.stderr = stderr error.stdout = stdout reject(error) } }) proc.on('error', reject) }) } /** * Get current tmux session info */ async getCurrentTmuxInfo(): Promise<TmuxInfo | null> { try { const session = ( await this.runCommand('tmux', [ 'display-message', '-p', '#{session_name}', ]) ).trim() const window = ( await this.runCommand('tmux', [ 'display-message', '-p', '#{window_index}', ]) ).trim() const pane = ( await this.runCommand('tmux', [ 'display-message', '-p', '#{pane_index}', ]) ).trim() return { session, window, pane } } catch (_error) { return null } } /** * List tmux sessions */ async listSessions(): Promise<string[]> { try { const output = await this.runCommand('tmux', [ 'list-sessions', '-F', '#{session_name}', ]) return output.trim().split('\n').filter(Boolean) } catch (_error) { return [] } } /** * Check if a session exists */ async sessionExists(session: string): Promise<boolean> { const sessions = await this.listSessions() return sessions.includes(session) } /** * Get the detected terminal emulator type */ async getTerminalEmulator(): Promise<TerminalType> { return this.detectTerminalEmulator() } /** * Send notification */ async sendNotification(options: NotificationOptions): Promise<void> { const { title = this.defaultTitle, message, sound = 'Glass', session, window, pane, } = options // Check if app path is valid if (!this.appPath) { throw new Error('MacOSNotifyMCP.app not found') } // Always detect terminal emulator to pass to notification app const terminal = await this.detectTerminalEmulator() // Use MacOSNotifyMCP.app for notifications const args = [ '-n', this.appPath, '--args', '-t', title, '-m', message, '--sound', sound, '--terminal', terminal, ] if (session) { args.push('-s', session) if (window !== undefined && window !== '') { args.push('-w', window) } if (pane !== undefined && pane !== '') { args.push('-p', pane) } } await this.runCommand('/usr/bin/open', args) } } ```