#
tokens: 29862/50000 21/21 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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:
--------------------------------------------------------------------------------

```
 1 | # Dependencies
 2 | node_modules/
 3 | 
 4 | # Build output
 5 | dist/
 6 | 
 7 | # IDE
 8 | .vscode/
 9 | .idea/
10 | 
11 | # OS
12 | .DS_Store
13 | 
14 | # Logs
15 | *.log
16 | npm-debug.log*
17 | 
18 | # Environment
19 | .env
20 | .env.local
21 | 
22 | # TypeScript
23 | *.tsbuildinfo
24 | 
25 | # Temporary files
26 | *.tmp
27 | tmp/
28 | temp/
```

--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------

```
 1 | # Source files
 2 | src/
 3 | tsconfig.json
 4 | 
 5 | # Development files
 6 | .gitignore
 7 | .git/
 8 | 
 9 | # Swift source files (but keep the built app)
10 | NotifyTmux/main.swift
11 | NotifyTmux/build-app.sh
12 | NotifyTmux/notify-swift
13 | 
14 | # Old Deno files
15 | bin/
16 | 
17 | # IDE
18 | .vscode/
19 | .idea/
20 | 
21 | # OS
22 | .DS_Store
23 | 
24 | # Temporary
25 | *.tmp
26 | tmp/
27 | temp/
28 | 
29 | # Keep NotifyTmux.app
30 | !NotifyTmux/NotifyTmux.app/
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # macOS Notify MCP
  2 | 
  3 | 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.
  4 | 
  5 | ## Features
  6 | 
  7 | - 🔔 Native macOS notifications using UserNotifications API
  8 | - 🖱️ Clickable notifications that focus tmux sessions
  9 | - 🎯 Direct navigation to specific tmux session, window, and pane
 10 | - 🔊 Customizable notification sounds
 11 | - 🚀 Support for multiple concurrent notifications
 12 | - 🤖 MCP server for AI assistant integration
 13 | - 🖥️ Terminal emulator detection (VSCode, Cursor, iTerm2, Terminal.app)
 14 | 
 15 | ## Installation
 16 | 
 17 | ### Prerequisites
 18 | 
 19 | - macOS (required for notifications)
 20 | - Node.js >= 18.0.0
 21 | - tmux (optional, for tmux integration)
 22 | 
 23 | ### Install from npm
 24 | 
 25 | ```bash
 26 | npm install -g macos-notify-mcp
 27 | ```
 28 | 
 29 | ### Build from source
 30 | 
 31 | ```bash
 32 | git clone https://github.com/yuki-yano/macos-notify-mcp.git
 33 | cd macos-notify-mcp
 34 | npm install
 35 | npm run build
 36 | npm run build-app  # Build the macOS app bundle (only needed for development)
 37 | ```
 38 | 
 39 | ## Usage
 40 | 
 41 | ### As MCP Server
 42 | 
 43 | First, install the package globally:
 44 | 
 45 | ```bash
 46 | npm install -g macos-notify-mcp
 47 | ```
 48 | 
 49 | #### Quick Setup with Claude Code
 50 | 
 51 | Use the `claude mcp add` command:
 52 | 
 53 | ```bash
 54 | claude mcp add macos-notify -s user -- macos-notify-mcp
 55 | ```
 56 | 
 57 | Then restart Claude Code.
 58 | 
 59 | #### Manual Setup for Claude Desktop
 60 | 
 61 | Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
 62 | 
 63 | ```json
 64 | {
 65 |   "mcpServers": {
 66 |     "macos-notify": {
 67 |       "command": "macos-notify-mcp"
 68 |     }
 69 |   }
 70 | }
 71 | ```
 72 | 
 73 | 
 74 | ### Available MCP Tools
 75 | 
 76 | - `send_notification` - Send a macOS notification
 77 |   - `message` (required): Notification message
 78 |   - `title`: Notification title (default: "Claude Code")
 79 |   - `sound`: Notification sound (default: "Glass")
 80 |   - `session`: tmux session name
 81 |   - `window`: tmux window number
 82 |   - `pane`: tmux pane number
 83 |   - `useCurrent`: Use current tmux location
 84 | 
 85 | - `list_tmux_sessions` - List available tmux sessions
 86 | 
 87 | - `get_current_tmux_info` - Get current tmux session information
 88 | 
 89 | ### As CLI Tool
 90 | 
 91 | ```bash
 92 | # Basic notification
 93 | macos-notify-cli -m "Build completed"
 94 | 
 95 | # With title
 96 | macos-notify-cli -t "Development" -m "Tests passed"
 97 | 
 98 | # With tmux integration
 99 | macos-notify-cli -m "Task finished" -s my-session -w 1 -p 0
100 | 
101 | # Use current tmux location
102 | macos-notify-cli -m "Check this pane" --current-tmux
103 | 
104 | # Detect current terminal emulator
105 | macos-notify-cli --detect-terminal
106 | 
107 | # List tmux sessions
108 | macos-notify-cli --list-sessions
109 | ```
110 | 
111 | ### Terminal Detection
112 | 
113 | 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:
114 | 
115 | ```bash
116 | # Test terminal detection
117 | macos-notify-cli --detect-terminal
118 | ```
119 | 
120 | #### Supported Terminal Detection
121 | 
122 | The tool detects terminals using various methods:
123 | 
124 | 1. **Cursor**: Via `CURSOR_TRACE_ID` environment variable
125 | 2. **VSCode**: Via `VSCODE_IPC_HOOK_CLI` or `VSCODE_REMOTE` environment variables
126 | 3. **alacritty**: Via `ALACRITTY_WINDOW_ID` or `ALACRITTY_SOCKET` environment variables
127 | 4. **iTerm2**: Via `TERM_PROGRAM=iTerm.app`
128 | 5. **Terminal.app**: Via `TERM_PROGRAM=Apple_Terminal`
129 | 
130 | #### Terminal Detection in tmux
131 | 
132 | When running inside tmux, the tool attempts to detect which terminal emulator the active tmux client is using:
133 | 
134 | 1. **Active Client Detection**: Identifies the most recently active tmux client
135 | 2. **TTY Process Analysis**: Traces processes using the client's TTY
136 | 3. **Environment Preservation**: Checks preserved environment variables
137 | 4. **Process Tree Fallback**: Analyzes the process tree as a last resort
138 | 
139 | For advanced tmux client tracking, see `examples/tmux-client-tracking.sh`.
140 | 
141 | ## How it Works
142 | 
143 | 1. **Notification Delivery**: Uses a native macOS app bundle (MacOSNotifyMCP.app) to send UserNotifications API notifications
144 | 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
145 | 3. **Terminal Support**: Automatically detects and activates the correct terminal application
146 | 4. **Multiple Instances**: Each notification runs as a separate process, allowing multiple concurrent notifications
147 | 
148 | ## Architecture
149 | 
150 | The project consists of two main components:
151 | 
152 | 1. **MCP Server/CLI** (TypeScript/Node.js)
153 |    - Implements the Model Context Protocol
154 |    - Provides a command-line interface
155 |    - Manages tmux session detection and validation
156 | 
157 | 2. **MacOSNotifyMCP.app** (Swift/macOS)
158 |    - Native macOS application for notifications
159 |    - Handles notification clicks to focus tmux sessions
160 |    - Runs as a background process for each notification
161 | 
162 | ## MacOSNotifyMCP.app
163 | 
164 | The MacOSNotifyMCP.app is bundled with the npm package and is automatically available after installation. No additional setup is required.
165 | 
166 | ## Troubleshooting
167 | 
168 | ### Notifications not appearing
169 | 
170 | 1. Check System Settings → Notifications → MacOSNotifyMCP
171 | 2. Ensure notifications are allowed
172 | 3. Run `macos-notify-mcp -m "test"` to verify
173 | 
174 | ### tmux integration not working
175 | 
176 | 1. Ensure tmux is installed and running
177 | 2. Check session names with `macos-notify-mcp --list-sessions`
178 | 3. Verify terminal app is supported (Alacritty, iTerm2, WezTerm, or Terminal)
179 | 
180 | ## Development
181 | 
182 | ```bash
183 | # Install dependencies
184 | npm install
185 | 
186 | # Build TypeScript
187 | npm run build
188 | 
189 | # Run in development
190 | npm run dev
191 | 
192 | # Lint and format code
193 | npm run lint
194 | npm run format
195 | 
196 | # Build macOS app (only if modifying Swift code)
197 | npm run build-app
198 | ```
199 | 
200 | ## License
201 | 
202 | MIT
203 | 
204 | ## Author
205 | 
206 | Yuki Yano
```

--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------

```markdown
  1 | # CLAUDE.md
  2 | 
  3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
  4 | 
  5 | ## Project Overview
  6 | 
  7 | This is a Model Context Protocol (MCP) server for macOS notifications with tmux integration. The project consists of:
  8 | - A TypeScript-based MCP server and CLI tool
  9 | - A native macOS Swift application (MacOSNotifyMCP.app) that handles notifications
 10 | 
 11 | ## Key Commands
 12 | 
 13 | ### Development
 14 | ```bash
 15 | npm install          # Install dependencies
 16 | npm run build        # Build TypeScript to dist/
 17 | npm run dev          # Run MCP server in watch mode
 18 | npm run build-app    # Build the macOS app bundle (MacOSNotifyMCP.app)
 19 | ```
 20 | 
 21 | ### Linting & Formatting
 22 | ```bash
 23 | npm run lint         # Run biome linter with auto-fix
 24 | npm run format       # Format code with biome
 25 | npm run check        # Check code without modifications
 26 | ```
 27 | 
 28 | ### Testing
 29 | ```bash
 30 | npm test             # Build and test CLI help output
 31 | node dist/cli.js -m "test" --current-tmux  # Test notification with current tmux session
 32 | ```
 33 | 
 34 | ## Architecture
 35 | 
 36 | ### Core Components
 37 | 
 38 | 1. **MCP Server** (`src/index.ts`)
 39 |    - Implements Model Context Protocol server
 40 |    - Provides tools: `send_notification`, `list_tmux_sessions`, `get_current_tmux_info`
 41 |    - Uses StdioServerTransport for communication
 42 | 
 43 | 2. **Notifier Core** (`src/notifier.ts`)
 44 |    - Main notification logic
 45 |    - Searches for MacOSNotifyMCP.app in multiple locations
 46 |    - Handles tmux session detection and validation
 47 |    - Uses `spawn` for subprocess management (not `exec`)
 48 | 
 49 | 3. **CLI Interface** (`src/cli.js`)
 50 |    - Command-line tool for direct notification sending
 51 |    - Argument parsing for tmux session/window/pane
 52 |    - Session validation before sending notifications
 53 | 
 54 | 4. **MacOSNotifyMCP.app** (`MacOSNotifyMCP/main.swift`)
 55 |    - Native macOS app using UserNotifications API
 56 |    - Handles notification clicks to focus tmux sessions
 57 |    - Runs as background process (no Dock icon)
 58 |    - Supports multiple concurrent notifications via `-n` flag
 59 | 
 60 | ### Key Design Decisions
 61 | 
 62 | 1. **Single Notification Method**: All notifications go through MacOSNotifyMCP.app (no osascript fallbacks)
 63 | 2. **App Bundling**: MacOSNotifyMCP.app is included in the npm package, no post-install scripts
 64 | 3. **App Discovery**: MacOSNotifyMCP.app is searched in order:
 65 |    - Package installation directory (primary)
 66 |    - Current working directory (development)
 67 | 4. **Process Management**: Each notification spawns a new MacOSNotifyMCP.app instance
 68 | 5. **Error Handling**: Commands use `spawn` to properly handle arguments with special characters
 69 | 
 70 | ## Important Notes
 71 | 
 72 | - The project uses ES modules (`"type": "module"` in package.json)
 73 | - MacOSNotifyMCP.app is pre-built and included in the npm package
 74 | - The app uses ad-hoc signing
 75 | - Biome is configured for linting/formatting (config embedded in package.json)
 76 | - tmux integration requires tmux to be installed and running
 77 | - The app path is resolved from the npm package installation directory
 78 | 
 79 | ## MCP Tools Usage
 80 | 
 81 | When this project is installed as an MCP server, use these tools for notifications:
 82 | 
 83 | ### Available MCP Tools
 84 | 
 85 | 1. **send_notification** - Send macOS notifications
 86 |    - Required: `message` (string)
 87 |    - Optional: `title`, `sound`, `session`, `window`, `pane`, `useCurrent`
 88 |    - Example: "Send a notification with message 'Build completed'"
 89 | 
 90 | 2. **list_tmux_sessions** - List available tmux sessions
 91 |    - No parameters required
 92 |    - Returns list of active tmux sessions
 93 | 
 94 | 3. **get_current_tmux_info** - Get current tmux location
 95 |    - No parameters required
 96 |    - Returns current session, window, and pane
 97 | 
 98 | ### Usage Examples
 99 | 
100 | When users ask about notifications or tmux, actively use these tools:
101 | 
102 | - "Notify me when done" → Use `send_notification` with appropriate message
103 | - "Send notification to current tmux" → Use `send_notification` with `useCurrent: true`
104 | - "What tmux sessions are available?" → Use `list_tmux_sessions`
105 | - "Where am I in tmux?" → Use `get_current_tmux_info`
106 | 
107 | ### Interactive Patterns
108 | 
109 | When waiting for user input, always send a notification first:
110 | 
111 | 1. **Before asking for confirmation**:
112 |    ```
113 |    send_notification("Build complete. Waiting for deployment confirmation")
114 |    // Then ask: "Deploy to production?"
115 |    ```
116 | 
117 | 2. **When presenting options**:
118 |    ```
119 |    send_notification("Multiple matches found. Please choose in terminal")
120 |    // Then show options
121 |    ```
122 | 
123 | 3. **On errors requiring user decision**:
124 |    ```
125 |    send_notification("Error encountered. Need your input to proceed")
126 |    // Then present error and options
127 |    ```
128 | 
129 | ### Testing Commands
130 | 
131 | For development testing, use these CLI commands:
132 | ```bash
133 | # Test notification directly
134 | node dist/cli.js -m "Test message"
135 | 
136 | # Test with current tmux session
137 | node dist/cli.js -m "Test message" --current-tmux
138 | 
139 | # List tmux sessions
140 | node dist/cli.js --list-sessions
141 | ```
```

--------------------------------------------------------------------------------
/src/mcp-wrapper.cjs:
--------------------------------------------------------------------------------

```
 1 | #!/usr/bin/env node
 2 | 
 3 | // CommonJS wrapper for ES module MCP server
 4 | // This ensures compatibility with npm global installs
 5 | 
 6 | async function main() {
 7 |   try {
 8 |     await import('./index.js')
 9 |   } catch (error) {
10 |     console.error('Failed to start MCP server:', error)
11 |     process.exit(1)
12 |   }
13 | }
14 | 
15 | main()
```

--------------------------------------------------------------------------------
/src/cli-wrapper.cjs:
--------------------------------------------------------------------------------

```
 1 | #!/usr/bin/env node
 2 | 
 3 | // CommonJS wrapper for ES module CLI
 4 | // This ensures compatibility with npm global installs
 5 | 
 6 | async function main() {
 7 |   try {
 8 |     const { main } = await import('./cli.js')
 9 |     await main()
10 |   } catch (error) {
11 |     console.error('Failed to load CLI:', error)
12 |     process.exit(1)
13 |   }
14 | }
15 | 
16 | main()
```

--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { defineConfig } from 'vitest/config'
 2 | 
 3 | export default defineConfig({
 4 |   test: {
 5 |     globals: true,
 6 |     environment: 'node',
 7 |     coverage: {
 8 |       provider: 'v8',
 9 |       reporter: ['text', 'json', 'html'],
10 |       exclude: [
11 |         'node_modules/**',
12 |         'dist/**',
13 |         'MacOSNotifyMCP/**',
14 |         'test/**',
15 |         '**/*.d.ts',
16 |         '**/*.config.*',
17 |         '**/mockData.ts',
18 |         'scripts/**',
19 |       ],
20 |     },
21 |     include: ['test/**/*.test.ts'],
22 |     testTimeout: 10000,
23 |   },
24 | })
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "module": "ESNext",
 5 |     "moduleResolution": "node",
 6 |     "lib": ["ES2022"],
 7 |     "outDir": "./dist",
 8 |     "rootDir": "./src",
 9 |     "strict": true,
10 |     "esModuleInterop": true,
11 |     "skipLibCheck": true,
12 |     "forceConsistentCasingInFileNames": true,
13 |     "declaration": true,
14 |     "declarationMap": true,
15 |     "sourceMap": true,
16 |     "resolveJsonModule": true,
17 |     "allowSyntheticDefaultImports": true
18 |   },
19 |   "include": ["src/**/*"],
20 |   "exclude": ["node_modules", "dist"],
21 |   "ts-node": {
22 |     "compilerOptions": {
23 |       "allowJs": true
24 |     }
25 |   }
26 | }
```

--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
 3 |   "assist": { "actions": { "source": { "organizeImports": "on" } } },
 4 |   "linter": {
 5 |     "enabled": true,
 6 |     "rules": {
 7 |       "recommended": true,
 8 |       "style": {
 9 |         "noParameterAssign": "error",
10 |         "useAsConstAssertion": "error",
11 |         "useDefaultParameterLast": "error",
12 |         "useEnumInitializers": "error",
13 |         "useSelfClosingElements": "error",
14 |         "useSingleVarDeclarator": "error",
15 |         "noUnusedTemplateLiteral": "error",
16 |         "useNumberNamespace": "error",
17 |         "noInferrableTypes": "error",
18 |         "noUselessElse": "error"
19 |       }
20 |     }
21 |   },
22 |   "formatter": {
23 |     "enabled": true,
24 |     "formatWithErrors": false,
25 |     "indentStyle": "space",
26 |     "indentWidth": 2,
27 |     "lineWidth": 80,
28 |     "lineEnding": "lf"
29 |   },
30 |   "javascript": {
31 |     "formatter": {
32 |       "quoteStyle": "single",
33 |       "semicolons": "asNeeded",
34 |       "trailingCommas": "all"
35 |     }
36 |   },
37 |   "files": {
38 |     "includes": [
39 |       "**/src/**/*.ts",
40 |       "**/bin/**/*",
41 |       "!**/node_modules",
42 |       "!**/dist",
43 |       "!**/build",
44 |       "!**/MacOSNotifyMCP"
45 |     ]
46 |   }
47 | }
48 | 
```

--------------------------------------------------------------------------------
/examples/terminal-detection.js:
--------------------------------------------------------------------------------

```javascript
 1 | #!/usr/bin/env node
 2 | 
 3 | import { TmuxNotifier } from '../dist/notifier.js'
 4 | 
 5 | async function demonstrateTerminalDetection() {
 6 |   const notifier = new TmuxNotifier()
 7 |   
 8 |   console.log('Terminal Detection Example')
 9 |   console.log('==========================\n')
10 |   
11 |   // Show environment variables
12 |   console.log('Environment variables:')
13 |   console.log(`  VSCODE_IPC_HOOK_CLI: ${process.env.VSCODE_IPC_HOOK_CLI ? 'Set' : 'Not set'}`)
14 |   console.log(`  TERM_PROGRAM: ${process.env.TERM_PROGRAM || 'Not set'}`)
15 |   console.log(`  TMUX: ${process.env.TMUX ? 'Set' : 'Not set'}\n`)
16 |   
17 |   // Detect terminal
18 |   const terminal = await notifier.getTerminalEmulator()
19 |   console.log(`Detected Terminal: ${terminal}\n`)
20 |   
21 |   // Send notification without terminal info
22 |   console.log('Sending notification without terminal info...')
23 |   await notifier.sendNotification({
24 |     message: 'Standard notification',
25 |     title: 'Test'
26 |   })
27 |   
28 |   // Send notification with terminal info
29 |   console.log('Sending notification with terminal info...')
30 |   await notifier.sendNotification({
31 |     message: 'Notification with terminal detection',
32 |     title: 'Test',
33 |     includeTerminalInfo: true
34 |   })
35 |   
36 |   console.log('\nDone! Check your notifications.')
37 | }
38 | 
39 | demonstrateTerminalDetection().catch(console.error)
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "macos-notify-mcp",
 3 |   "version": "0.0.8",
 4 |   "description": "MCP server for macOS notifications with tmux integration",
 5 |   "keywords": [
 6 |     "mcp",
 7 |     "macos",
 8 |     "notification",
 9 |     "tmux",
10 |     "claude"
11 |   ],
12 |   "author": "Yuki Yano",
13 |   "license": "MIT",
14 |   "repository": {
15 |     "type": "git",
16 |     "url": "https://github.com/yuki-yano/macos-notify-mcp.git"
17 |   },
18 |   "type": "module",
19 |   "main": "dist/index.js",
20 |   "types": "dist/index.d.ts",
21 |   "bin": {
22 |     "macos-notify-cli": "dist/cli-wrapper.cjs",
23 |     "macos-notify-mcp": "dist/mcp-wrapper.cjs"
24 |   },
25 |   "files": [
26 |     "dist",
27 |     "MacOSNotifyMCP"
28 |   ],
29 |   "scripts": {
30 |     "build": "tsc",
31 |     "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",
32 |     "dev": "tsx watch src/index.ts",
33 |     "start": "node dist/index.js",
34 |     "test": "vitest",
35 |     "test:ui": "vitest --ui",
36 |     "test:coverage": "vitest --coverage",
37 |     "test:watch": "vitest --watch",
38 |     "cli:test": "npm run build && node dist/cli.js --help",
39 |     "prepare": "npm run build",
40 |     "build-app": "cd MacOSNotifyMCP && ./build-app.sh",
41 |     "lint": "biome check --write",
42 |     "format": "biome format --write",
43 |     "check": "biome check"
44 |   },
45 |   "dependencies": {
46 |     "@modelcontextprotocol/sdk": "^1.0.4"
47 |   },
48 |   "devDependencies": {
49 |     "@biomejs/biome": "^2.0.0",
50 |     "@types/node": "^22.15.32",
51 |     "@vitest/coverage-v8": "^3.2.4",
52 |     "tsx": "^4.19.2",
53 |     "typescript": "^5.7.3",
54 |     "vitest": "^3.2.4"
55 |   },
56 |   "engines": {
57 |     "node": ">=18.0.0"
58 |   }
59 | }
60 | 
```

--------------------------------------------------------------------------------
/MacOSNotifyMCP/build-app.sh:
--------------------------------------------------------------------------------

```bash
 1 | #!/bin/bash
 2 | # Create complete macOS app bundle
 3 | 
 4 | APP_NAME="MacOSNotifyMCP"
 5 | APP_DIR="${APP_NAME}.app"
 6 | CONTENTS_DIR="${APP_DIR}/Contents"
 7 | MACOS_DIR="${CONTENTS_DIR}/MacOS"
 8 | RESOURCES_DIR="${CONTENTS_DIR}/Resources"
 9 | 
10 | # Create directory structure
11 | mkdir -p "${MACOS_DIR}"
12 | mkdir -p "${RESOURCES_DIR}"
13 | 
14 | # Create Info.plist
15 | cat > "${CONTENTS_DIR}/Info.plist" << EOF
16 | <?xml version="1.0" encoding="UTF-8"?>
17 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18 | <plist version="1.0">
19 | <dict>
20 |     <key>CFBundleIdentifier</key>
21 |     <string>com.macos-notify-mcp.app</string>
22 |     <key>CFBundleName</key>
23 |     <string>MacOSNotifyMCP</string>
24 |     <key>CFBundleDisplayName</key>
25 |     <string>MacOSNotifyMCP</string>
26 |     <key>CFBundleVersion</key>
27 |     <string>1.0</string>
28 |     <key>CFBundleShortVersionString</key>
29 |     <string>1.0</string>
30 |     <key>CFBundleExecutable</key>
31 |     <string>MacOSNotifyMCP</string>
32 |     <key>CFBundlePackageType</key>
33 |     <string>APPL</string>
34 |     <key>LSMinimumSystemVersion</key>
35 |     <string>10.15</string>
36 |     <key>NSUserNotificationAlertStyle</key>
37 |     <string>alert</string>
38 |     <key>CFBundleInfoDictionaryVersion</key>
39 |     <string>6.0</string>
40 |     <key>NSSupportsAutomaticTermination</key>
41 |     <true/>
42 |     <key>NSAppTransportSecurity</key>
43 |     <dict>
44 |         <key>NSAllowsArbitraryLoads</key>
45 |         <true/>
46 |     </dict>
47 |     <key>CFBundleIconFile</key>
48 |     <string>MacOSNotifyMCP</string>
49 |     <key>LSUIElement</key>
50 |     <true/>
51 | </dict>
52 | </plist>
53 | EOF
54 | 
55 | # Copy icon file to Resources directory
56 | if [ -f "${APP_NAME}.icns" ]; then
57 |     cp "${APP_NAME}.icns" "${RESOURCES_DIR}/"
58 |     echo "Icon file copied: ${APP_NAME}.icns"
59 | else
60 |     echo "Warning: Icon file ${APP_NAME}.icns not found"
61 | fi
62 | 
63 | # Compile Swift binary and place it in the app
64 | swiftc -o "${MACOS_DIR}/${APP_NAME}" main.swift
65 | 
66 | # Add signature to app (ad-hoc signing)
67 | echo "Signing app..."
68 | codesign --force --deep --sign - "${APP_DIR}"
69 | 
70 | echo "App bundle created: ${APP_DIR}"
71 | echo "Usage: open ${APP_DIR} --args -m \"Test message\""
72 | echo "Bundle ID: com.macos-notify-mcp.app"
```

--------------------------------------------------------------------------------
/macos-notify-mcp-icon.svg:
--------------------------------------------------------------------------------

```
 1 | <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
 2 |   <!-- Background - macOS style rounded square -->
 3 |   <defs>
 4 |     <!-- Gradient for background -->
 5 |     <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
 6 |       <stop offset="0%" style="stop-color:#007AFF;stop-opacity:1" />
 7 |       <stop offset="100%" style="stop-color:#0051D5;stop-opacity:1" />
 8 |     </linearGradient>
 9 |     
10 |     <!-- Shadow filter -->
11 |     <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
12 |       <feGaussianBlur in="SourceAlpha" stdDeviation="20"/>
13 |       <feOffset dx="0" dy="10" result="offsetblur"/>
14 |       <feFlood flood-color="#000000" flood-opacity="0.25"/>
15 |       <feComposite in2="offsetblur" operator="in"/>
16 |       <feMerge>
17 |         <feMergeNode/>
18 |         <feMergeNode in="SourceGraphic"/>
19 |       </feMerge>
20 |     </filter>
21 |     
22 |     <!-- Glow effect -->
23 |     <filter id="glow">
24 |       <feGaussianBlur stdDeviation="4" result="coloredBlur"/>
25 |       <feMerge>
26 |         <feMergeNode in="coloredBlur"/>
27 |         <feMergeNode in="SourceGraphic"/>
28 |       </feMerge>
29 |     </filter>
30 |   </defs>
31 |   
32 |   <!-- App icon background -->
33 |   <rect x="64" y="64" width="896" height="896" rx="200" ry="200" 
34 |         fill="url(#bgGradient)" filter="url(#shadow)"/>
35 |   
36 |   <!-- Main bell shape -->
37 |   <g transform="translate(512, 440)">
38 |     <!-- Bell body -->
39 |     <path d="M-180,-140 C-180,-260 -100,-340 0,-340 C100,-340 180,-260 180,-140 
40 |              L180,40 C180,80 160,100 160,100 L-160,100 C-160,100 -180,80 -180,40 Z" 
41 |           fill="#FFFFFF" opacity="0.95"/>
42 |     
43 |     <!-- Bell clapper -->
44 |     <circle cx="0" cy="140" r="40" fill="#FFFFFF" opacity="0.95"/>
45 |     
46 |     <!-- Notification indicator -->
47 |     <circle cx="140" cy="-120" r="60" fill="#FF3B30" filter="url(#glow)"/>
48 |   </g>
49 |   
50 |   <!-- Terminal/Code brackets to represent tmux -->
51 |   <g transform="translate(512, 720)">
52 |     <!-- Left bracket -->
53 |     <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" 
54 |           fill="#FFFFFF" opacity="0.7"/>
55 |     
56 |     <!-- Right bracket -->
57 |     <path d="M240,-80 L200,-80 L200,-60 L220,-60 L220,60 L200,60 L200,80 L240,80 L240,-80 Z" 
58 |           fill="#FFFFFF" opacity="0.7"/>
59 |     
60 |     <!-- Underscore cursor -->
61 |     <rect x="-40" y="40" width="80" height="20" rx="10" fill="#00FF00" opacity="0.9"/>
62 |   </g>
63 |   
64 |   <!-- AI circuit dots -->
65 |   <g transform="translate(512, 512)">
66 |     <!-- Connection lines -->
67 |     <circle cx="-320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/>
68 |     <circle cx="320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/>
69 |     <circle cx="0" cy="-320" r="20" fill="#00D4FF" opacity="0.6"/>
70 |     <circle cx="0" cy="320" r="20" fill="#00D4FF" opacity="0.6"/>
71 |     
72 |     <!-- Diagonal dots -->
73 |     <circle cx="-220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/>
74 |     <circle cx="220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/>
75 |     <circle cx="-220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/>
76 |     <circle cx="220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/>
77 |   </g>
78 | </svg>
```

--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { TmuxNotifier } from './notifier.js'
  4 | 
  5 | interface CliOptions {
  6 |   message: string
  7 |   title?: string
  8 |   sound?: string
  9 |   session?: string
 10 |   window?: string
 11 |   pane?: string
 12 | }
 13 | 
 14 | export async function main() {
 15 |   const notifier = new TmuxNotifier()
 16 | 
 17 |   // Parse command line arguments
 18 |   const args = process.argv.slice(2)
 19 | 
 20 |   if (args.includes('--help') || args.includes('-h')) {
 21 |     console.log(`
 22 | Usage:
 23 |   macos-notify-cli [options]
 24 | 
 25 | Options:
 26 |   -m, --message <text>    Notification message (required)
 27 |   -t, --title <text>      Notification title (default: git repository name)
 28 |   -s, --session <name>    tmux session name
 29 |   -w, --window <number>   tmux window number
 30 |   -p, --pane <number>     tmux pane number
 31 |   --sound <name>          Notification sound (default: "Glass")
 32 |   --current-tmux          Use current tmux location
 33 |   --list-sessions         List available tmux sessions
 34 |   --detect-terminal       Detect and display the current terminal emulator
 35 |   -h, --help              Show this help message
 36 | 
 37 | Examples:
 38 |   # Basic notification
 39 |   macos-notify-cli -m "Build completed"
 40 |   
 41 |   # Navigate to specific session
 42 |   macos-notify-cli -m "Tests passed" -s development -w 1 -p 0
 43 |   
 44 |   # Use current tmux location
 45 |   macos-notify-cli -m "Task finished" --current-tmux
 46 |     `)
 47 |     process.exit(0)
 48 |   }
 49 | 
 50 |   if (args.includes('--list-sessions')) {
 51 |     const sessions = await notifier.listSessions()
 52 |     console.log('Available tmux sessions:')
 53 |     sessions.forEach((s) => console.log(`  ${s}`))
 54 |     process.exit(0)
 55 |   }
 56 | 
 57 |   if (args.includes('--detect-terminal')) {
 58 |     const terminal = await notifier.getTerminalEmulator()
 59 |     console.log(`Detected terminal: ${terminal}`)
 60 |     process.exit(0)
 61 |   }
 62 | 
 63 |   // Parse arguments
 64 |   const options: CliOptions = {
 65 |     message: '',
 66 |   }
 67 | 
 68 |   for (let i = 0; i < args.length; i++) {
 69 |     switch (args[i]) {
 70 |       case '-m':
 71 |       case '--message':
 72 |         options.message = args[++i]
 73 |         break
 74 |       case '-t':
 75 |       case '--title':
 76 |         options.title = args[++i]
 77 |         break
 78 |       case '-s':
 79 |       case '--session':
 80 |         options.session = args[++i]
 81 |         break
 82 |       case '-w':
 83 |       case '--window':
 84 |         options.window = args[++i]
 85 |         break
 86 |       case '-p':
 87 |       case '--pane':
 88 |         options.pane = args[++i]
 89 |         break
 90 |       case '--sound':
 91 |         options.sound = args[++i]
 92 |         break
 93 |       case '--current-tmux': {
 94 |         const current = await notifier.getCurrentTmuxInfo()
 95 |         if (current) {
 96 |           options.session = current.session
 97 |           options.window = current.window
 98 |           options.pane = current.pane
 99 |         } else {
100 |           console.error('Error: Not in a tmux session')
101 |           process.exit(1)
102 |         }
103 |         break
104 |       }
105 |     }
106 |   }
107 | 
108 |   if (!options.message) {
109 |     console.error('Error: Message is required (-m option)')
110 |     process.exit(1)
111 |   }
112 | 
113 |   // Check if session exists
114 |   if (options.session) {
115 |     const exists = await notifier.sessionExists(options.session)
116 |     if (!exists) {
117 |       console.error(`Error: Session '${options.session}' does not exist`)
118 |       const sessions = await notifier.listSessions()
119 |       if (sessions.length > 0) {
120 |         console.log('\nAvailable sessions:')
121 |         sessions.forEach((s) => console.log(`  ${s}`))
122 |       }
123 |       process.exit(1)
124 |     }
125 |   }
126 | 
127 |   // Send notification
128 |   try {
129 |     await notifier.sendNotification(options)
130 |     console.log('Notification sent successfully')
131 |     process.exit(0)
132 |   } catch (error) {
133 |     console.error('Failed to send notification:', error)
134 |     process.exit(1)
135 |   }
136 | }
137 | 
138 | // Export for testing (already exported as function declaration)
139 | 
140 | // Only run if called directly (not when imported as a module)
141 | if (import.meta.url === `file://${process.argv[1]}`) {
142 |   main().catch((error) => {
143 |     console.error('Unexpected error:', error)
144 |     process.exit(1)
145 |   })
146 | }
147 | 
```

--------------------------------------------------------------------------------
/examples/tmux-client-tracking.sh:
--------------------------------------------------------------------------------

```bash
  1 | #!/bin/bash
  2 | 
  3 | # tmux client tracking example
  4 | # This script demonstrates how to track which terminal emulator is attached to tmux
  5 | 
  6 | # Method 1: Setup tmux hooks to track client information
  7 | setup_tmux_hooks() {
  8 |     # Track when clients attach
  9 |     tmux set-hook -g client-attached 'run-shell "echo \"Client attached: #{client_tty} at $(date)\" >> ~/.tmux-client.log"'
 10 |     
 11 |     # Track when clients change sessions
 12 |     tmux set-hook -g client-session-changed 'run-shell "echo \"Client #{client_tty} switched to #{session_name} at $(date)\" >> ~/.tmux-client.log"'
 13 |     
 14 |     # Track client activity
 15 |     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"'
 16 | }
 17 | 
 18 | # Method 2: Get terminal info from active client
 19 | get_active_client_terminal() {
 20 |     # Get the most recently active client
 21 |     local active_client=$(tmux list-clients -F '#{client_tty}:#{client_activity}' | sort -t: -k2 -nr | head -1 | cut -d: -f1)
 22 |     
 23 |     if [ -n "$active_client" ]; then
 24 |         echo "Active client TTY: $active_client"
 25 |         
 26 |         # Try to find the terminal process
 27 |         if command -v lsof >/dev/null 2>&1; then
 28 |             echo "Processes using TTY:"
 29 |             lsof "$active_client" 2>/dev/null | grep -E '(Cursor|Code|iTerm|Terminal|alacritty)' | head -5
 30 |         fi
 31 |         
 32 |         # Alternative: Check parent processes
 33 |         local pids=$(ps aux | grep "$active_client" | grep -v grep | awk '{print $2}')
 34 |         for pid in $pids; do
 35 |             if [ -f "/proc/$pid/environ" ]; then
 36 |                 echo "Environment for PID $pid:"
 37 |                 tr '\0' '\n' < "/proc/$pid/environ" | grep -E '(TERM_PROGRAM|CURSOR_TRACE_ID|VSCODE_IPC_HOOK_CLI|ALACRITTY)'
 38 |             fi
 39 |         done
 40 |     fi
 41 | }
 42 | 
 43 | # Method 3: Store client info in tmux environment
 44 | track_client_terminal() {
 45 |     # This would be called when Claude Code starts
 46 |     local terminal_type="Unknown"
 47 |     
 48 |     # Detect terminal type
 49 |     if [ -n "$CURSOR_TRACE_ID" ]; then
 50 |         terminal_type="Cursor"
 51 |     elif [ -n "$VSCODE_IPC_HOOK_CLI" ]; then
 52 |         terminal_type="VSCode"
 53 |     elif [ "$TERM_PROGRAM" = "iTerm.app" ]; then
 54 |         terminal_type="iTerm2"
 55 |     elif [ "$TERM_PROGRAM" = "Apple_Terminal" ]; then
 56 |         terminal_type="Terminal"
 57 |     elif [ -n "$ALACRITTY_WINDOW_ID" ]; then
 58 |         terminal_type="alacritty"
 59 |     fi
 60 |     
 61 |     # Store in tmux environment
 62 |     tmux set-environment -g "CLAUDE_CODE_TERMINAL_${TMUX_PANE}" "$terminal_type"
 63 |     tmux set-environment -g "CLAUDE_CODE_STARTED_$(date +%s)" "$terminal_type:$TMUX_PANE"
 64 | }
 65 | 
 66 | # Method 4: Real-time client detection
 67 | detect_current_client() {
 68 |     # Get current pane
 69 |     local current_pane=$(tmux display-message -p '#{pane_id}')
 70 |     
 71 |     # Find which client is currently viewing this pane
 72 |     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
 73 |         # Check if this client is viewing our pane
 74 |         local client_pane=$(tmux display-message -t "$tty" -p '#{pane_id}' 2>/dev/null)
 75 |         if [ "$client_pane" = "$current_pane" ]; then
 76 |             echo "Client $tty is viewing this pane"
 77 |             # Now detect terminal from this TTY
 78 |         fi
 79 |     done
 80 | }
 81 | 
 82 | # Example usage
 83 | case "${1:-}" in
 84 |     "setup")
 85 |         setup_tmux_hooks
 86 |         echo "tmux hooks configured"
 87 |         ;;
 88 |     "track")
 89 |         track_client_terminal
 90 |         echo "Terminal tracked: $(tmux show-environment -g | grep CLAUDE_CODE_TERMINAL)"
 91 |         ;;
 92 |     "detect")
 93 |         get_active_client_terminal
 94 |         ;;
 95 |     "current")
 96 |         detect_current_client
 97 |         ;;
 98 |     *)
 99 |         echo "Usage: $0 {setup|track|detect|current}"
100 |         echo "  setup   - Configure tmux hooks for client tracking"
101 |         echo "  track   - Track current terminal in tmux environment"
102 |         echo "  detect  - Detect active client's terminal"
103 |         echo "  current - Detect which client is viewing current pane"
104 |         ;;
105 | esac
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { readFileSync } from 'node:fs'
  2 | import { dirname, join } from 'node:path'
  3 | import { fileURLToPath } from 'node:url'
  4 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'
  5 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
  6 | import {
  7 |   CallToolRequestSchema,
  8 |   ListToolsRequestSchema,
  9 | } from '@modelcontextprotocol/sdk/types.js'
 10 | import { TmuxNotifier } from './notifier.js'
 11 | 
 12 | interface NotificationOptions {
 13 |   message: string
 14 |   title?: string
 15 |   sound?: string
 16 |   session?: string
 17 |   window?: string
 18 |   pane?: string
 19 | }
 20 | 
 21 | // Get version from package.json
 22 | const __dirname = dirname(fileURLToPath(import.meta.url))
 23 | const packageJson = JSON.parse(
 24 |   readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'),
 25 | )
 26 | 
 27 | const server = new Server(
 28 |   {
 29 |     name: 'macos-notify-mcp',
 30 |     version: packageJson.version,
 31 |   },
 32 |   {
 33 |     capabilities: {
 34 |       tools: {},
 35 |     },
 36 |   },
 37 | )
 38 | 
 39 | const notifier = new TmuxNotifier()
 40 | 
 41 | // List available tools
 42 | server.setRequestHandler(ListToolsRequestSchema, async () => {
 43 |   return {
 44 |     tools: [
 45 |       {
 46 |         name: 'send_notification',
 47 |         description: 'Send a macOS notification with optional tmux integration',
 48 |         inputSchema: {
 49 |           type: 'object',
 50 |           properties: {
 51 |             message: {
 52 |               type: 'string',
 53 |               description: 'The notification message',
 54 |             },
 55 |             title: {
 56 |               type: 'string',
 57 |               description: 'The notification title (default: "Claude Code")',
 58 |             },
 59 |             sound: {
 60 |               type: 'string',
 61 |               description: 'The notification sound (default: "Glass")',
 62 |             },
 63 |             session: {
 64 |               type: 'string',
 65 |               description: 'tmux session name',
 66 |             },
 67 |             window: {
 68 |               type: 'string',
 69 |               description: 'tmux window number',
 70 |             },
 71 |             pane: {
 72 |               type: 'string',
 73 |               description: 'tmux pane number',
 74 |             },
 75 |             useCurrent: {
 76 |               type: 'boolean',
 77 |               description: 'Use current tmux location',
 78 |             },
 79 |           },
 80 |           required: ['message'],
 81 |         },
 82 |       },
 83 |       {
 84 |         name: 'list_tmux_sessions',
 85 |         description: 'List available tmux sessions',
 86 |         inputSchema: {
 87 |           type: 'object',
 88 |           properties: {},
 89 |         },
 90 |       },
 91 |       {
 92 |         name: 'get_current_tmux_info',
 93 |         description: 'Get current tmux session information',
 94 |         inputSchema: {
 95 |           type: 'object',
 96 |           properties: {},
 97 |         },
 98 |       },
 99 |     ],
100 |   }
101 | })
102 | 
103 | // Handle tool calls
104 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
105 |   const { name, arguments: args } = request.params
106 | 
107 |   if (!args) {
108 |     throw new Error('No arguments provided')
109 |   }
110 | 
111 |   try {
112 |     switch (name) {
113 |       case 'send_notification': {
114 |         // Safely extract properties from args
115 |         const notificationArgs = args as Record<string, unknown>
116 | 
117 |         // Validate message is provided
118 |         if (!notificationArgs.message) {
119 |           throw new Error('Message is required')
120 |         }
121 | 
122 |         const options: NotificationOptions = {
123 |           message: String(notificationArgs.message),
124 |           title: notificationArgs.title
125 |             ? String(notificationArgs.title)
126 |             : undefined,
127 |           sound: notificationArgs.sound
128 |             ? String(notificationArgs.sound)
129 |             : undefined,
130 |         }
131 | 
132 |         if (notificationArgs.useCurrent) {
133 |           const current = await notifier.getCurrentTmuxInfo()
134 |           if (current) {
135 |             options.session = current.session
136 |             options.window = current.window
137 |             options.pane = current.pane
138 |           }
139 |         } else {
140 |           if (notificationArgs.session)
141 |             options.session = String(notificationArgs.session)
142 |           if (notificationArgs.window)
143 |             options.window = String(notificationArgs.window)
144 |           if (notificationArgs.pane)
145 |             options.pane = String(notificationArgs.pane)
146 |         }
147 | 
148 |         // Validate session if specified
149 |         if (options.session) {
150 |           const exists = await notifier.sessionExists(options.session)
151 |           if (!exists) {
152 |             const sessions = await notifier.listSessions()
153 |             return {
154 |               content: [
155 |                 {
156 |                   type: 'text',
157 |                   text: `Error: Session '${options.session}' does not exist. Available sessions: ${sessions.join(', ')}`,
158 |                 },
159 |               ],
160 |             }
161 |           }
162 |         }
163 | 
164 |         await notifier.sendNotification(options)
165 | 
166 |         return {
167 |           content: [
168 |             {
169 |               type: 'text',
170 |               text: `Notification sent: "${options.message}"${options.session ? ` (tmux: ${options.session})` : ''}`,
171 |             },
172 |           ],
173 |         }
174 |       }
175 | 
176 |       case 'list_tmux_sessions': {
177 |         const sessions = await notifier.listSessions()
178 |         return {
179 |           content: [
180 |             {
181 |               type: 'text',
182 |               text:
183 |                 sessions.length > 0
184 |                   ? `Available tmux sessions:\n${sessions.map((s) => `- ${s}`).join('\n')}`
185 |                   : 'No tmux sessions found',
186 |             },
187 |           ],
188 |         }
189 |       }
190 | 
191 |       case 'get_current_tmux_info': {
192 |         const info = await notifier.getCurrentTmuxInfo()
193 |         if (info) {
194 |           return {
195 |             content: [
196 |               {
197 |                 type: 'text',
198 |                 text: `Current tmux location:\n- Session: ${info.session}\n- Window: ${info.window}\n- Pane: ${info.pane}`,
199 |               },
200 |             ],
201 |           }
202 |         }
203 |         return {
204 |           content: [
205 |             {
206 |               type: 'text',
207 |               text: 'Not in a tmux session',
208 |             },
209 |           ],
210 |         }
211 |       }
212 | 
213 |       default:
214 |         throw new Error(`Unknown tool: ${name}`)
215 |     }
216 |   } catch (error) {
217 |     return {
218 |       content: [
219 |         {
220 |           type: 'text',
221 |           text: `Error: ${error instanceof Error ? error.message : String(error)}`,
222 |         },
223 |       ],
224 |     }
225 |   }
226 | })
227 | 
228 | // Start the server
229 | async function main() {
230 |   const transport = new StdioServerTransport()
231 |   await server.connect(transport)
232 |   console.error('macOS Notify MCP server started')
233 | }
234 | 
235 | main().catch((error) => {
236 |   console.error('Server error:', error)
237 |   process.exit(1)
238 | })
239 | 
```

--------------------------------------------------------------------------------
/test/cli.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2 | 
  3 | // Mock the notifier module before importing cli
  4 | let mockNotifier: any
  5 | 
  6 | vi.mock('../src/notifier.js', () => ({
  7 |   TmuxNotifier: vi.fn(() => mockNotifier),
  8 | }))
  9 | 
 10 | describe('CLI', () => {
 11 |   let originalArgv: string[]
 12 |   let originalExit: typeof process.exit
 13 |   let exitCode: number | undefined
 14 |   let consoleLogSpy: ReturnType<typeof vi.spyOn>
 15 |   let consoleErrorSpy: ReturnType<typeof vi.spyOn>
 16 | 
 17 |   beforeEach(() => {
 18 |     // Save original values
 19 |     originalArgv = process.argv
 20 |     originalExit = process.exit
 21 | 
 22 |     // Mock process.exit
 23 |     exitCode = undefined
 24 |     process.exit = ((code?: number) => {
 25 |       exitCode = code
 26 |       throw new Error(`Process.exit(${code})`)
 27 |     }) as never
 28 | 
 29 |     // Mock console methods
 30 |     consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
 31 |     consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
 32 | 
 33 |     // Reset module cache
 34 |     vi.resetModules()
 35 | 
 36 |     // Create mock notifier
 37 |     mockNotifier = {
 38 |       sendNotification: vi.fn().mockResolvedValue(undefined),
 39 |       listSessions: vi.fn().mockResolvedValue(['session1', 'session2']),
 40 |       sessionExists: vi.fn().mockResolvedValue(true),
 41 |       getCurrentTmuxInfo: vi
 42 |         .fn()
 43 |         .mockResolvedValue({ session: 'current', window: '1', pane: '0' }),
 44 |     }
 45 |   })
 46 | 
 47 |   afterEach(() => {
 48 |     // Restore original values
 49 |     process.argv = originalArgv
 50 |     process.exit = originalExit
 51 |     consoleLogSpy.mockRestore()
 52 |     consoleErrorSpy.mockRestore()
 53 |     vi.clearAllMocks()
 54 |   })
 55 | 
 56 |   async function runCli() {
 57 |     // Import fresh copy and get main function
 58 |     const cliModule = await import('../src/cli.js')
 59 |     const main = (cliModule as any).main || cliModule.default
 60 | 
 61 |     if (typeof main === 'function') {
 62 |       try {
 63 |         await main()
 64 |       } catch (e: any) {
 65 |         if (!e.message.startsWith('Process.exit')) {
 66 |           throw e
 67 |         }
 68 |       }
 69 |     }
 70 |   }
 71 | 
 72 |   describe('--help flag', () => {
 73 |     it('should display help message with --help', async () => {
 74 |       process.argv = ['node', 'cli.js', '--help']
 75 | 
 76 |       await runCli()
 77 | 
 78 |       expect(consoleLogSpy).toHaveBeenCalled()
 79 |       const output = consoleLogSpy.mock.calls.join('\n')
 80 |       expect(output).toContain('Usage:')
 81 |       expect(output).toContain('Options:')
 82 |       expect(output).toContain('--help')
 83 |       expect(output).toContain('--list-sessions')
 84 |       expect(exitCode).toBe(0)
 85 |     })
 86 | 
 87 |     it('should display help message with -h', async () => {
 88 |       process.argv = ['node', 'cli.js', '-h']
 89 | 
 90 |       await runCli()
 91 | 
 92 |       expect(consoleLogSpy).toHaveBeenCalled()
 93 |       expect(exitCode).toBe(0)
 94 |     })
 95 |   })
 96 | 
 97 |   describe('--list-sessions', () => {
 98 |     it('should list tmux sessions', async () => {
 99 |       process.argv = ['node', 'cli.js', '--list-sessions']
100 | 
101 |       await runCli()
102 | 
103 |       expect(mockNotifier.listSessions).toHaveBeenCalled()
104 |       expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
105 |       expect(consoleLogSpy).toHaveBeenCalledWith('  session1')
106 |       expect(consoleLogSpy).toHaveBeenCalledWith('  session2')
107 |       expect(exitCode).toBe(0)
108 |     })
109 | 
110 |     it('should handle no sessions gracefully', async () => {
111 |       mockNotifier.listSessions.mockResolvedValue([])
112 |       process.argv = ['node', 'cli.js', '--list-sessions']
113 | 
114 |       await runCli()
115 | 
116 |       expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
117 |       expect(exitCode).toBe(0)
118 |     })
119 |   })
120 | 
121 |   describe('notification sending', () => {
122 |     it.skip('should send basic notification with message', async () => {
123 |       process.argv = ['node', 'cli.js', '-m', 'Hello World']
124 | 
125 |       await runCli()
126 | 
127 |       // Check what errors were logged
128 |       if (consoleErrorSpy.mock.calls.length > 0) {
129 |         console.log('Console errors found:', consoleErrorSpy.mock.calls)
130 |       }
131 | 
132 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
133 |         message: 'Hello World',
134 |       })
135 |       expect(consoleLogSpy).toHaveBeenCalledWith('Notification sent successfully')
136 |       expect(consoleErrorSpy).not.toHaveBeenCalled()
137 |       expect(exitCode).toBe(0)
138 |     })
139 | 
140 |     it('should send notification with title', async () => {
141 |       process.argv = [
142 |         'node',
143 |         'cli.js',
144 |         '-m',
145 |         'Test message',
146 |         '-t',
147 |         'Test Title',
148 |       ]
149 | 
150 |       await runCli()
151 | 
152 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
153 |         message: 'Test message',
154 |         title: 'Test Title',
155 |       })
156 |     })
157 | 
158 |     it('should send notification with sound', async () => {
159 |       process.argv = [
160 |         'node',
161 |         'cli.js',
162 |         '-m',
163 |         'Alert!',
164 |         '--sound',
165 |         'Glass',
166 |       ]
167 | 
168 |       await runCli()
169 | 
170 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
171 |         message: 'Alert!',
172 |         sound: 'Glass',
173 |       })
174 |     })
175 | 
176 |     it('should send notification to specific tmux location', async () => {
177 |       process.argv = [
178 |         'node',
179 |         'cli.js',
180 |         '-m',
181 |         'Tmux notification',
182 |         '-s',
183 |         'my-session',
184 |         '-w',
185 |         '2',
186 |         '-p',
187 |         '1',
188 |       ]
189 | 
190 |       await runCli()
191 | 
192 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
193 |         message: 'Tmux notification',
194 |         session: 'my-session',
195 |         window: '2',
196 |         pane: '1',
197 |       })
198 |     })
199 | 
200 |     it('should use current tmux location with --current-tmux flag', async () => {
201 |       process.argv = ['node', 'cli.js', '-m', 'Current location', '--current-tmux']
202 | 
203 |       await runCli()
204 | 
205 |       expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled()
206 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
207 |         message: 'Current location',
208 |         session: 'current',
209 |         window: '1',
210 |         pane: '0',
211 |       })
212 |     })
213 | 
214 |     it('should handle missing current tmux info', async () => {
215 |       mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null)
216 |       process.argv = ['node', 'cli.js', '-m', 'Test', '--current-tmux']
217 | 
218 |       await runCli()
219 | 
220 |       // When --current returns null, it should exit with error
221 |       expect(exitCode).toBe(1)
222 |       expect(mockNotifier.sendNotification).not.toHaveBeenCalled()
223 |       expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Not in a tmux session')
224 |     })
225 | 
226 |     it('should validate session exists', async () => {
227 |       mockNotifier.sessionExists.mockResolvedValue(false)
228 |       process.argv = [
229 |         'node',
230 |         'cli.js',
231 |         '-m',
232 |         'Test',
233 |         '-s',
234 |         'nonexistent',
235 |       ]
236 | 
237 |       await runCli()
238 | 
239 |       expect(mockNotifier.sessionExists).toHaveBeenCalledWith('nonexistent')
240 |       expect(consoleErrorSpy).toHaveBeenCalledWith(
241 |         "Error: Session 'nonexistent' does not exist",
242 |       )
243 |       expect(exitCode).toBe(1)
244 |     })
245 |   })
246 | 
247 |   describe('argument parsing', () => {
248 |     it('should handle long form arguments', async () => {
249 |       process.argv = [
250 |         'node',
251 |         'cli.js',
252 |         '--message',
253 |         'Long form',
254 |         '--title',
255 |         'Title',
256 |         '--session',
257 |         'session1',
258 |         '--window',
259 |         '1',
260 |         '--pane',
261 |         '0',
262 |       ]
263 | 
264 |       await runCli()
265 | 
266 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
267 |         message: 'Long form',
268 |         title: 'Title',
269 |         session: 'session1',
270 |         window: '1',
271 |         pane: '0',
272 |       })
273 |     })
274 | 
275 |     it('should error when no message provided', async () => {
276 |       process.argv = ['node', 'cli.js']
277 | 
278 |       await runCli()
279 | 
280 |       expect(consoleErrorSpy).toHaveBeenCalledWith(
281 |         'Error: Message is required (-m option)',
282 |       )
283 |       expect(exitCode).toBe(1)
284 |     })
285 | 
286 |     it('should handle spaces in arguments', async () => {
287 |       process.argv = [
288 |         'node',
289 |         'cli.js',
290 |         '-m',
291 |         'Message with spaces',
292 |         '-t',
293 |         'Title with spaces',
294 |       ]
295 | 
296 |       await runCli()
297 | 
298 |       expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
299 |         message: 'Message with spaces',
300 |         title: 'Title with spaces',
301 |       })
302 |     })
303 |   })
304 | 
305 |   describe('error handling', () => {
306 |     it('should handle notification sending errors', async () => {
307 |       const error = new Error('Failed to send notification')
308 |       mockNotifier.sendNotification.mockRejectedValue(error)
309 |       process.argv = ['node', 'cli.js', '-m', 'Test']
310 | 
311 |       await runCli()
312 | 
313 |       expect(consoleErrorSpy).toHaveBeenCalledWith(
314 |         'Failed to send notification:',
315 |         error,
316 |       )
317 |       expect(exitCode).toBe(1)
318 |     })
319 | 
320 |     it('should handle list sessions errors', async () => {
321 |       // The notifier catches errors and returns empty array
322 |       mockNotifier.listSessions.mockResolvedValue([])
323 |       process.argv = ['node', 'cli.js', '--list-sessions']
324 | 
325 |       await runCli()
326 | 
327 |       expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
328 |       expect(exitCode).toBe(0)
329 |     })
330 |   })
331 | })
```

--------------------------------------------------------------------------------
/MacOSNotifyMCP/main.swift:
--------------------------------------------------------------------------------

```swift
  1 | #!/usr/bin/env swift
  2 | 
  3 | import Foundation
  4 | import UserNotifications
  5 | import Cocoa
  6 | 
  7 | class MacOSNotifyMCP: NSObject, UNUserNotificationCenterDelegate {
  8 |     private let center = UNUserNotificationCenter.current()
  9 |     
 10 |     override init() {
 11 |         super.init()
 12 |         center.delegate = self
 13 |     }
 14 |     
 15 |     func requestPermissionAndSendNotification(
 16 |         title: String, 
 17 |         message: String, 
 18 |         sound: String = "default",
 19 |         session: String? = nil,
 20 |         window: String? = nil,
 21 |         pane: String? = nil,
 22 |         terminal: String? = nil
 23 |     ) {
 24 |         center.requestAuthorization(options: [.alert, .sound]) { granted, error in
 25 |             if granted {
 26 |                 self.sendNotification(
 27 |                     title: title,
 28 |                     message: message,
 29 |                     sound: sound,
 30 |                     session: session,
 31 |                     window: window,
 32 |                     pane: pane,
 33 |                     terminal: terminal
 34 |                 )
 35 |             } else {
 36 |                 print("Notification permission denied")
 37 |                 exit(1)
 38 |             }
 39 |         }
 40 |     }
 41 |     
 42 |     private func sendNotification(
 43 |         title: String,
 44 |         message: String,
 45 |         sound: String,
 46 |         session: String?,
 47 |         window: String?,
 48 |         pane: String?,
 49 |         terminal: String?
 50 |     ) {
 51 |         let content = UNMutableNotificationContent()
 52 |         content.title = title
 53 |         content.body = message
 54 |         if sound == "default" {
 55 |             content.sound = .default
 56 |         } else {
 57 |             content.sound = UNNotificationSound(named: UNNotificationSoundName(sound + ".aiff"))
 58 |         }
 59 |         
 60 |         // tmux情報とターミナル情報をuserInfoに格納
 61 |         var userInfo: [String: Any] = [:]
 62 |         if let session = session {
 63 |             userInfo["session"] = session
 64 |             if let window = window {
 65 |                 userInfo["window"] = window
 66 |             }
 67 |             if let pane = pane {
 68 |                 userInfo["pane"] = pane
 69 |             }
 70 |         }
 71 |         if let terminal = terminal {
 72 |             userInfo["terminal"] = terminal
 73 |         }
 74 |         content.userInfo = userInfo
 75 |         
 76 |         let request = UNNotificationRequest(
 77 |             identifier: UUID().uuidString,
 78 |             content: content,
 79 |             trigger: nil
 80 |         )
 81 |         
 82 |         center.add(request) { error in
 83 |             if let error = error {
 84 |                 print("Notification error: \(error)")
 85 |                 exit(1)
 86 |             }
 87 |             print("Notification sent")
 88 |         }
 89 |     }
 90 |     
 91 |     // Handle notification click
 92 |     func userNotificationCenter(
 93 |         _ center: UNUserNotificationCenter,
 94 |         didReceive response: UNNotificationResponse,
 95 |         withCompletionHandler completionHandler: @escaping () -> Void
 96 |     ) {
 97 |         let userInfo = response.notification.request.content.userInfo
 98 |         let terminal = userInfo["terminal"] as? String
 99 |         
100 |         if let session = userInfo["session"] as? String {
101 |             focusToTmux(
102 |                 session: session,
103 |                 window: userInfo["window"] as? String,
104 |                 pane: userInfo["pane"] as? String,
105 |                 terminal: terminal
106 |             )
107 |         } else if let terminal = terminal {
108 |             // tmuxセッションがない場合でもターミナルをアクティブ化
109 |             activateTerminal(preferredTerminal: terminal)
110 |         }
111 |         
112 |         completionHandler()
113 |         
114 |         // Exit after handling click
115 |         DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
116 |             NSApplication.shared.terminate(nil)
117 |         }
118 |     }
119 |     
120 |     private func focusToTmux(session: String, window: String?, pane: String?, terminal: String?) {
121 |         // Activate terminal
122 |         activateTerminal(preferredTerminal: terminal)
123 |         
124 |         // Execute tmux commands
125 |         DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
126 |             let tmuxPath = self.findTmuxPath()
127 |             guard !tmuxPath.isEmpty else { return }
128 |             
129 |             // Switch to session
130 |             var tmuxTarget = session
131 |             if let window = window {
132 |                 tmuxTarget += ":\(window)"
133 |                 if let pane = pane {
134 |                     tmuxTarget += ".\(pane)"
135 |                 }
136 |             }
137 |             
138 |             self.runCommand(tmuxPath, args: ["switch-client", "-t", tmuxTarget])
139 |         }
140 |     }
141 |     
142 |     private func activateTerminal(preferredTerminal: String? = nil) {
143 |         // ターミナルタイプからアプリケーション名へのマッピング
144 |         let terminalMap: [String: String] = [
145 |             "VSCode": "Visual Studio Code",
146 |             "Cursor": "Cursor",
147 |             "iTerm2": "iTerm2",
148 |             "Terminal": "Terminal",
149 |             "alacritty": "Alacritty"
150 |         ]
151 |         
152 |         // 検出されたターミナルを優先的に使用
153 |         if let preferred = preferredTerminal,
154 |            let appName = terminalMap[preferred] {
155 |             if isAppRunning(appName) {
156 |                 runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(appName)\" to activate"])
157 |                 return
158 |             }
159 |         }
160 |         
161 |         // フォールバック: 実行中のターミナルを探す
162 |         let terminals = ["Alacritty", "iTerm2", "WezTerm", "Terminal", "Visual Studio Code", "Cursor"]
163 |         
164 |         for terminal in terminals {
165 |             if isAppRunning(terminal) {
166 |                 runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(terminal)\" to activate"])
167 |                 return
168 |             }
169 |         }
170 |         
171 |         // Default to Terminal.app
172 |         runCommand("/usr/bin/osascript", args: ["-e", "tell application \"Terminal\" to activate"])
173 |     }
174 |     
175 |     private func isAppRunning(_ appName: String) -> Bool {
176 |         let task = Process()
177 |         task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
178 |         task.arguments = ["-f", appName]
179 |         task.standardOutput = Pipe()
180 |         
181 |         do {
182 |             try task.run()
183 |             task.waitUntilExit()
184 |             return task.terminationStatus == 0
185 |         } catch {
186 |             return false
187 |         }
188 |     }
189 |     
190 |     private func findTmuxPath() -> String {
191 |         let paths = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
192 |         
193 |         for path in paths {
194 |             if FileManager.default.fileExists(atPath: path) {
195 |                 return path
196 |             }
197 |         }
198 |         
199 |         // Search using which
200 |         let task = Process()
201 |         task.executableURL = URL(fileURLWithPath: "/usr/bin/which")
202 |         task.arguments = ["tmux"]
203 |         let pipe = Pipe()
204 |         task.standardOutput = pipe
205 |         
206 |         do {
207 |             try task.run()
208 |             task.waitUntilExit()
209 |             
210 |             if task.terminationStatus == 0 {
211 |                 let data = pipe.fileHandleForReading.readDataToEndOfFile()
212 |                 if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
213 |                     return output
214 |                 }
215 |             }
216 |         } catch {}
217 |         
218 |         return ""
219 |     }
220 |     
221 |     @discardableResult
222 |     private func runCommand(_ path: String, args: [String]) -> Bool {
223 |         let task = Process()
224 |         task.executableURL = URL(fileURLWithPath: path)
225 |         task.arguments = args
226 |         
227 |         do {
228 |             try task.run()
229 |             task.waitUntilExit()
230 |             return task.terminationStatus == 0
231 |         } catch {
232 |             return false
233 |         }
234 |     }
235 | }
236 | 
237 | // Main process
238 | let app = NSApplication.shared
239 | app.setActivationPolicy(.accessory) // Run in background (no Dock icon, but can show notification icons)
240 | 
241 | // Parse arguments
242 | var title = "Claude Code"
243 | var message = ""
244 | var session: String?
245 | var window: String?
246 | var pane: String?
247 | var sound = "default"
248 | var terminal: String?
249 | 
250 | var i = 1
251 | let args = CommandLine.arguments
252 | while i < args.count {
253 |     switch args[i] {
254 |     case "-t", "--title":
255 |         if i + 1 < args.count {
256 |             title = args[i + 1]
257 |             i += 1
258 |         }
259 |     case "-m", "--message":
260 |         if i + 1 < args.count {
261 |             message = args[i + 1]
262 |             i += 1
263 |         }
264 |     case "-s", "--session":
265 |         if i + 1 < args.count {
266 |             session = args[i + 1]
267 |             i += 1
268 |         }
269 |     case "-w", "--window":
270 |         if i + 1 < args.count {
271 |             window = args[i + 1]
272 |             i += 1
273 |         }
274 |     case "-p", "--pane":
275 |         if i + 1 < args.count {
276 |             pane = args[i + 1]
277 |             i += 1
278 |         }
279 |     case "--sound":
280 |         if i + 1 < args.count {
281 |             sound = args[i + 1]
282 |             i += 1
283 |         }
284 |     case "--terminal":
285 |         if i + 1 < args.count {
286 |             terminal = args[i + 1]
287 |             i += 1
288 |         }
289 |     case "-h", "--help":
290 |         print("""
291 |         Usage:
292 |           MacOSNotifyMCP [options]
293 |         
294 |         Options:
295 |           -t, --title <text>      Notification title (default: "Claude Code")
296 |           -m, --message <text>    Notification message (required)
297 |           -s, --session <name>    tmux session name
298 |           -w, --window <number>   tmux window number
299 |           -p, --pane <number>     tmux pane number
300 |           --sound <name>          Notification sound (default: "default")
301 |           --terminal <type>       Terminal type (VSCode, Cursor, iTerm2, etc.)
302 |         
303 |         Examples:
304 |           MacOSNotifyMCP -m "Build completed"
305 |           MacOSNotifyMCP -t "Build" -m "Success" -s development -w 1 -p 0
306 |         """)
307 |         exit(0)
308 |     default:
309 |         break
310 |     }
311 |     i += 1
312 | }
313 | 
314 | // Message is required
315 | if message.isEmpty {
316 |     print("Error: Message is required (-m option)")
317 |     exit(1)
318 | }
319 | 
320 | // Create MacOSNotifyMCP instance and send notification
321 | let notifier = MacOSNotifyMCP()
322 | 
323 | // Send notification and wait in RunLoop
324 | notifier.requestPermissionAndSendNotification(
325 |     title: title,
326 |     message: message,
327 |     sound: sound,
328 |     session: session,
329 |     window: window,
330 |     pane: pane,
331 |     terminal: terminal
332 | )
333 | 
334 | // Run the app
335 | app.run()
```

--------------------------------------------------------------------------------
/test/notifier.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import {
  2 |   afterEach,
  3 |   beforeEach,
  4 |   describe,
  5 |   expect,
  6 |   it,
  7 |   type Mock,
  8 |   vi,
  9 | } from 'vitest'
 10 | import { TmuxNotifier } from '../src/notifier'
 11 | import type { ChildProcess } from 'node:child_process'
 12 | 
 13 | // Mock modules
 14 | vi.mock('node:child_process')
 15 | vi.mock('node:fs')
 16 | vi.mock('node:url', () => ({
 17 |   fileURLToPath: vi.fn(() => '/mocked/path/notifier.js'),
 18 | }))
 19 | 
 20 | describe('TmuxNotifier', () => {
 21 |   let notifier: TmuxNotifier
 22 |   let mockSpawn: Mock
 23 |   let mockExistsSync: Mock
 24 | 
 25 |   beforeEach(async () => {
 26 |     // Reset mocks
 27 |     vi.clearAllMocks()
 28 | 
 29 |     // Get mocked functions
 30 |     const childProcess = await import('node:child_process')
 31 |     const fs = await import('node:fs')
 32 |     mockSpawn = childProcess.spawn as unknown as Mock
 33 |     mockExistsSync = fs.existsSync as unknown as Mock
 34 | 
 35 |     // Default mock implementations
 36 |     mockExistsSync.mockReturnValue(true)
 37 |   })
 38 | 
 39 |   afterEach(() => {
 40 |     vi.restoreAllMocks()
 41 |   })
 42 | 
 43 |   describe('constructor', () => {
 44 |     it('should use custom app path when provided', () => {
 45 |       const customPath = '/custom/path/to/MacOSNotifyMCP.app'
 46 |       notifier = new TmuxNotifier(customPath)
 47 |       expect(notifier['appPath']).toBe(customPath)
 48 |     })
 49 | 
 50 |     it('should find app in default locations when no custom path provided', () => {
 51 |       mockExistsSync.mockImplementation((path: string) => {
 52 |         return path.includes('MacOSNotifyMCP/MacOSNotifyMCP.app')
 53 |       })
 54 | 
 55 |       notifier = new TmuxNotifier()
 56 |       expect(notifier['appPath']).toContain('MacOSNotifyMCP.app')
 57 |     })
 58 | 
 59 |     it('should handle missing app gracefully', () => {
 60 |       mockExistsSync.mockReturnValue(false)
 61 |       notifier = new TmuxNotifier()
 62 |       // Should default to first possible path even if it doesn't exist
 63 |       expect(notifier['appPath']).toContain('MacOSNotifyMCP.app')
 64 |     })
 65 |   })
 66 | 
 67 |   describe('runCommand', () => {
 68 |     beforeEach(() => {
 69 |       notifier = new TmuxNotifier('/test/app/path')
 70 |     })
 71 | 
 72 |     it('should execute command successfully', async () => {
 73 |       const mockProcess = createMockProcess()
 74 |       mockSpawn.mockReturnValue(mockProcess)
 75 | 
 76 |       const result = notifier['runCommand']('echo', ['hello'])
 77 | 
 78 |       // Simulate successful execution
 79 |       mockProcess.stdout.emit('data', Buffer.from('hello'))
 80 |       mockProcess.emit('close', 0)
 81 | 
 82 |       expect(await result).toBe('hello')
 83 |       expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello'])
 84 |     })
 85 | 
 86 |     it('should handle command failure with non-zero exit code', async () => {
 87 |       const mockProcess = createMockProcess()
 88 |       mockSpawn.mockReturnValue(mockProcess)
 89 | 
 90 |       const promise = notifier['runCommand']('false', [])
 91 | 
 92 |       // Simulate failure
 93 |       mockProcess.stderr.emit('data', Buffer.from('Command failed'))
 94 |       mockProcess.emit('close', 1)
 95 | 
 96 |       await expect(promise).rejects.toThrow('Command failed: false')
 97 |     })
 98 | 
 99 |     it('should handle spawn errors', async () => {
100 |       const mockProcess = createMockProcess()
101 |       mockSpawn.mockReturnValue(mockProcess)
102 | 
103 |       const promise = notifier['runCommand']('nonexistent', [])
104 | 
105 |       // Simulate spawn error
106 |       mockProcess.emit('error', new Error('spawn ENOENT'))
107 | 
108 |       await expect(promise).rejects.toThrow('spawn ENOENT')
109 |     })
110 | 
111 |     it('should accumulate stdout and stderr data', async () => {
112 |       const mockProcess = createMockProcess()
113 |       mockSpawn.mockReturnValue(mockProcess)
114 | 
115 |       const result = notifier['runCommand']('test', [])
116 | 
117 |       // Emit multiple data chunks
118 |       mockProcess.stdout.emit('data', Buffer.from('Hello '))
119 |       mockProcess.stdout.emit('data', Buffer.from('World'))
120 |       mockProcess.stderr.emit('data', Buffer.from('Warning'))
121 |       mockProcess.emit('close', 0)
122 | 
123 |       expect(await result).toBe('Hello World')
124 |     })
125 |   })
126 | 
127 |   describe('getCurrentTmuxInfo', () => {
128 |     beforeEach(() => {
129 |       notifier = new TmuxNotifier('/test/app/path')
130 |     })
131 | 
132 |     it('should return current tmux session info', async () => {
133 |       const runCommandSpy = vi
134 |         .spyOn(notifier as any, 'runCommand')
135 |         .mockResolvedValueOnce('my-session')
136 |         .mockResolvedValueOnce('1')
137 |         .mockResolvedValueOnce('0')
138 | 
139 |       const result = await notifier.getCurrentTmuxInfo()
140 | 
141 |       expect(result).toEqual({
142 |         session: 'my-session',
143 |         window: '1',
144 |         pane: '0',
145 |       })
146 |       expect(runCommandSpy).toHaveBeenCalledTimes(3)
147 |     })
148 | 
149 |     it('should return null when not in tmux session', async () => {
150 |       const runCommandSpy = vi
151 |         .spyOn(notifier as any, 'runCommand')
152 |         .mockRejectedValue(new Error('not in tmux'))
153 | 
154 |       const result = await notifier.getCurrentTmuxInfo()
155 | 
156 |       expect(result).toBeNull()
157 |       expect(runCommandSpy).toHaveBeenCalledTimes(1)
158 |     })
159 |   })
160 | 
161 |   describe('listSessions', () => {
162 |     beforeEach(() => {
163 |       notifier = new TmuxNotifier('/test/app/path')
164 |     })
165 | 
166 |     it('should return list of tmux sessions', async () => {
167 |       const runCommandSpy = vi
168 |         .spyOn(notifier as any, 'runCommand')
169 |         .mockResolvedValue('session1\nsession2\nsession3\n')
170 | 
171 |       const result = await notifier.listSessions()
172 | 
173 |       expect(result).toEqual(['session1', 'session2', 'session3'])
174 |       expect(runCommandSpy).toHaveBeenCalledWith('tmux', [
175 |         'list-sessions',
176 |         '-F',
177 |         '#{session_name}',
178 |       ])
179 |     })
180 | 
181 |     it('should return empty array when no sessions exist', async () => {
182 |       const runCommandSpy = vi
183 |         .spyOn(notifier as any, 'runCommand')
184 |         .mockRejectedValue(new Error('no sessions'))
185 | 
186 |       const result = await notifier.listSessions()
187 | 
188 |       expect(result).toEqual([])
189 |     })
190 | 
191 |     it('should filter out empty lines', async () => {
192 |       const runCommandSpy = vi
193 |         .spyOn(notifier as any, 'runCommand')
194 |         .mockResolvedValue('session1\n\nsession2\n\n')
195 | 
196 |       const result = await notifier.listSessions()
197 | 
198 |       expect(result).toEqual(['session1', 'session2'])
199 |     })
200 |   })
201 | 
202 |   describe('sessionExists', () => {
203 |     beforeEach(() => {
204 |       notifier = new TmuxNotifier('/test/app/path')
205 |     })
206 | 
207 |     it('should return true when session exists', async () => {
208 |       const listSessionsSpy = vi
209 |         .spyOn(notifier, 'listSessions')
210 |         .mockResolvedValue(['session1', 'session2', 'my-session'])
211 | 
212 |       const result = await notifier.sessionExists('my-session')
213 | 
214 |       expect(result).toBe(true)
215 |       expect(listSessionsSpy).toHaveBeenCalled()
216 |     })
217 | 
218 |     it('should return false when session does not exist', async () => {
219 |       const listSessionsSpy = vi
220 |         .spyOn(notifier, 'listSessions')
221 |         .mockResolvedValue(['session1', 'session2'])
222 | 
223 |       const result = await notifier.sessionExists('nonexistent')
224 | 
225 |       expect(result).toBe(false)
226 |     })
227 | 
228 |     it('should handle empty session list', async () => {
229 |       const listSessionsSpy = vi
230 |         .spyOn(notifier, 'listSessions')
231 |         .mockResolvedValue([])
232 | 
233 |       const result = await notifier.sessionExists('any-session')
234 | 
235 |       expect(result).toBe(false)
236 |     })
237 |   })
238 | 
239 |   describe('sendNotification', () => {
240 |     beforeEach(() => {
241 |       notifier = new TmuxNotifier('/test/app/path')
242 |     })
243 | 
244 |     it('should send basic notification with message only', async () => {
245 |       const runCommandSpy = vi
246 |         .spyOn(notifier as any, 'runCommand')
247 |         .mockResolvedValue('')
248 | 
249 |       await notifier.sendNotification({ message: 'Hello World' })
250 | 
251 |       expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [
252 |         '-n',
253 |         '/test/app/path',
254 |         '--args',
255 |         '-t',
256 |         'macos-notify-mcp',
257 |         '-m',
258 |         'Hello World',
259 |         '--sound',
260 |         'Glass',
261 |       ])
262 |     })
263 | 
264 |     it('should send notification with all options', async () => {
265 |       const runCommandSpy = vi
266 |         .spyOn(notifier as any, 'runCommand')
267 |         .mockResolvedValue('')
268 | 
269 |       await notifier.sendNotification({
270 |         message: 'Test message',
271 |         title: 'Test Title',
272 |         sound: 'Glass',
273 |         session: 'my-session',
274 |         window: '2',
275 |         pane: '1',
276 |       })
277 | 
278 |       expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [
279 |         '-n',
280 |         '/test/app/path',
281 |         '--args',
282 |         '-t',
283 |         'Test Title',
284 |         '-m',
285 |         'Test message',
286 |         '--sound',
287 |         'Glass',
288 |         '-s',
289 |         'my-session',
290 |         '-w',
291 |         '2',
292 |         '-p',
293 |         '1',
294 |       ])
295 |     })
296 | 
297 |     it('should handle empty app path', async () => {
298 |       notifier = new TmuxNotifier()
299 |       notifier['appPath'] = ''
300 | 
301 |       await expect(
302 |         notifier.sendNotification({ message: 'Test' }),
303 |       ).rejects.toThrow('MacOSNotifyMCP.app not found')
304 |     })
305 | 
306 |     it('should escape special characters in arguments', async () => {
307 |       const runCommandSpy = vi
308 |         .spyOn(notifier as any, 'runCommand')
309 |         .mockResolvedValue('')
310 | 
311 |       await notifier.sendNotification({
312 |         message: 'Message with "quotes"',
313 |         title: "Title with 'apostrophes'",
314 |       })
315 | 
316 |       const call = runCommandSpy.mock.calls[0]
317 |       expect(call[1]).toContain('Message with "quotes"')
318 |       expect(call[1]).toContain('-t')
319 |       expect(call[1]).toContain("Title with 'apostrophes'")
320 |     })
321 | 
322 |     it('should omit undefined optional parameters', async () => {
323 |       const runCommandSpy = vi
324 |         .spyOn(notifier as any, 'runCommand')
325 |         .mockResolvedValue('')
326 | 
327 |       await notifier.sendNotification({
328 |         message: 'Simple message',
329 |         title: 'Title',
330 |         // Other options undefined
331 |       })
332 | 
333 |       const args = runCommandSpy.mock.calls[0][1]
334 |       expect(args).toEqual([
335 |         '-n',
336 |         '/test/app/path',
337 |         '--args',
338 |         '-t',
339 |         'Title',
340 |         '-m',
341 |         'Simple message',
342 |         '--sound',
343 |         'Glass',
344 |       ])
345 |       expect(args).not.toContain('-s')
346 |       expect(args).not.toContain('-w')
347 |       expect(args).not.toContain('-p')
348 |     })
349 |   })
350 | })
351 | 
352 | // Helper function to create a mock child process
353 | function createMockProcess(): Partial<ChildProcess> {
354 |   const EventEmitter = require('node:events').EventEmitter
355 |   const stdout = new EventEmitter()
356 |   const stderr = new EventEmitter()
357 |   const process = new EventEmitter()
358 | 
359 |   return Object.assign(process, {
360 |     stdout,
361 |     stderr,
362 |     stdin: {
363 |       end: vi.fn(),
364 |     },
365 |     kill: vi.fn(),
366 |     pid: 12345,
367 |   }) as unknown as ChildProcess
368 | }
```

--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2 | import {
  3 |   CallToolRequestSchema,
  4 |   ListToolsRequestSchema,
  5 | } from '@modelcontextprotocol/sdk/types.js'
  6 | 
  7 | describe('MCP Server', () => {
  8 |   let mockNotifier: any
  9 |   let mockServer: any
 10 |   let handlers: Map<string, any>
 11 | 
 12 |   beforeEach(() => {
 13 |     // Reset module cache
 14 |     vi.resetModules()
 15 | 
 16 |     // Create handlers map
 17 |     handlers = new Map()
 18 | 
 19 |     // Create mock notifier
 20 |     mockNotifier = {
 21 |       sendNotification: vi.fn().mockResolvedValue(undefined),
 22 |       listSessions: vi.fn().mockResolvedValue(['session1', 'session2']),
 23 |       sessionExists: vi.fn().mockResolvedValue(true),
 24 |       getCurrentTmuxInfo: vi
 25 |         .fn()
 26 |         .mockResolvedValue({ session: 'current', window: '1', pane: '0' }),
 27 |     }
 28 | 
 29 |     // Mock the notifier module
 30 |     vi.doMock('../src/notifier', () => ({
 31 |       TmuxNotifier: vi.fn(() => mockNotifier),
 32 |     }))
 33 | 
 34 |     // Create mock server
 35 |     mockServer = {
 36 |       name: 'macos-notify-mcp',
 37 |       version: '0.1.0',
 38 |       setRequestHandler: vi.fn((schema: any, handler: any) => {
 39 |         handlers.set(schema, handler)
 40 |       }),
 41 |       connect: vi.fn(),
 42 |     }
 43 | 
 44 |     // Mock MCP SDK
 45 |     vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({
 46 |       Server: vi.fn(() => mockServer),
 47 |     }))
 48 | 
 49 |     vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
 50 |       StdioServerTransport: vi.fn(() => ({
 51 |         start: vi.fn(),
 52 |         close: vi.fn(),
 53 |       })),
 54 |     }))
 55 |   })
 56 | 
 57 |   afterEach(() => {
 58 |     vi.clearAllMocks()
 59 |   })
 60 | 
 61 |   async function loadServer() {
 62 |     await import('../src/index')
 63 |     return { handlers }
 64 |   }
 65 | 
 66 |   describe('Tool Registration', () => {
 67 |     it('should register all tools on initialization', async () => {
 68 |       const { handlers } = await loadServer()
 69 | 
 70 |       const listToolsHandler = handlers.get(ListToolsRequestSchema)
 71 |       expect(listToolsHandler).toBeDefined()
 72 | 
 73 |       const response = await listToolsHandler({ method: 'tools/list' })
 74 |       
 75 |       expect(response.tools).toHaveLength(3)
 76 |       
 77 |       const toolNames = response.tools.map((tool: any) => tool.name)
 78 |       expect(toolNames).toContain('send_notification')
 79 |       expect(toolNames).toContain('list_tmux_sessions')
 80 |       expect(toolNames).toContain('get_current_tmux_info')
 81 |     })
 82 | 
 83 |     it('should provide correct schema for send_notification tool', async () => {
 84 |       const { handlers } = await loadServer()
 85 | 
 86 |       const listToolsHandler = handlers.get(ListToolsRequestSchema)
 87 |       const response = await listToolsHandler({ method: 'tools/list' })
 88 |       
 89 |       const sendNotificationTool = response.tools.find(
 90 |         (tool: any) => tool.name === 'send_notification',
 91 |       )
 92 |       
 93 |       expect(sendNotificationTool).toBeDefined()
 94 |       expect(sendNotificationTool.description).toContain(
 95 |         'Send a macOS notification',
 96 |       )
 97 |       expect(sendNotificationTool.inputSchema.type).toBe('object')
 98 |       expect(sendNotificationTool.inputSchema.required).toContain('message')
 99 |       expect(
100 |         sendNotificationTool.inputSchema.properties.message,
101 |       ).toBeDefined()
102 |       expect(sendNotificationTool.inputSchema.properties.title).toBeDefined()
103 |       expect(sendNotificationTool.inputSchema.properties.sound).toBeDefined()
104 |     })
105 |   })
106 | 
107 |   describe('Tool Execution', () => {
108 |     let callToolHandler: any
109 | 
110 |     beforeEach(async () => {
111 |       const { handlers } = await loadServer()
112 |       callToolHandler = handlers.get(CallToolRequestSchema)
113 |     })
114 | 
115 |     describe('send_notification', () => {
116 |       it('should send notification with message only', async () => {
117 |         const request = {
118 |           method: 'tools/call',
119 |           params: {
120 |             name: 'send_notification',
121 |             arguments: {
122 |               message: 'Test notification',
123 |             },
124 |           },
125 |         }
126 | 
127 |         const response = await callToolHandler(request)
128 | 
129 |         expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
130 |           message: 'Test notification',
131 |         })
132 |         expect(response.content).toHaveLength(1)
133 |         expect(response.content[0].type).toBe('text')
134 |         expect(response.content[0].text).toBe('Notification sent: "Test notification"')
135 |       })
136 | 
137 |       it('should send notification with all parameters', async () => {
138 |         const request = {
139 |           method: 'tools/call',
140 |           params: {
141 |             name: 'send_notification',
142 |             arguments: {
143 |               message: 'Full notification',
144 |               title: 'Important',
145 |               sound: 'Glass',
146 |               session: 'work',
147 |               window: '2',
148 |               pane: '1',
149 |             },
150 |           },
151 |         }
152 | 
153 |         const response = await callToolHandler(request)
154 | 
155 |         expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
156 |           message: 'Full notification',
157 |           title: 'Important',
158 |           sound: 'Glass',
159 |           session: 'work',
160 |           window: '2',
161 |           pane: '1',
162 |         })
163 |         expect(response.content[0].text).toBe('Notification sent: "Full notification" (tmux: work)')
164 |       })
165 | 
166 |       it('should handle missing required message parameter', async () => {
167 |         const request = {
168 |           method: 'tools/call',
169 |           params: {
170 |             name: 'send_notification',
171 |             arguments: {
172 |               title: 'No message',
173 |             },
174 |           },
175 |         }
176 | 
177 |         const response = await callToolHandler(request)
178 |         expect(response.content[0].text).toBe('Error: Message is required')
179 |       })
180 | 
181 |       it('should handle notification sending errors', async () => {
182 |         mockNotifier.sendNotification.mockRejectedValue(
183 |           new Error('Failed to send'),
184 |         )
185 | 
186 |         const request = {
187 |           method: 'tools/call',
188 |           params: {
189 |             name: 'send_notification',
190 |             arguments: {
191 |               message: 'Will fail',
192 |             },
193 |           },
194 |         }
195 | 
196 |         const response = await callToolHandler(request)
197 |         expect(response.content[0].text).toBe('Error: Failed to send')
198 |       })
199 | 
200 |       it('should convert non-string parameters to strings', async () => {
201 |         const request = {
202 |           method: 'tools/call',
203 |           params: {
204 |             name: 'send_notification',
205 |             arguments: {
206 |               message: 123,
207 |               title: true,
208 |               window: 456,
209 |               pane: null,
210 |             } as any,
211 |           },
212 |         }
213 | 
214 |         const response = await callToolHandler(request)
215 | 
216 |         expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
217 |           message: '123',
218 |           title: 'true',
219 |           window: '456',
220 |           // pane is not included because null is filtered out
221 |         })
222 |       })
223 |     })
224 | 
225 |     describe('list_tmux_sessions', () => {
226 |       it('should list tmux sessions', async () => {
227 |         const request = {
228 |           method: 'tools/call',
229 |           params: {
230 |             name: 'list_tmux_sessions',
231 |             arguments: {},
232 |           },
233 |         }
234 | 
235 |         const response = await callToolHandler(request)
236 | 
237 |         expect(mockNotifier.listSessions).toHaveBeenCalled()
238 |         expect(response.content).toHaveLength(1)
239 |         expect(response.content[0].type).toBe('text')
240 |         expect(response.content[0].text).toContain('session1')
241 |         expect(response.content[0].text).toContain('session2')
242 |       })
243 | 
244 |       it('should handle empty session list', async () => {
245 |         mockNotifier.listSessions.mockResolvedValue([])
246 | 
247 |         const request = {
248 |           method: 'tools/call',
249 |           params: {
250 |             name: 'list_tmux_sessions',
251 |             arguments: {},
252 |           },
253 |         }
254 | 
255 |         const response = await callToolHandler(request)
256 | 
257 |         expect(response.content[0].text).toBe('No tmux sessions found')
258 |       })
259 | 
260 |       it('should handle errors when listing sessions', async () => {
261 |         mockNotifier.listSessions.mockRejectedValue(
262 |           new Error('Tmux not available'),
263 |         )
264 | 
265 |         const request = {
266 |           method: 'tools/call',
267 |           params: {
268 |             name: 'list_tmux_sessions',
269 |             arguments: {},
270 |           },
271 |         }
272 | 
273 |         const response = await callToolHandler(request)
274 |         expect(response.content[0].text).toBe('Error: Tmux not available')
275 |       })
276 |     })
277 | 
278 |     describe('get_current_tmux_info', () => {
279 |       it('should get current tmux info', async () => {
280 |         const request = {
281 |           method: 'tools/call',
282 |           params: {
283 |             name: 'get_current_tmux_info',
284 |             arguments: {},
285 |           },
286 |         }
287 | 
288 |         const response = await callToolHandler(request)
289 | 
290 |         expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled()
291 |         expect(response.content).toHaveLength(1)
292 |         expect(response.content[0].type).toBe('text')
293 |         const text = response.content[0].text
294 |         expect(text).toContain('Session: current')
295 |         expect(text).toContain('Window: 1')
296 |         expect(text).toContain('Pane: 0')
297 |       })
298 | 
299 |       it('should handle when not in tmux session', async () => {
300 |         mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null)
301 | 
302 |         const request = {
303 |           method: 'tools/call',
304 |           params: {
305 |             name: 'get_current_tmux_info',
306 |             arguments: {},
307 |           },
308 |         }
309 | 
310 |         const response = await callToolHandler(request)
311 | 
312 |         expect(response.content[0].text).toBe('Not in a tmux session')
313 |       })
314 | 
315 |       it('should handle errors when getting tmux info', async () => {
316 |         mockNotifier.getCurrentTmuxInfo.mockRejectedValue(
317 |           new Error('Tmux error'),
318 |         )
319 | 
320 |         const request = {
321 |           method: 'tools/call',
322 |           params: {
323 |             name: 'get_current_tmux_info',
324 |             arguments: {},
325 |           },
326 |         }
327 | 
328 |         const response = await callToolHandler(request)
329 |         expect(response.content[0].text).toBe('Error: Tmux error')
330 |       })
331 |     })
332 | 
333 |     describe('unknown tool', () => {
334 |       it('should handle unknown tool name', async () => {
335 |         const request = {
336 |           method: 'tools/call',
337 |           params: {
338 |             name: 'unknown_tool',
339 |             arguments: {},
340 |           },
341 |         }
342 | 
343 |         const response = await callToolHandler(request)
344 |         expect(response.content[0].text).toBe('Error: Unknown tool: unknown_tool')
345 |       })
346 |     })
347 |   })
348 | 
349 |   describe('Server Lifecycle', () => {
350 |     it('should create server with correct configuration', async () => {
351 |       await loadServer()
352 | 
353 |       const { Server } = await import('@modelcontextprotocol/sdk/server/index.js')
354 |       expect(Server).toHaveBeenCalledWith({
355 |         name: 'macos-notify-mcp',
356 |         version: expect.any(String),
357 |       }, expect.any(Object))
358 |     })
359 | 
360 |     it('should connect transport', async () => {
361 |       await loadServer()
362 | 
363 |       const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
364 |       expect(StdioServerTransport).toHaveBeenCalled()
365 |     })
366 | 
367 |     it('should set up error handling', async () => {
368 |       const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
369 |       
370 |       await loadServer()
371 |       
372 |       // Simulate an error event
373 |       const errorHandler = mockServer.onerror || mockServer.setRequestHandler.mock.calls.find(
374 |         (call: any) => call[0] === 'error'
375 |       )?.[1]
376 | 
377 |       if (errorHandler) {
378 |         const testError = new Error('Test error')
379 |         errorHandler(testError)
380 |         expect(consoleErrorSpy).toHaveBeenCalledWith('Server error:', testError)
381 |       }
382 | 
383 |       consoleErrorSpy.mockRestore()
384 |     })
385 |   })
386 | })
```

--------------------------------------------------------------------------------
/src/notifier.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { spawn } from 'node:child_process'
  2 | import { existsSync } from 'node:fs'
  3 | import { dirname, join } from 'node:path'
  4 | import { fileURLToPath } from 'node:url'
  5 | 
  6 | interface NotificationOptions {
  7 |   title?: string
  8 |   message: string
  9 |   sound?: string
 10 |   session?: string
 11 |   window?: string
 12 |   pane?: string
 13 | }
 14 | 
 15 | interface TmuxInfo {
 16 |   session: string
 17 |   window: string
 18 |   pane: string
 19 | }
 20 | 
 21 | interface CommandError extends Error {
 22 |   code?: number
 23 |   stderr?: string
 24 |   stdout?: string
 25 | }
 26 | 
 27 | export type TerminalType =
 28 |   | 'VSCode'
 29 |   | 'Cursor'
 30 |   | 'iTerm2'
 31 |   | 'Terminal'
 32 |   | 'alacritty'
 33 |   | 'Unknown'
 34 | 
 35 | export class TmuxNotifier {
 36 |   private appPath = ''
 37 |   private defaultTitle = 'macos-notify-mcp'
 38 | 
 39 |   constructor(customAppPath?: string) {
 40 |     if (customAppPath) {
 41 |       this.appPath = customAppPath
 42 |     } else {
 43 |       // Try multiple locations
 44 |       const possiblePaths = [
 45 |         // Relative to the package installation (primary location)
 46 |         join(
 47 |           dirname(fileURLToPath(import.meta.url)),
 48 |           '..',
 49 |           'MacOSNotifyMCP',
 50 |           'MacOSNotifyMCP.app',
 51 |         ),
 52 |         // Development path
 53 |         join(process.cwd(), 'MacOSNotifyMCP', 'MacOSNotifyMCP.app'),
 54 |       ]
 55 | 
 56 |       // Find the first existing path
 57 |       for (const path of possiblePaths) {
 58 |         if (existsSync(path)) {
 59 |           this.appPath = path
 60 |           break
 61 |         }
 62 |       }
 63 | 
 64 |       // Default to package-relative path
 65 |       if (!this.appPath) {
 66 |         this.appPath = possiblePaths[0]
 67 |       }
 68 |     }
 69 | 
 70 |     // Get repository name as default title
 71 |     this.initializeDefaultTitle()
 72 |   }
 73 | 
 74 |   /**
 75 |    * Initialize default title from git repository name
 76 |    */
 77 |   private async initializeDefaultTitle(): Promise<void> {
 78 |     try {
 79 |       const repoName = await this.getGitRepoName()
 80 |       if (repoName) {
 81 |         this.defaultTitle = repoName
 82 |       }
 83 |     } catch (_error) {
 84 |       // Keep default title if git command fails
 85 |     }
 86 |   }
 87 | 
 88 |   /**
 89 |    * Get the active tmux client information
 90 |    */
 91 |   private async getActiveClientInfo(): Promise<{
 92 |     tty: string
 93 |     session: string
 94 |     activity: string
 95 |   } | null> {
 96 |     try {
 97 |       // Get list of all clients attached to the current session
 98 |       const currentSession = process.env.TMUX_PANE
 99 |         ? await this.runCommand('tmux', [
100 |             'display-message',
101 |             '-p',
102 |             '#{session_name}',
103 |           ])
104 |         : null
105 | 
106 |       if (!currentSession) return null
107 | 
108 |       // Get all clients attached to this session
109 |       const clientsOutput = await this.runCommand('tmux', [
110 |         'list-clients',
111 |         '-t',
112 |         currentSession.trim(),
113 |         '-F',
114 |         '#{client_tty}|#{client_session}|#{client_activity}',
115 |       ])
116 | 
117 |       const clients = clientsOutput
118 |         .trim()
119 |         .split('\n')
120 |         .filter(Boolean)
121 |         .map((line) => {
122 |           const [tty, session, activity] = line.split('|')
123 |           return { tty, session, activity: Number(activity) }
124 |         })
125 | 
126 |       // Get the most recently active client
127 |       const activeClient = clients.reduce((prev, curr) =>
128 |         curr.activity > prev.activity ? curr : prev,
129 |       )
130 | 
131 |       return {
132 |         tty: activeClient.tty,
133 |         session: activeClient.session,
134 |         activity: activeClient.activity.toString(),
135 |       }
136 |     } catch (_error) {
137 |       return null
138 |     }
139 |   }
140 | 
141 |   /**
142 |    * Detect terminal emulator from client TTY
143 |    */
144 |   private async detectTerminalFromClient(
145 |     clientTty: string,
146 |   ): Promise<TerminalType> {
147 |     try {
148 |       // Find processes using this TTY
149 |       const lsofOutput = await this.runCommand('lsof', [clientTty])
150 |       const lines = lsofOutput.trim().split('\n').slice(1) // Skip header
151 | 
152 |       for (const line of lines) {
153 |         const parts = line.split(/\s+/)
154 |         if (parts.length < 2) continue
155 | 
156 |         const pid = parts[1]
157 |         // Get process info
158 |         const psOutput = await this.runCommand('ps', ['-p', pid, '-o', 'comm='])
159 |         const command = psOutput.trim()
160 | 
161 |         // Check for known terminal emulators
162 |         if (command.includes('Cursor')) return 'Cursor'
163 |         if (command.includes('Code')) return 'VSCode'
164 |         if (command.includes('iTerm2')) return 'iTerm2'
165 |         if (command.includes('Terminal')) return 'Terminal'
166 |         if (command.includes('alacritty')) return 'alacritty'
167 |       }
168 |     } catch (_error) {
169 |       // lsof might fail, continue with other methods
170 |     }
171 | 
172 |     return 'Unknown'
173 |   }
174 | 
175 |   /**
176 |    * Detect the parent terminal emulator
177 |    */
178 |   private async detectTerminalEmulator(): Promise<TerminalType> {
179 |     // 1. Check for Cursor via CURSOR_TRACE_ID
180 |     if (process.env.CURSOR_TRACE_ID) {
181 |       return 'Cursor'
182 |     }
183 | 
184 |     // 2. Check for VSCode/Cursor via VSCODE_IPC_HOOK_CLI
185 |     if (process.env.VSCODE_IPC_HOOK_CLI) {
186 |       // Check if it's Cursor by looking for cursor-specific paths
187 |       if (process.env.VSCODE_IPC_HOOK_CLI.includes('Cursor')) {
188 |         return 'Cursor'
189 |       }
190 |       return 'VSCode'
191 |     }
192 | 
193 |     // 3. Check for VSCode Remote (for tmux attached from VSCode)
194 |     if (process.env.VSCODE_REMOTE || process.env.VSCODE_PID) {
195 |       return 'VSCode'
196 |     }
197 | 
198 |     // 4. Check for alacritty via specific environment variables
199 |     if (process.env.ALACRITTY_WINDOW_ID || process.env.ALACRITTY_SOCKET) {
200 |       return 'alacritty'
201 |     }
202 | 
203 |     // 5. Check TERM_PROGRAM for iTerm2 and Terminal.app
204 |     if (process.env.TERM_PROGRAM) {
205 |       if (process.env.TERM_PROGRAM === 'iTerm.app') {
206 |         return 'iTerm2'
207 |       }
208 |       if (process.env.TERM_PROGRAM === 'Apple_Terminal') {
209 |         return 'Terminal'
210 |       }
211 |       if (process.env.TERM_PROGRAM === 'alacritty') {
212 |         return 'alacritty'
213 |       }
214 |     }
215 | 
216 |     // 6. If we're in tmux, try to detect the active client's terminal
217 |     if (process.env.TMUX) {
218 |       try {
219 |         // Get active client info
220 |         const clientInfo = await this.getActiveClientInfo()
221 |         if (clientInfo) {
222 |           const detectedTerminal = await this.detectTerminalFromClient(
223 |             clientInfo.tty,
224 |           )
225 |           if (detectedTerminal !== 'Unknown') {
226 |             return detectedTerminal
227 |           }
228 |         }
229 | 
230 |         // Fallback: Get the tmux client's terminal info
231 |         const clientTerm = await this.runCommand('tmux', [
232 |           'display-message',
233 |           '-p',
234 |           '#{client_termname}',
235 |         ])
236 | 
237 |         // Check for specific terminal indicators in the client termname
238 |         if (clientTerm.includes('iterm') || clientTerm.includes('iTerm')) {
239 |           return 'iTerm2'
240 |         }
241 |         if (clientTerm.includes('Apple_Terminal')) {
242 |           return 'Terminal'
243 |         }
244 | 
245 |         // Also check tmux client environment variables
246 |         try {
247 |           const clientEnv = await this.runCommand('tmux', [
248 |             'show-environment',
249 |             '-g',
250 |             'TERM_PROGRAM',
251 |           ])
252 |           if (clientEnv.includes('TERM_PROGRAM=iTerm.app')) {
253 |             return 'iTerm2'
254 |           }
255 |           if (clientEnv.includes('TERM_PROGRAM=Apple_Terminal')) {
256 |             return 'Terminal'
257 |           }
258 |         } catch (_) {
259 |           // Ignore if show-environment fails
260 |         }
261 |       } catch (_) {
262 |         // Ignore tmux command failures
263 |       }
264 |     }
265 | 
266 |     // 3. Fallback: Check process tree
267 |     try {
268 |       // Get the parent process ID chain
269 |       let currentPid = process.pid
270 |       const maxDepth = 10 // Prevent infinite loops
271 | 
272 |       for (let i = 0; i < maxDepth; i++) {
273 |         // Get parent process info using ps command
274 |         const psOutput = await this.runCommand('ps', [
275 |           '-p',
276 |           currentPid.toString(),
277 |           '-o',
278 |           'ppid=,comm=',
279 |         ])
280 | 
281 |         const [ppidStr, command] = psOutput.trim().split(/\s+/, 2)
282 |         const ppid = Number.parseInt(ppidStr)
283 | 
284 |         if (!ppid || ppid === 1) {
285 |           break // Reached init process
286 |         }
287 | 
288 |         // Check if the command matches known terminal emulators
289 |         if (command) {
290 |           if (command.includes('Cursor')) {
291 |             return 'Cursor'
292 |           }
293 |           if (command.includes('Code') || command.includes('code-insiders')) {
294 |             return 'VSCode'
295 |           }
296 |           if (command.includes('iTerm2')) {
297 |             return 'iTerm2'
298 |           }
299 |           if (command.includes('Terminal')) {
300 |             return 'Terminal'
301 |           }
302 |         }
303 | 
304 |         currentPid = ppid
305 |       }
306 |     } catch (_error) {
307 |       // Ignore errors in process tree detection
308 |     }
309 | 
310 |     return 'Unknown'
311 |   }
312 | 
313 |   /**
314 |    * Get git repository name from current directory
315 |    */
316 |   private async getGitRepoName(): Promise<string | null> {
317 |     try {
318 |       // Get the remote URL
319 |       const remoteUrl = (
320 |         await this.runCommand('git', ['config', '--get', 'remote.origin.url'])
321 |       ).trim()
322 | 
323 |       if (!remoteUrl) {
324 |         // If no remote, try to get the directory name of the git root
325 |         const gitRoot = (
326 |           await this.runCommand('git', ['rev-parse', '--show-toplevel'])
327 |         ).trim()
328 |         return gitRoot.split('/').pop() || null
329 |       }
330 | 
331 |       // Extract repo name from URL
332 |       // Handle both HTTPS and SSH formats
333 |       // https://github.com/user/repo.git
334 |       // [email protected]:user/repo.git
335 |       const match = remoteUrl.match(/[/:]([\w-]+)\/([\w-]+?)(\.git)?$/)
336 |       if (match) {
337 |         return match[2]
338 |       }
339 | 
340 |       // Fallback to directory name
341 |       const gitRoot = (
342 |         await this.runCommand('git', ['rev-parse', '--show-toplevel'])
343 |       ).trim()
344 |       return gitRoot.split('/').pop() || null
345 |     } catch (_error) {
346 |       return null
347 |     }
348 |   }
349 | 
350 |   /**
351 |    * Run a command and return the output
352 |    */
353 |   private async runCommand(command: string, args: string[]): Promise<string> {
354 |     return new Promise((resolve, reject) => {
355 |       const proc = spawn(command, args)
356 |       let stdout = ''
357 |       let stderr = ''
358 | 
359 |       proc.stdout.on('data', (data) => {
360 |         stdout += data.toString()
361 |       })
362 | 
363 |       proc.stderr.on('data', (data) => {
364 |         stderr += data.toString()
365 |       })
366 | 
367 |       proc.on('close', (code) => {
368 |         if (code === 0) {
369 |           resolve(stdout)
370 |         } else {
371 |           const error = new Error(
372 |             `Command failed: ${command} ${args.join(' ')}\n${stderr}`,
373 |           ) as CommandError
374 |           error.code = code ?? undefined
375 |           error.stderr = stderr
376 |           error.stdout = stdout
377 |           reject(error)
378 |         }
379 |       })
380 | 
381 |       proc.on('error', reject)
382 |     })
383 |   }
384 | 
385 |   /**
386 |    * Get current tmux session info
387 |    */
388 |   async getCurrentTmuxInfo(): Promise<TmuxInfo | null> {
389 |     try {
390 |       const session = (
391 |         await this.runCommand('tmux', [
392 |           'display-message',
393 |           '-p',
394 |           '#{session_name}',
395 |         ])
396 |       ).trim()
397 |       const window = (
398 |         await this.runCommand('tmux', [
399 |           'display-message',
400 |           '-p',
401 |           '#{window_index}',
402 |         ])
403 |       ).trim()
404 |       const pane = (
405 |         await this.runCommand('tmux', [
406 |           'display-message',
407 |           '-p',
408 |           '#{pane_index}',
409 |         ])
410 |       ).trim()
411 | 
412 |       return { session, window, pane }
413 |     } catch (_error) {
414 |       return null
415 |     }
416 |   }
417 | 
418 |   /**
419 |    * List tmux sessions
420 |    */
421 |   async listSessions(): Promise<string[]> {
422 |     try {
423 |       const output = await this.runCommand('tmux', [
424 |         'list-sessions',
425 |         '-F',
426 |         '#{session_name}',
427 |       ])
428 |       return output.trim().split('\n').filter(Boolean)
429 |     } catch (_error) {
430 |       return []
431 |     }
432 |   }
433 | 
434 |   /**
435 |    * Check if a session exists
436 |    */
437 |   async sessionExists(session: string): Promise<boolean> {
438 |     const sessions = await this.listSessions()
439 |     return sessions.includes(session)
440 |   }
441 | 
442 |   /**
443 |    * Get the detected terminal emulator type
444 |    */
445 |   async getTerminalEmulator(): Promise<TerminalType> {
446 |     return this.detectTerminalEmulator()
447 |   }
448 | 
449 |   /**
450 |    * Send notification
451 |    */
452 |   async sendNotification(options: NotificationOptions): Promise<void> {
453 |     const {
454 |       title = this.defaultTitle,
455 |       message,
456 |       sound = 'Glass',
457 |       session,
458 |       window,
459 |       pane,
460 |     } = options
461 | 
462 |     // Check if app path is valid
463 |     if (!this.appPath) {
464 |       throw new Error('MacOSNotifyMCP.app not found')
465 |     }
466 | 
467 |     // Always detect terminal emulator to pass to notification app
468 |     const terminal = await this.detectTerminalEmulator()
469 | 
470 |     // Use MacOSNotifyMCP.app for notifications
471 |     const args = [
472 |       '-n',
473 |       this.appPath,
474 |       '--args',
475 |       '-t',
476 |       title,
477 |       '-m',
478 |       message,
479 |       '--sound',
480 |       sound,
481 |       '--terminal',
482 |       terminal,
483 |     ]
484 | 
485 |     if (session) {
486 |       args.push('-s', session)
487 |       if (window !== undefined && window !== '') {
488 |         args.push('-w', window)
489 |       }
490 |       if (pane !== undefined && pane !== '') {
491 |         args.push('-p', pane)
492 |       }
493 |     }
494 | 
495 |     await this.runCommand('/usr/bin/open', args)
496 |   }
497 | }
498 | 
```