#
tokens: 21836/50000 21/21 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .gitignore
├── .npmignore
├── biome.json
├── CLAUDE.md
├── examples
│   ├── terminal-detection.js
│   └── tmux-client-tracking.sh
├── macos-notify-mcp-icon.svg
├── MacOSNotifyMCP
│   ├── build-app.sh
│   ├── MacOSNotifyMCP.app
│   │   └── Contents
│   │       ├── _CodeSignature
│   │       │   └── CodeResources
│   │       ├── Info.plist
│   │       ├── MacOS
│   │       │   └── MacOSNotifyMCP
│   │       └── Resources
│   │           └── MacOSNotifyMCP.icns
│   ├── MacOSNotifyMCP.icns
│   ├── main.swift
│   └── notify-swift
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── cli-wrapper.cjs
│   ├── cli.ts
│   ├── index.ts
│   ├── mcp-wrapper.cjs
│   └── notifier.ts
├── test
│   ├── cli.test.ts
│   ├── index.test.ts
│   └── notifier.test.ts
├── tsconfig.json
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Dependencies
node_modules/

# Build output
dist/

# IDE
.vscode/
.idea/

# OS
.DS_Store

# Logs
*.log
npm-debug.log*

# Environment
.env
.env.local

# TypeScript
*.tsbuildinfo

# Temporary files
*.tmp
tmp/
temp/
```

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

```
# Source files
src/
tsconfig.json

# Development files
.gitignore
.git/

# Swift source files (but keep the built app)
NotifyTmux/main.swift
NotifyTmux/build-app.sh
NotifyTmux/notify-swift

# Old Deno files
bin/

# IDE
.vscode/
.idea/

# OS
.DS_Store

# Temporary
*.tmp
tmp/
temp/

# Keep NotifyTmux.app
!NotifyTmux/NotifyTmux.app/
```

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

```markdown
# macOS Notify MCP

A Model Context Protocol (MCP) server for macOS notifications with tmux integration. This tool allows AI assistants like Claude to send native macOS notifications that can focus specific tmux sessions when clicked.

## Features

- 🔔 Native macOS notifications using UserNotifications API
- 🖱️ Clickable notifications that focus tmux sessions
- 🎯 Direct navigation to specific tmux session, window, and pane
- 🔊 Customizable notification sounds
- 🚀 Support for multiple concurrent notifications
- 🤖 MCP server for AI assistant integration
- 🖥️ Terminal emulator detection (VSCode, Cursor, iTerm2, Terminal.app)

## Installation

### Prerequisites

- macOS (required for notifications)
- Node.js >= 18.0.0
- tmux (optional, for tmux integration)

### Install from npm

```bash
npm install -g macos-notify-mcp
```

### Build from source

```bash
git clone https://github.com/yuki-yano/macos-notify-mcp.git
cd macos-notify-mcp
npm install
npm run build
npm run build-app  # Build the macOS app bundle (only needed for development)
```

## Usage

### As MCP Server

First, install the package globally:

```bash
npm install -g macos-notify-mcp
```

#### Quick Setup with Claude Code

Use the `claude mcp add` command:

```bash
claude mcp add macos-notify -s user -- macos-notify-mcp
```

Then restart Claude Code.

#### Manual Setup for Claude Desktop

Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "macos-notify": {
      "command": "macos-notify-mcp"
    }
  }
}
```


### Available MCP Tools

- `send_notification` - Send a macOS notification
  - `message` (required): Notification message
  - `title`: Notification title (default: "Claude Code")
  - `sound`: Notification sound (default: "Glass")
  - `session`: tmux session name
  - `window`: tmux window number
  - `pane`: tmux pane number
  - `useCurrent`: Use current tmux location

- `list_tmux_sessions` - List available tmux sessions

- `get_current_tmux_info` - Get current tmux session information

### As CLI Tool

```bash
# Basic notification
macos-notify-cli -m "Build completed"

# With title
macos-notify-cli -t "Development" -m "Tests passed"

# With tmux integration
macos-notify-cli -m "Task finished" -s my-session -w 1 -p 0

# Use current tmux location
macos-notify-cli -m "Check this pane" --current-tmux

# Detect current terminal emulator
macos-notify-cli --detect-terminal

# List tmux sessions
macos-notify-cli --list-sessions
```

### Terminal Detection

The tool automatically detects which terminal emulator you're using and uses this information when you click on notifications to focus the correct application. You can test terminal detection with:

```bash
# Test terminal detection
macos-notify-cli --detect-terminal
```

#### Supported Terminal Detection

The tool detects terminals using various methods:

1. **Cursor**: Via `CURSOR_TRACE_ID` environment variable
2. **VSCode**: Via `VSCODE_IPC_HOOK_CLI` or `VSCODE_REMOTE` environment variables
3. **alacritty**: Via `ALACRITTY_WINDOW_ID` or `ALACRITTY_SOCKET` environment variables
4. **iTerm2**: Via `TERM_PROGRAM=iTerm.app`
5. **Terminal.app**: Via `TERM_PROGRAM=Apple_Terminal`

#### Terminal Detection in tmux

When running inside tmux, the tool attempts to detect which terminal emulator the active tmux client is using:

1. **Active Client Detection**: Identifies the most recently active tmux client
2. **TTY Process Analysis**: Traces processes using the client's TTY
3. **Environment Preservation**: Checks preserved environment variables
4. **Process Tree Fallback**: Analyzes the process tree as a last resort

For advanced tmux client tracking, see `examples/tmux-client-tracking.sh`.

## How it Works

1. **Notification Delivery**: Uses a native macOS app bundle (MacOSNotifyMCP.app) to send UserNotifications API notifications
2. **Click Handling**: When a notification is clicked, the app activates the detected terminal emulator (VSCode, Cursor, iTerm2, alacritty, or Terminal.app) and switches to the specified tmux session
3. **Terminal Support**: Automatically detects and activates the correct terminal application
4. **Multiple Instances**: Each notification runs as a separate process, allowing multiple concurrent notifications

## Architecture

The project consists of two main components:

1. **MCP Server/CLI** (TypeScript/Node.js)
   - Implements the Model Context Protocol
   - Provides a command-line interface
   - Manages tmux session detection and validation

2. **MacOSNotifyMCP.app** (Swift/macOS)
   - Native macOS application for notifications
   - Handles notification clicks to focus tmux sessions
   - Runs as a background process for each notification

## MacOSNotifyMCP.app

The MacOSNotifyMCP.app is bundled with the npm package and is automatically available after installation. No additional setup is required.

## Troubleshooting

### Notifications not appearing

1. Check System Settings → Notifications → MacOSNotifyMCP
2. Ensure notifications are allowed
3. Run `macos-notify-mcp -m "test"` to verify

### tmux integration not working

1. Ensure tmux is installed and running
2. Check session names with `macos-notify-mcp --list-sessions`
3. Verify terminal app is supported (Alacritty, iTerm2, WezTerm, or Terminal)

## Development

```bash
# Install dependencies
npm install

# Build TypeScript
npm run build

# Run in development
npm run dev

# Lint and format code
npm run lint
npm run format

# Build macOS app (only if modifying Swift code)
npm run build-app
```

## License

MIT

## Author

Yuki Yano
```

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

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is a Model Context Protocol (MCP) server for macOS notifications with tmux integration. The project consists of:
- A TypeScript-based MCP server and CLI tool
- A native macOS Swift application (MacOSNotifyMCP.app) that handles notifications

## Key Commands

### Development
```bash
npm install          # Install dependencies
npm run build        # Build TypeScript to dist/
npm run dev          # Run MCP server in watch mode
npm run build-app    # Build the macOS app bundle (MacOSNotifyMCP.app)
```

### Linting & Formatting
```bash
npm run lint         # Run biome linter with auto-fix
npm run format       # Format code with biome
npm run check        # Check code without modifications
```

### Testing
```bash
npm test             # Build and test CLI help output
node dist/cli.js -m "test" --current-tmux  # Test notification with current tmux session
```

## Architecture

### Core Components

1. **MCP Server** (`src/index.ts`)
   - Implements Model Context Protocol server
   - Provides tools: `send_notification`, `list_tmux_sessions`, `get_current_tmux_info`
   - Uses StdioServerTransport for communication

2. **Notifier Core** (`src/notifier.ts`)
   - Main notification logic
   - Searches for MacOSNotifyMCP.app in multiple locations
   - Handles tmux session detection and validation
   - Uses `spawn` for subprocess management (not `exec`)

3. **CLI Interface** (`src/cli.js`)
   - Command-line tool for direct notification sending
   - Argument parsing for tmux session/window/pane
   - Session validation before sending notifications

4. **MacOSNotifyMCP.app** (`MacOSNotifyMCP/main.swift`)
   - Native macOS app using UserNotifications API
   - Handles notification clicks to focus tmux sessions
   - Runs as background process (no Dock icon)
   - Supports multiple concurrent notifications via `-n` flag

### Key Design Decisions

1. **Single Notification Method**: All notifications go through MacOSNotifyMCP.app (no osascript fallbacks)
2. **App Bundling**: MacOSNotifyMCP.app is included in the npm package, no post-install scripts
3. **App Discovery**: MacOSNotifyMCP.app is searched in order:
   - Package installation directory (primary)
   - Current working directory (development)
4. **Process Management**: Each notification spawns a new MacOSNotifyMCP.app instance
5. **Error Handling**: Commands use `spawn` to properly handle arguments with special characters

## Important Notes

- The project uses ES modules (`"type": "module"` in package.json)
- MacOSNotifyMCP.app is pre-built and included in the npm package
- The app uses ad-hoc signing
- Biome is configured for linting/formatting (config embedded in package.json)
- tmux integration requires tmux to be installed and running
- The app path is resolved from the npm package installation directory

## MCP Tools Usage

When this project is installed as an MCP server, use these tools for notifications:

### Available MCP Tools

1. **send_notification** - Send macOS notifications
   - Required: `message` (string)
   - Optional: `title`, `sound`, `session`, `window`, `pane`, `useCurrent`
   - Example: "Send a notification with message 'Build completed'"

2. **list_tmux_sessions** - List available tmux sessions
   - No parameters required
   - Returns list of active tmux sessions

3. **get_current_tmux_info** - Get current tmux location
   - No parameters required
   - Returns current session, window, and pane

### Usage Examples

When users ask about notifications or tmux, actively use these tools:

- "Notify me when done" → Use `send_notification` with appropriate message
- "Send notification to current tmux" → Use `send_notification` with `useCurrent: true`
- "What tmux sessions are available?" → Use `list_tmux_sessions`
- "Where am I in tmux?" → Use `get_current_tmux_info`

### Interactive Patterns

When waiting for user input, always send a notification first:

1. **Before asking for confirmation**:
   ```
   send_notification("Build complete. Waiting for deployment confirmation")
   // Then ask: "Deploy to production?"
   ```

2. **When presenting options**:
   ```
   send_notification("Multiple matches found. Please choose in terminal")
   // Then show options
   ```

3. **On errors requiring user decision**:
   ```
   send_notification("Error encountered. Need your input to proceed")
   // Then present error and options
   ```

### Testing Commands

For development testing, use these CLI commands:
```bash
# Test notification directly
node dist/cli.js -m "Test message"

# Test with current tmux session
node dist/cli.js -m "Test message" --current-tmux

# List tmux sessions
node dist/cli.js --list-sessions
```
```

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

```
#!/usr/bin/env node

// CommonJS wrapper for ES module MCP server
// This ensures compatibility with npm global installs

async function main() {
  try {
    await import('./index.js')
  } catch (error) {
    console.error('Failed to start MCP server:', error)
    process.exit(1)
  }
}

main()
```

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

```
#!/usr/bin/env node

// CommonJS wrapper for ES module CLI
// This ensures compatibility with npm global installs

async function main() {
  try {
    const { main } = await import('./cli.js')
    await main()
  } catch (error) {
    console.error('Failed to load CLI:', error)
    process.exit(1)
  }
}

main()
```

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

```typescript
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/**',
        'dist/**',
        'MacOSNotifyMCP/**',
        'test/**',
        '**/*.d.ts',
        '**/*.config.*',
        '**/mockData.ts',
        'scripts/**',
      ],
    },
    include: ['test/**/*.test.ts'],
    testTimeout: 10000,
  },
})
```

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

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"],
  "ts-node": {
    "compilerOptions": {
      "allowJs": true
    }
  }
}
```

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

```json
{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "assist": { "actions": { "source": { "organizeImports": "on" } } },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "noParameterAssign": "error",
        "useAsConstAssertion": "error",
        "useDefaultParameterLast": "error",
        "useEnumInitializers": "error",
        "useSelfClosingElements": "error",
        "useSingleVarDeclarator": "error",
        "noUnusedTemplateLiteral": "error",
        "useNumberNamespace": "error",
        "noInferrableTypes": "error",
        "noUselessElse": "error"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "formatWithErrors": false,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 80,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "asNeeded",
      "trailingCommas": "all"
    }
  },
  "files": {
    "includes": [
      "**/src/**/*.ts",
      "**/bin/**/*",
      "!**/node_modules",
      "!**/dist",
      "!**/build",
      "!**/MacOSNotifyMCP"
    ]
  }
}

```

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

```javascript
#!/usr/bin/env node

import { TmuxNotifier } from '../dist/notifier.js'

async function demonstrateTerminalDetection() {
  const notifier = new TmuxNotifier()
  
  console.log('Terminal Detection Example')
  console.log('==========================\n')
  
  // Show environment variables
  console.log('Environment variables:')
  console.log(`  VSCODE_IPC_HOOK_CLI: ${process.env.VSCODE_IPC_HOOK_CLI ? 'Set' : 'Not set'}`)
  console.log(`  TERM_PROGRAM: ${process.env.TERM_PROGRAM || 'Not set'}`)
  console.log(`  TMUX: ${process.env.TMUX ? 'Set' : 'Not set'}\n`)
  
  // Detect terminal
  const terminal = await notifier.getTerminalEmulator()
  console.log(`Detected Terminal: ${terminal}\n`)
  
  // Send notification without terminal info
  console.log('Sending notification without terminal info...')
  await notifier.sendNotification({
    message: 'Standard notification',
    title: 'Test'
  })
  
  // Send notification with terminal info
  console.log('Sending notification with terminal info...')
  await notifier.sendNotification({
    message: 'Notification with terminal detection',
    title: 'Test',
    includeTerminalInfo: true
  })
  
  console.log('\nDone! Check your notifications.')
}

demonstrateTerminalDetection().catch(console.error)
```

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

```json
{
  "name": "macos-notify-mcp",
  "version": "0.0.8",
  "description": "MCP server for macOS notifications with tmux integration",
  "keywords": [
    "mcp",
    "macos",
    "notification",
    "tmux",
    "claude"
  ],
  "author": "Yuki Yano",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yuki-yano/macos-notify-mcp.git"
  },
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "bin": {
    "macos-notify-cli": "dist/cli-wrapper.cjs",
    "macos-notify-mcp": "dist/mcp-wrapper.cjs"
  },
  "files": [
    "dist",
    "MacOSNotifyMCP"
  ],
  "scripts": {
    "build": "tsc",
    "postbuild": "cp src/cli-wrapper.cjs dist/cli-wrapper.cjs && chmod +x dist/cli-wrapper.cjs && cp src/mcp-wrapper.cjs dist/mcp-wrapper.cjs && chmod +x dist/mcp-wrapper.cjs",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:watch": "vitest --watch",
    "cli:test": "npm run build && node dist/cli.js --help",
    "prepare": "npm run build",
    "build-app": "cd MacOSNotifyMCP && ./build-app.sh",
    "lint": "biome check --write",
    "format": "biome format --write",
    "check": "biome check"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.4"
  },
  "devDependencies": {
    "@biomejs/biome": "^2.0.0",
    "@types/node": "^22.15.32",
    "@vitest/coverage-v8": "^3.2.4",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3",
    "vitest": "^3.2.4"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

```

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

```bash
#!/bin/bash
# Create complete macOS app bundle

APP_NAME="MacOSNotifyMCP"
APP_DIR="${APP_NAME}.app"
CONTENTS_DIR="${APP_DIR}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"

# Create directory structure
mkdir -p "${MACOS_DIR}"
mkdir -p "${RESOURCES_DIR}"

# Create Info.plist
cat > "${CONTENTS_DIR}/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleIdentifier</key>
    <string>com.macos-notify-mcp.app</string>
    <key>CFBundleName</key>
    <string>MacOSNotifyMCP</string>
    <key>CFBundleDisplayName</key>
    <string>MacOSNotifyMCP</string>
    <key>CFBundleVersion</key>
    <string>1.0</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleExecutable</key>
    <string>MacOSNotifyMCP</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>LSMinimumSystemVersion</key>
    <string>10.15</string>
    <key>NSUserNotificationAlertStyle</key>
    <string>alert</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>NSSupportsAutomaticTermination</key>
    <true/>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
    <key>CFBundleIconFile</key>
    <string>MacOSNotifyMCP</string>
    <key>LSUIElement</key>
    <true/>
</dict>
</plist>
EOF

# Copy icon file to Resources directory
if [ -f "${APP_NAME}.icns" ]; then
    cp "${APP_NAME}.icns" "${RESOURCES_DIR}/"
    echo "Icon file copied: ${APP_NAME}.icns"
else
    echo "Warning: Icon file ${APP_NAME}.icns not found"
fi

# Compile Swift binary and place it in the app
swiftc -o "${MACOS_DIR}/${APP_NAME}" main.swift

# Add signature to app (ad-hoc signing)
echo "Signing app..."
codesign --force --deep --sign - "${APP_DIR}"

echo "App bundle created: ${APP_DIR}"
echo "Usage: open ${APP_DIR} --args -m \"Test message\""
echo "Bundle ID: com.macos-notify-mcp.app"
```

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

```
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
  <!-- Background - macOS style rounded square -->
  <defs>
    <!-- Gradient for background -->
    <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:#007AFF;stop-opacity:1" />
      <stop offset="100%" style="stop-color:#0051D5;stop-opacity:1" />
    </linearGradient>
    
    <!-- Shadow filter -->
    <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
      <feGaussianBlur in="SourceAlpha" stdDeviation="20"/>
      <feOffset dx="0" dy="10" result="offsetblur"/>
      <feFlood flood-color="#000000" flood-opacity="0.25"/>
      <feComposite in2="offsetblur" operator="in"/>
      <feMerge>
        <feMergeNode/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
    
    <!-- Glow effect -->
    <filter id="glow">
      <feGaussianBlur stdDeviation="4" result="coloredBlur"/>
      <feMerge>
        <feMergeNode in="coloredBlur"/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>
  
  <!-- App icon background -->
  <rect x="64" y="64" width="896" height="896" rx="200" ry="200" 
        fill="url(#bgGradient)" filter="url(#shadow)"/>
  
  <!-- Main bell shape -->
  <g transform="translate(512, 440)">
    <!-- Bell body -->
    <path d="M-180,-140 C-180,-260 -100,-340 0,-340 C100,-340 180,-260 180,-140 
             L180,40 C180,80 160,100 160,100 L-160,100 C-160,100 -180,80 -180,40 Z" 
          fill="#FFFFFF" opacity="0.95"/>
    
    <!-- Bell clapper -->
    <circle cx="0" cy="140" r="40" fill="#FFFFFF" opacity="0.95"/>
    
    <!-- Notification indicator -->
    <circle cx="140" cy="-120" r="60" fill="#FF3B30" filter="url(#glow)"/>
  </g>
  
  <!-- Terminal/Code brackets to represent tmux -->
  <g transform="translate(512, 720)">
    <!-- Left bracket -->
    <path d="M-240,-80 L-200,-80 L-200,-60 L-220,-60 L-220,60 L-200,60 L-200,80 L-240,80 L-240,-80 Z" 
          fill="#FFFFFF" opacity="0.7"/>
    
    <!-- Right bracket -->
    <path d="M240,-80 L200,-80 L200,-60 L220,-60 L220,60 L200,60 L200,80 L240,80 L240,-80 Z" 
          fill="#FFFFFF" opacity="0.7"/>
    
    <!-- Underscore cursor -->
    <rect x="-40" y="40" width="80" height="20" rx="10" fill="#00FF00" opacity="0.9"/>
  </g>
  
  <!-- AI circuit dots -->
  <g transform="translate(512, 512)">
    <!-- Connection lines -->
    <circle cx="-320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/>
    <circle cx="320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/>
    <circle cx="0" cy="-320" r="20" fill="#00D4FF" opacity="0.6"/>
    <circle cx="0" cy="320" r="20" fill="#00D4FF" opacity="0.6"/>
    
    <!-- Diagonal dots -->
    <circle cx="-220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/>
    <circle cx="220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/>
    <circle cx="-220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/>
    <circle cx="220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/>
  </g>
</svg>
```

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

```typescript
#!/usr/bin/env node

import { TmuxNotifier } from './notifier.js'

interface CliOptions {
  message: string
  title?: string
  sound?: string
  session?: string
  window?: string
  pane?: string
}

export async function main() {
  const notifier = new TmuxNotifier()

  // Parse command line arguments
  const args = process.argv.slice(2)

  if (args.includes('--help') || args.includes('-h')) {
    console.log(`
Usage:
  macos-notify-cli [options]

Options:
  -m, --message <text>    Notification message (required)
  -t, --title <text>      Notification title (default: git repository name)
  -s, --session <name>    tmux session name
  -w, --window <number>   tmux window number
  -p, --pane <number>     tmux pane number
  --sound <name>          Notification sound (default: "Glass")
  --current-tmux          Use current tmux location
  --list-sessions         List available tmux sessions
  --detect-terminal       Detect and display the current terminal emulator
  -h, --help              Show this help message

Examples:
  # Basic notification
  macos-notify-cli -m "Build completed"
  
  # Navigate to specific session
  macos-notify-cli -m "Tests passed" -s development -w 1 -p 0
  
  # Use current tmux location
  macos-notify-cli -m "Task finished" --current-tmux
    `)
    process.exit(0)
  }

  if (args.includes('--list-sessions')) {
    const sessions = await notifier.listSessions()
    console.log('Available tmux sessions:')
    sessions.forEach((s) => console.log(`  ${s}`))
    process.exit(0)
  }

  if (args.includes('--detect-terminal')) {
    const terminal = await notifier.getTerminalEmulator()
    console.log(`Detected terminal: ${terminal}`)
    process.exit(0)
  }

  // Parse arguments
  const options: CliOptions = {
    message: '',
  }

  for (let i = 0; i < args.length; i++) {
    switch (args[i]) {
      case '-m':
      case '--message':
        options.message = args[++i]
        break
      case '-t':
      case '--title':
        options.title = args[++i]
        break
      case '-s':
      case '--session':
        options.session = args[++i]
        break
      case '-w':
      case '--window':
        options.window = args[++i]
        break
      case '-p':
      case '--pane':
        options.pane = args[++i]
        break
      case '--sound':
        options.sound = args[++i]
        break
      case '--current-tmux': {
        const current = await notifier.getCurrentTmuxInfo()
        if (current) {
          options.session = current.session
          options.window = current.window
          options.pane = current.pane
        } else {
          console.error('Error: Not in a tmux session')
          process.exit(1)
        }
        break
      }
    }
  }

  if (!options.message) {
    console.error('Error: Message is required (-m option)')
    process.exit(1)
  }

  // Check if session exists
  if (options.session) {
    const exists = await notifier.sessionExists(options.session)
    if (!exists) {
      console.error(`Error: Session '${options.session}' does not exist`)
      const sessions = await notifier.listSessions()
      if (sessions.length > 0) {
        console.log('\nAvailable sessions:')
        sessions.forEach((s) => console.log(`  ${s}`))
      }
      process.exit(1)
    }
  }

  // Send notification
  try {
    await notifier.sendNotification(options)
    console.log('Notification sent successfully')
    process.exit(0)
  } catch (error) {
    console.error('Failed to send notification:', error)
    process.exit(1)
  }
}

// Export for testing (already exported as function declaration)

// Only run if called directly (not when imported as a module)
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((error) => {
    console.error('Unexpected error:', error)
    process.exit(1)
  })
}

```

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

```bash
#!/bin/bash

# tmux client tracking example
# This script demonstrates how to track which terminal emulator is attached to tmux

# Method 1: Setup tmux hooks to track client information
setup_tmux_hooks() {
    # Track when clients attach
    tmux set-hook -g client-attached 'run-shell "echo \"Client attached: #{client_tty} at $(date)\" >> ~/.tmux-client.log"'
    
    # Track when clients change sessions
    tmux set-hook -g client-session-changed 'run-shell "echo \"Client #{client_tty} switched to #{session_name} at $(date)\" >> ~/.tmux-client.log"'
    
    # Track client activity
    tmux set-hook -g after-select-pane 'run-shell "echo \"Pane selected by #{client_tty} in #{session_name}:#{window_index}.#{pane_index} at $(date)\" >> ~/.tmux-client.log"'
}

# Method 2: Get terminal info from active client
get_active_client_terminal() {
    # Get the most recently active client
    local active_client=$(tmux list-clients -F '#{client_tty}:#{client_activity}' | sort -t: -k2 -nr | head -1 | cut -d: -f1)
    
    if [ -n "$active_client" ]; then
        echo "Active client TTY: $active_client"
        
        # Try to find the terminal process
        if command -v lsof >/dev/null 2>&1; then
            echo "Processes using TTY:"
            lsof "$active_client" 2>/dev/null | grep -E '(Cursor|Code|iTerm|Terminal|alacritty)' | head -5
        fi
        
        # Alternative: Check parent processes
        local pids=$(ps aux | grep "$active_client" | grep -v grep | awk '{print $2}')
        for pid in $pids; do
            if [ -f "/proc/$pid/environ" ]; then
                echo "Environment for PID $pid:"
                tr '\0' '\n' < "/proc/$pid/environ" | grep -E '(TERM_PROGRAM|CURSOR_TRACE_ID|VSCODE_IPC_HOOK_CLI|ALACRITTY)'
            fi
        done
    fi
}

# Method 3: Store client info in tmux environment
track_client_terminal() {
    # This would be called when Claude Code starts
    local terminal_type="Unknown"
    
    # Detect terminal type
    if [ -n "$CURSOR_TRACE_ID" ]; then
        terminal_type="Cursor"
    elif [ -n "$VSCODE_IPC_HOOK_CLI" ]; then
        terminal_type="VSCode"
    elif [ "$TERM_PROGRAM" = "iTerm.app" ]; then
        terminal_type="iTerm2"
    elif [ "$TERM_PROGRAM" = "Apple_Terminal" ]; then
        terminal_type="Terminal"
    elif [ -n "$ALACRITTY_WINDOW_ID" ]; then
        terminal_type="alacritty"
    fi
    
    # Store in tmux environment
    tmux set-environment -g "CLAUDE_CODE_TERMINAL_${TMUX_PANE}" "$terminal_type"
    tmux set-environment -g "CLAUDE_CODE_STARTED_$(date +%s)" "$terminal_type:$TMUX_PANE"
}

# Method 4: Real-time client detection
detect_current_client() {
    # Get current pane
    local current_pane=$(tmux display-message -p '#{pane_id}')
    
    # Find which client is currently viewing this pane
    tmux list-clients -F '#{client_tty}:#{client_session}:#{session_id}:#{window_id}:#{pane_id}' | while IFS=: read -r tty session session_id window_id pane_id; do
        # Check if this client is viewing our pane
        local client_pane=$(tmux display-message -t "$tty" -p '#{pane_id}' 2>/dev/null)
        if [ "$client_pane" = "$current_pane" ]; then
            echo "Client $tty is viewing this pane"
            # Now detect terminal from this TTY
        fi
    done
}

# Example usage
case "${1:-}" in
    "setup")
        setup_tmux_hooks
        echo "tmux hooks configured"
        ;;
    "track")
        track_client_terminal
        echo "Terminal tracked: $(tmux show-environment -g | grep CLAUDE_CODE_TERMINAL)"
        ;;
    "detect")
        get_active_client_terminal
        ;;
    "current")
        detect_current_client
        ;;
    *)
        echo "Usage: $0 {setup|track|detect|current}"
        echo "  setup   - Configure tmux hooks for client tracking"
        echo "  track   - Track current terminal in tmux environment"
        echo "  detect  - Detect active client's terminal"
        echo "  current - Detect which client is viewing current pane"
        ;;
esac
```

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

```typescript
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { TmuxNotifier } from './notifier.js'

interface NotificationOptions {
  message: string
  title?: string
  sound?: string
  session?: string
  window?: string
  pane?: string
}

// Get version from package.json
const __dirname = dirname(fileURLToPath(import.meta.url))
const packageJson = JSON.parse(
  readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'),
)

const server = new Server(
  {
    name: 'macos-notify-mcp',
    version: packageJson.version,
  },
  {
    capabilities: {
      tools: {},
    },
  },
)

const notifier = new TmuxNotifier()

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'send_notification',
        description: 'Send a macOS notification with optional tmux integration',
        inputSchema: {
          type: 'object',
          properties: {
            message: {
              type: 'string',
              description: 'The notification message',
            },
            title: {
              type: 'string',
              description: 'The notification title (default: "Claude Code")',
            },
            sound: {
              type: 'string',
              description: 'The notification sound (default: "Glass")',
            },
            session: {
              type: 'string',
              description: 'tmux session name',
            },
            window: {
              type: 'string',
              description: 'tmux window number',
            },
            pane: {
              type: 'string',
              description: 'tmux pane number',
            },
            useCurrent: {
              type: 'boolean',
              description: 'Use current tmux location',
            },
          },
          required: ['message'],
        },
      },
      {
        name: 'list_tmux_sessions',
        description: 'List available tmux sessions',
        inputSchema: {
          type: 'object',
          properties: {},
        },
      },
      {
        name: 'get_current_tmux_info',
        description: 'Get current tmux session information',
        inputSchema: {
          type: 'object',
          properties: {},
        },
      },
    ],
  }
})

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params

  if (!args) {
    throw new Error('No arguments provided')
  }

  try {
    switch (name) {
      case 'send_notification': {
        // Safely extract properties from args
        const notificationArgs = args as Record<string, unknown>

        // Validate message is provided
        if (!notificationArgs.message) {
          throw new Error('Message is required')
        }

        const options: NotificationOptions = {
          message: String(notificationArgs.message),
          title: notificationArgs.title
            ? String(notificationArgs.title)
            : undefined,
          sound: notificationArgs.sound
            ? String(notificationArgs.sound)
            : undefined,
        }

        if (notificationArgs.useCurrent) {
          const current = await notifier.getCurrentTmuxInfo()
          if (current) {
            options.session = current.session
            options.window = current.window
            options.pane = current.pane
          }
        } else {
          if (notificationArgs.session)
            options.session = String(notificationArgs.session)
          if (notificationArgs.window)
            options.window = String(notificationArgs.window)
          if (notificationArgs.pane)
            options.pane = String(notificationArgs.pane)
        }

        // Validate session if specified
        if (options.session) {
          const exists = await notifier.sessionExists(options.session)
          if (!exists) {
            const sessions = await notifier.listSessions()
            return {
              content: [
                {
                  type: 'text',
                  text: `Error: Session '${options.session}' does not exist. Available sessions: ${sessions.join(', ')}`,
                },
              ],
            }
          }
        }

        await notifier.sendNotification(options)

        return {
          content: [
            {
              type: 'text',
              text: `Notification sent: "${options.message}"${options.session ? ` (tmux: ${options.session})` : ''}`,
            },
          ],
        }
      }

      case 'list_tmux_sessions': {
        const sessions = await notifier.listSessions()
        return {
          content: [
            {
              type: 'text',
              text:
                sessions.length > 0
                  ? `Available tmux sessions:\n${sessions.map((s) => `- ${s}`).join('\n')}`
                  : 'No tmux sessions found',
            },
          ],
        }
      }

      case 'get_current_tmux_info': {
        const info = await notifier.getCurrentTmuxInfo()
        if (info) {
          return {
            content: [
              {
                type: 'text',
                text: `Current tmux location:\n- Session: ${info.session}\n- Window: ${info.window}\n- Pane: ${info.pane}`,
              },
            ],
          }
        }
        return {
          content: [
            {
              type: 'text',
              text: 'Not in a tmux session',
            },
          ],
        }
      }

      default:
        throw new Error(`Unknown tool: ${name}`)
    }
  } catch (error) {
    return {
      content: [
        {
          type: 'text',
          text: `Error: ${error instanceof Error ? error.message : String(error)}`,
        },
      ],
    }
  }
})

// Start the server
async function main() {
  const transport = new StdioServerTransport()
  await server.connect(transport)
  console.error('macOS Notify MCP server started')
}

main().catch((error) => {
  console.error('Server error:', error)
  process.exit(1)
})

```

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

```typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// Mock the notifier module before importing cli
let mockNotifier: any

vi.mock('../src/notifier.js', () => ({
  TmuxNotifier: vi.fn(() => mockNotifier),
}))

describe('CLI', () => {
  let originalArgv: string[]
  let originalExit: typeof process.exit
  let exitCode: number | undefined
  let consoleLogSpy: ReturnType<typeof vi.spyOn>
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>

  beforeEach(() => {
    // Save original values
    originalArgv = process.argv
    originalExit = process.exit

    // Mock process.exit
    exitCode = undefined
    process.exit = ((code?: number) => {
      exitCode = code
      throw new Error(`Process.exit(${code})`)
    }) as never

    // Mock console methods
    consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

    // Reset module cache
    vi.resetModules()

    // Create mock notifier
    mockNotifier = {
      sendNotification: vi.fn().mockResolvedValue(undefined),
      listSessions: vi.fn().mockResolvedValue(['session1', 'session2']),
      sessionExists: vi.fn().mockResolvedValue(true),
      getCurrentTmuxInfo: vi
        .fn()
        .mockResolvedValue({ session: 'current', window: '1', pane: '0' }),
    }
  })

  afterEach(() => {
    // Restore original values
    process.argv = originalArgv
    process.exit = originalExit
    consoleLogSpy.mockRestore()
    consoleErrorSpy.mockRestore()
    vi.clearAllMocks()
  })

  async function runCli() {
    // Import fresh copy and get main function
    const cliModule = await import('../src/cli.js')
    const main = (cliModule as any).main || cliModule.default

    if (typeof main === 'function') {
      try {
        await main()
      } catch (e: any) {
        if (!e.message.startsWith('Process.exit')) {
          throw e
        }
      }
    }
  }

  describe('--help flag', () => {
    it('should display help message with --help', async () => {
      process.argv = ['node', 'cli.js', '--help']

      await runCli()

      expect(consoleLogSpy).toHaveBeenCalled()
      const output = consoleLogSpy.mock.calls.join('\n')
      expect(output).toContain('Usage:')
      expect(output).toContain('Options:')
      expect(output).toContain('--help')
      expect(output).toContain('--list-sessions')
      expect(exitCode).toBe(0)
    })

    it('should display help message with -h', async () => {
      process.argv = ['node', 'cli.js', '-h']

      await runCli()

      expect(consoleLogSpy).toHaveBeenCalled()
      expect(exitCode).toBe(0)
    })
  })

  describe('--list-sessions', () => {
    it('should list tmux sessions', async () => {
      process.argv = ['node', 'cli.js', '--list-sessions']

      await runCli()

      expect(mockNotifier.listSessions).toHaveBeenCalled()
      expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
      expect(consoleLogSpy).toHaveBeenCalledWith('  session1')
      expect(consoleLogSpy).toHaveBeenCalledWith('  session2')
      expect(exitCode).toBe(0)
    })

    it('should handle no sessions gracefully', async () => {
      mockNotifier.listSessions.mockResolvedValue([])
      process.argv = ['node', 'cli.js', '--list-sessions']

      await runCli()

      expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
      expect(exitCode).toBe(0)
    })
  })

  describe('notification sending', () => {
    it.skip('should send basic notification with message', async () => {
      process.argv = ['node', 'cli.js', '-m', 'Hello World']

      await runCli()

      // Check what errors were logged
      if (consoleErrorSpy.mock.calls.length > 0) {
        console.log('Console errors found:', consoleErrorSpy.mock.calls)
      }

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Hello World',
      })
      expect(consoleLogSpy).toHaveBeenCalledWith('Notification sent successfully')
      expect(consoleErrorSpy).not.toHaveBeenCalled()
      expect(exitCode).toBe(0)
    })

    it('should send notification with title', async () => {
      process.argv = [
        'node',
        'cli.js',
        '-m',
        'Test message',
        '-t',
        'Test Title',
      ]

      await runCli()

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Test message',
        title: 'Test Title',
      })
    })

    it('should send notification with sound', async () => {
      process.argv = [
        'node',
        'cli.js',
        '-m',
        'Alert!',
        '--sound',
        'Glass',
      ]

      await runCli()

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Alert!',
        sound: 'Glass',
      })
    })

    it('should send notification to specific tmux location', async () => {
      process.argv = [
        'node',
        'cli.js',
        '-m',
        'Tmux notification',
        '-s',
        'my-session',
        '-w',
        '2',
        '-p',
        '1',
      ]

      await runCli()

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Tmux notification',
        session: 'my-session',
        window: '2',
        pane: '1',
      })
    })

    it('should use current tmux location with --current-tmux flag', async () => {
      process.argv = ['node', 'cli.js', '-m', 'Current location', '--current-tmux']

      await runCli()

      expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled()
      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Current location',
        session: 'current',
        window: '1',
        pane: '0',
      })
    })

    it('should handle missing current tmux info', async () => {
      mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null)
      process.argv = ['node', 'cli.js', '-m', 'Test', '--current-tmux']

      await runCli()

      // When --current returns null, it should exit with error
      expect(exitCode).toBe(1)
      expect(mockNotifier.sendNotification).not.toHaveBeenCalled()
      expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Not in a tmux session')
    })

    it('should validate session exists', async () => {
      mockNotifier.sessionExists.mockResolvedValue(false)
      process.argv = [
        'node',
        'cli.js',
        '-m',
        'Test',
        '-s',
        'nonexistent',
      ]

      await runCli()

      expect(mockNotifier.sessionExists).toHaveBeenCalledWith('nonexistent')
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        "Error: Session 'nonexistent' does not exist",
      )
      expect(exitCode).toBe(1)
    })
  })

  describe('argument parsing', () => {
    it('should handle long form arguments', async () => {
      process.argv = [
        'node',
        'cli.js',
        '--message',
        'Long form',
        '--title',
        'Title',
        '--session',
        'session1',
        '--window',
        '1',
        '--pane',
        '0',
      ]

      await runCli()

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Long form',
        title: 'Title',
        session: 'session1',
        window: '1',
        pane: '0',
      })
    })

    it('should error when no message provided', async () => {
      process.argv = ['node', 'cli.js']

      await runCli()

      expect(consoleErrorSpy).toHaveBeenCalledWith(
        'Error: Message is required (-m option)',
      )
      expect(exitCode).toBe(1)
    })

    it('should handle spaces in arguments', async () => {
      process.argv = [
        'node',
        'cli.js',
        '-m',
        'Message with spaces',
        '-t',
        'Title with spaces',
      ]

      await runCli()

      expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
        message: 'Message with spaces',
        title: 'Title with spaces',
      })
    })
  })

  describe('error handling', () => {
    it('should handle notification sending errors', async () => {
      const error = new Error('Failed to send notification')
      mockNotifier.sendNotification.mockRejectedValue(error)
      process.argv = ['node', 'cli.js', '-m', 'Test']

      await runCli()

      expect(consoleErrorSpy).toHaveBeenCalledWith(
        'Failed to send notification:',
        error,
      )
      expect(exitCode).toBe(1)
    })

    it('should handle list sessions errors', async () => {
      // The notifier catches errors and returns empty array
      mockNotifier.listSessions.mockResolvedValue([])
      process.argv = ['node', 'cli.js', '--list-sessions']

      await runCli()

      expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:')
      expect(exitCode).toBe(0)
    })
  })
})
```

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

```swift
#!/usr/bin/env swift

import Foundation
import UserNotifications
import Cocoa

class MacOSNotifyMCP: NSObject, UNUserNotificationCenterDelegate {
    private let center = UNUserNotificationCenter.current()
    
    override init() {
        super.init()
        center.delegate = self
    }
    
    func requestPermissionAndSendNotification(
        title: String, 
        message: String, 
        sound: String = "default",
        session: String? = nil,
        window: String? = nil,
        pane: String? = nil,
        terminal: String? = nil
    ) {
        center.requestAuthorization(options: [.alert, .sound]) { granted, error in
            if granted {
                self.sendNotification(
                    title: title,
                    message: message,
                    sound: sound,
                    session: session,
                    window: window,
                    pane: pane,
                    terminal: terminal
                )
            } else {
                print("Notification permission denied")
                exit(1)
            }
        }
    }
    
    private func sendNotification(
        title: String,
        message: String,
        sound: String,
        session: String?,
        window: String?,
        pane: String?,
        terminal: String?
    ) {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = message
        if sound == "default" {
            content.sound = .default
        } else {
            content.sound = UNNotificationSound(named: UNNotificationSoundName(sound + ".aiff"))
        }
        
        // tmux情報とターミナル情報をuserInfoに格納
        var userInfo: [String: Any] = [:]
        if let session = session {
            userInfo["session"] = session
            if let window = window {
                userInfo["window"] = window
            }
            if let pane = pane {
                userInfo["pane"] = pane
            }
        }
        if let terminal = terminal {
            userInfo["terminal"] = terminal
        }
        content.userInfo = userInfo
        
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: nil
        )
        
        center.add(request) { error in
            if let error = error {
                print("Notification error: \(error)")
                exit(1)
            }
            print("Notification sent")
        }
    }
    
    // Handle notification click
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        let terminal = userInfo["terminal"] as? String
        
        if let session = userInfo["session"] as? String {
            focusToTmux(
                session: session,
                window: userInfo["window"] as? String,
                pane: userInfo["pane"] as? String,
                terminal: terminal
            )
        } else if let terminal = terminal {
            // tmuxセッションがない場合でもターミナルをアクティブ化
            activateTerminal(preferredTerminal: terminal)
        }
        
        completionHandler()
        
        // Exit after handling click
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            NSApplication.shared.terminate(nil)
        }
    }
    
    private func focusToTmux(session: String, window: String?, pane: String?, terminal: String?) {
        // Activate terminal
        activateTerminal(preferredTerminal: terminal)
        
        // Execute tmux commands
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            let tmuxPath = self.findTmuxPath()
            guard !tmuxPath.isEmpty else { return }
            
            // Switch to session
            var tmuxTarget = session
            if let window = window {
                tmuxTarget += ":\(window)"
                if let pane = pane {
                    tmuxTarget += ".\(pane)"
                }
            }
            
            self.runCommand(tmuxPath, args: ["switch-client", "-t", tmuxTarget])
        }
    }
    
    private func activateTerminal(preferredTerminal: String? = nil) {
        // ターミナルタイプからアプリケーション名へのマッピング
        let terminalMap: [String: String] = [
            "VSCode": "Visual Studio Code",
            "Cursor": "Cursor",
            "iTerm2": "iTerm2",
            "Terminal": "Terminal",
            "alacritty": "Alacritty"
        ]
        
        // 検出されたターミナルを優先的に使用
        if let preferred = preferredTerminal,
           let appName = terminalMap[preferred] {
            if isAppRunning(appName) {
                runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(appName)\" to activate"])
                return
            }
        }
        
        // フォールバック: 実行中のターミナルを探す
        let terminals = ["Alacritty", "iTerm2", "WezTerm", "Terminal", "Visual Studio Code", "Cursor"]
        
        for terminal in terminals {
            if isAppRunning(terminal) {
                runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(terminal)\" to activate"])
                return
            }
        }
        
        // Default to Terminal.app
        runCommand("/usr/bin/osascript", args: ["-e", "tell application \"Terminal\" to activate"])
    }
    
    private func isAppRunning(_ appName: String) -> Bool {
        let task = Process()
        task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
        task.arguments = ["-f", appName]
        task.standardOutput = Pipe()
        
        do {
            try task.run()
            task.waitUntilExit()
            return task.terminationStatus == 0
        } catch {
            return false
        }
    }
    
    private func findTmuxPath() -> String {
        let paths = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]
        
        for path in paths {
            if FileManager.default.fileExists(atPath: path) {
                return path
            }
        }
        
        // Search using which
        let task = Process()
        task.executableURL = URL(fileURLWithPath: "/usr/bin/which")
        task.arguments = ["tmux"]
        let pipe = Pipe()
        task.standardOutput = pipe
        
        do {
            try task.run()
            task.waitUntilExit()
            
            if task.terminationStatus == 0 {
                let data = pipe.fileHandleForReading.readDataToEndOfFile()
                if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
                    return output
                }
            }
        } catch {}
        
        return ""
    }
    
    @discardableResult
    private func runCommand(_ path: String, args: [String]) -> Bool {
        let task = Process()
        task.executableURL = URL(fileURLWithPath: path)
        task.arguments = args
        
        do {
            try task.run()
            task.waitUntilExit()
            return task.terminationStatus == 0
        } catch {
            return false
        }
    }
}

// Main process
let app = NSApplication.shared
app.setActivationPolicy(.accessory) // Run in background (no Dock icon, but can show notification icons)

// Parse arguments
var title = "Claude Code"
var message = ""
var session: String?
var window: String?
var pane: String?
var sound = "default"
var terminal: String?

var i = 1
let args = CommandLine.arguments
while i < args.count {
    switch args[i] {
    case "-t", "--title":
        if i + 1 < args.count {
            title = args[i + 1]
            i += 1
        }
    case "-m", "--message":
        if i + 1 < args.count {
            message = args[i + 1]
            i += 1
        }
    case "-s", "--session":
        if i + 1 < args.count {
            session = args[i + 1]
            i += 1
        }
    case "-w", "--window":
        if i + 1 < args.count {
            window = args[i + 1]
            i += 1
        }
    case "-p", "--pane":
        if i + 1 < args.count {
            pane = args[i + 1]
            i += 1
        }
    case "--sound":
        if i + 1 < args.count {
            sound = args[i + 1]
            i += 1
        }
    case "--terminal":
        if i + 1 < args.count {
            terminal = args[i + 1]
            i += 1
        }
    case "-h", "--help":
        print("""
        Usage:
          MacOSNotifyMCP [options]
        
        Options:
          -t, --title <text>      Notification title (default: "Claude Code")
          -m, --message <text>    Notification message (required)
          -s, --session <name>    tmux session name
          -w, --window <number>   tmux window number
          -p, --pane <number>     tmux pane number
          --sound <name>          Notification sound (default: "default")
          --terminal <type>       Terminal type (VSCode, Cursor, iTerm2, etc.)
        
        Examples:
          MacOSNotifyMCP -m "Build completed"
          MacOSNotifyMCP -t "Build" -m "Success" -s development -w 1 -p 0
        """)
        exit(0)
    default:
        break
    }
    i += 1
}

// Message is required
if message.isEmpty {
    print("Error: Message is required (-m option)")
    exit(1)
}

// Create MacOSNotifyMCP instance and send notification
let notifier = MacOSNotifyMCP()

// Send notification and wait in RunLoop
notifier.requestPermissionAndSendNotification(
    title: title,
    message: message,
    sound: sound,
    session: session,
    window: window,
    pane: pane,
    terminal: terminal
)

// Run the app
app.run()
```

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

```typescript
import {
  afterEach,
  beforeEach,
  describe,
  expect,
  it,
  type Mock,
  vi,
} from 'vitest'
import { TmuxNotifier } from '../src/notifier'
import type { ChildProcess } from 'node:child_process'

// Mock modules
vi.mock('node:child_process')
vi.mock('node:fs')
vi.mock('node:url', () => ({
  fileURLToPath: vi.fn(() => '/mocked/path/notifier.js'),
}))

describe('TmuxNotifier', () => {
  let notifier: TmuxNotifier
  let mockSpawn: Mock
  let mockExistsSync: Mock

  beforeEach(async () => {
    // Reset mocks
    vi.clearAllMocks()

    // Get mocked functions
    const childProcess = await import('node:child_process')
    const fs = await import('node:fs')
    mockSpawn = childProcess.spawn as unknown as Mock
    mockExistsSync = fs.existsSync as unknown as Mock

    // Default mock implementations
    mockExistsSync.mockReturnValue(true)
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  describe('constructor', () => {
    it('should use custom app path when provided', () => {
      const customPath = '/custom/path/to/MacOSNotifyMCP.app'
      notifier = new TmuxNotifier(customPath)
      expect(notifier['appPath']).toBe(customPath)
    })

    it('should find app in default locations when no custom path provided', () => {
      mockExistsSync.mockImplementation((path: string) => {
        return path.includes('MacOSNotifyMCP/MacOSNotifyMCP.app')
      })

      notifier = new TmuxNotifier()
      expect(notifier['appPath']).toContain('MacOSNotifyMCP.app')
    })

    it('should handle missing app gracefully', () => {
      mockExistsSync.mockReturnValue(false)
      notifier = new TmuxNotifier()
      // Should default to first possible path even if it doesn't exist
      expect(notifier['appPath']).toContain('MacOSNotifyMCP.app')
    })
  })

  describe('runCommand', () => {
    beforeEach(() => {
      notifier = new TmuxNotifier('/test/app/path')
    })

    it('should execute command successfully', async () => {
      const mockProcess = createMockProcess()
      mockSpawn.mockReturnValue(mockProcess)

      const result = notifier['runCommand']('echo', ['hello'])

      // Simulate successful execution
      mockProcess.stdout.emit('data', Buffer.from('hello'))
      mockProcess.emit('close', 0)

      expect(await result).toBe('hello')
      expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello'])
    })

    it('should handle command failure with non-zero exit code', async () => {
      const mockProcess = createMockProcess()
      mockSpawn.mockReturnValue(mockProcess)

      const promise = notifier['runCommand']('false', [])

      // Simulate failure
      mockProcess.stderr.emit('data', Buffer.from('Command failed'))
      mockProcess.emit('close', 1)

      await expect(promise).rejects.toThrow('Command failed: false')
    })

    it('should handle spawn errors', async () => {
      const mockProcess = createMockProcess()
      mockSpawn.mockReturnValue(mockProcess)

      const promise = notifier['runCommand']('nonexistent', [])

      // Simulate spawn error
      mockProcess.emit('error', new Error('spawn ENOENT'))

      await expect(promise).rejects.toThrow('spawn ENOENT')
    })

    it('should accumulate stdout and stderr data', async () => {
      const mockProcess = createMockProcess()
      mockSpawn.mockReturnValue(mockProcess)

      const result = notifier['runCommand']('test', [])

      // Emit multiple data chunks
      mockProcess.stdout.emit('data', Buffer.from('Hello '))
      mockProcess.stdout.emit('data', Buffer.from('World'))
      mockProcess.stderr.emit('data', Buffer.from('Warning'))
      mockProcess.emit('close', 0)

      expect(await result).toBe('Hello World')
    })
  })

  describe('getCurrentTmuxInfo', () => {
    beforeEach(() => {
      notifier = new TmuxNotifier('/test/app/path')
    })

    it('should return current tmux session info', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValueOnce('my-session')
        .mockResolvedValueOnce('1')
        .mockResolvedValueOnce('0')

      const result = await notifier.getCurrentTmuxInfo()

      expect(result).toEqual({
        session: 'my-session',
        window: '1',
        pane: '0',
      })
      expect(runCommandSpy).toHaveBeenCalledTimes(3)
    })

    it('should return null when not in tmux session', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockRejectedValue(new Error('not in tmux'))

      const result = await notifier.getCurrentTmuxInfo()

      expect(result).toBeNull()
      expect(runCommandSpy).toHaveBeenCalledTimes(1)
    })
  })

  describe('listSessions', () => {
    beforeEach(() => {
      notifier = new TmuxNotifier('/test/app/path')
    })

    it('should return list of tmux sessions', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('session1\nsession2\nsession3\n')

      const result = await notifier.listSessions()

      expect(result).toEqual(['session1', 'session2', 'session3'])
      expect(runCommandSpy).toHaveBeenCalledWith('tmux', [
        'list-sessions',
        '-F',
        '#{session_name}',
      ])
    })

    it('should return empty array when no sessions exist', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockRejectedValue(new Error('no sessions'))

      const result = await notifier.listSessions()

      expect(result).toEqual([])
    })

    it('should filter out empty lines', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('session1\n\nsession2\n\n')

      const result = await notifier.listSessions()

      expect(result).toEqual(['session1', 'session2'])
    })
  })

  describe('sessionExists', () => {
    beforeEach(() => {
      notifier = new TmuxNotifier('/test/app/path')
    })

    it('should return true when session exists', async () => {
      const listSessionsSpy = vi
        .spyOn(notifier, 'listSessions')
        .mockResolvedValue(['session1', 'session2', 'my-session'])

      const result = await notifier.sessionExists('my-session')

      expect(result).toBe(true)
      expect(listSessionsSpy).toHaveBeenCalled()
    })

    it('should return false when session does not exist', async () => {
      const listSessionsSpy = vi
        .spyOn(notifier, 'listSessions')
        .mockResolvedValue(['session1', 'session2'])

      const result = await notifier.sessionExists('nonexistent')

      expect(result).toBe(false)
    })

    it('should handle empty session list', async () => {
      const listSessionsSpy = vi
        .spyOn(notifier, 'listSessions')
        .mockResolvedValue([])

      const result = await notifier.sessionExists('any-session')

      expect(result).toBe(false)
    })
  })

  describe('sendNotification', () => {
    beforeEach(() => {
      notifier = new TmuxNotifier('/test/app/path')
    })

    it('should send basic notification with message only', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('')

      await notifier.sendNotification({ message: 'Hello World' })

      expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [
        '-n',
        '/test/app/path',
        '--args',
        '-t',
        'macos-notify-mcp',
        '-m',
        'Hello World',
        '--sound',
        'Glass',
      ])
    })

    it('should send notification with all options', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('')

      await notifier.sendNotification({
        message: 'Test message',
        title: 'Test Title',
        sound: 'Glass',
        session: 'my-session',
        window: '2',
        pane: '1',
      })

      expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [
        '-n',
        '/test/app/path',
        '--args',
        '-t',
        'Test Title',
        '-m',
        'Test message',
        '--sound',
        'Glass',
        '-s',
        'my-session',
        '-w',
        '2',
        '-p',
        '1',
      ])
    })

    it('should handle empty app path', async () => {
      notifier = new TmuxNotifier()
      notifier['appPath'] = ''

      await expect(
        notifier.sendNotification({ message: 'Test' }),
      ).rejects.toThrow('MacOSNotifyMCP.app not found')
    })

    it('should escape special characters in arguments', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('')

      await notifier.sendNotification({
        message: 'Message with "quotes"',
        title: "Title with 'apostrophes'",
      })

      const call = runCommandSpy.mock.calls[0]
      expect(call[1]).toContain('Message with "quotes"')
      expect(call[1]).toContain('-t')
      expect(call[1]).toContain("Title with 'apostrophes'")
    })

    it('should omit undefined optional parameters', async () => {
      const runCommandSpy = vi
        .spyOn(notifier as any, 'runCommand')
        .mockResolvedValue('')

      await notifier.sendNotification({
        message: 'Simple message',
        title: 'Title',
        // Other options undefined
      })

      const args = runCommandSpy.mock.calls[0][1]
      expect(args).toEqual([
        '-n',
        '/test/app/path',
        '--args',
        '-t',
        'Title',
        '-m',
        'Simple message',
        '--sound',
        'Glass',
      ])
      expect(args).not.toContain('-s')
      expect(args).not.toContain('-w')
      expect(args).not.toContain('-p')
    })
  })
})

// Helper function to create a mock child process
function createMockProcess(): Partial<ChildProcess> {
  const EventEmitter = require('node:events').EventEmitter
  const stdout = new EventEmitter()
  const stderr = new EventEmitter()
  const process = new EventEmitter()

  return Object.assign(process, {
    stdout,
    stderr,
    stdin: {
      end: vi.fn(),
    },
    kill: vi.fn(),
    pid: 12345,
  }) as unknown as ChildProcess
}
```

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

```typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'

describe('MCP Server', () => {
  let mockNotifier: any
  let mockServer: any
  let handlers: Map<string, any>

  beforeEach(() => {
    // Reset module cache
    vi.resetModules()

    // Create handlers map
    handlers = new Map()

    // Create mock notifier
    mockNotifier = {
      sendNotification: vi.fn().mockResolvedValue(undefined),
      listSessions: vi.fn().mockResolvedValue(['session1', 'session2']),
      sessionExists: vi.fn().mockResolvedValue(true),
      getCurrentTmuxInfo: vi
        .fn()
        .mockResolvedValue({ session: 'current', window: '1', pane: '0' }),
    }

    // Mock the notifier module
    vi.doMock('../src/notifier', () => ({
      TmuxNotifier: vi.fn(() => mockNotifier),
    }))

    // Create mock server
    mockServer = {
      name: 'macos-notify-mcp',
      version: '0.1.0',
      setRequestHandler: vi.fn((schema: any, handler: any) => {
        handlers.set(schema, handler)
      }),
      connect: vi.fn(),
    }

    // Mock MCP SDK
    vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({
      Server: vi.fn(() => mockServer),
    }))

    vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
      StdioServerTransport: vi.fn(() => ({
        start: vi.fn(),
        close: vi.fn(),
      })),
    }))
  })

  afterEach(() => {
    vi.clearAllMocks()
  })

  async function loadServer() {
    await import('../src/index')
    return { handlers }
  }

  describe('Tool Registration', () => {
    it('should register all tools on initialization', async () => {
      const { handlers } = await loadServer()

      const listToolsHandler = handlers.get(ListToolsRequestSchema)
      expect(listToolsHandler).toBeDefined()

      const response = await listToolsHandler({ method: 'tools/list' })
      
      expect(response.tools).toHaveLength(3)
      
      const toolNames = response.tools.map((tool: any) => tool.name)
      expect(toolNames).toContain('send_notification')
      expect(toolNames).toContain('list_tmux_sessions')
      expect(toolNames).toContain('get_current_tmux_info')
    })

    it('should provide correct schema for send_notification tool', async () => {
      const { handlers } = await loadServer()

      const listToolsHandler = handlers.get(ListToolsRequestSchema)
      const response = await listToolsHandler({ method: 'tools/list' })
      
      const sendNotificationTool = response.tools.find(
        (tool: any) => tool.name === 'send_notification',
      )
      
      expect(sendNotificationTool).toBeDefined()
      expect(sendNotificationTool.description).toContain(
        'Send a macOS notification',
      )
      expect(sendNotificationTool.inputSchema.type).toBe('object')
      expect(sendNotificationTool.inputSchema.required).toContain('message')
      expect(
        sendNotificationTool.inputSchema.properties.message,
      ).toBeDefined()
      expect(sendNotificationTool.inputSchema.properties.title).toBeDefined()
      expect(sendNotificationTool.inputSchema.properties.sound).toBeDefined()
    })
  })

  describe('Tool Execution', () => {
    let callToolHandler: any

    beforeEach(async () => {
      const { handlers } = await loadServer()
      callToolHandler = handlers.get(CallToolRequestSchema)
    })

    describe('send_notification', () => {
      it('should send notification with message only', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'send_notification',
            arguments: {
              message: 'Test notification',
            },
          },
        }

        const response = await callToolHandler(request)

        expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
          message: 'Test notification',
        })
        expect(response.content).toHaveLength(1)
        expect(response.content[0].type).toBe('text')
        expect(response.content[0].text).toBe('Notification sent: "Test notification"')
      })

      it('should send notification with all parameters', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'send_notification',
            arguments: {
              message: 'Full notification',
              title: 'Important',
              sound: 'Glass',
              session: 'work',
              window: '2',
              pane: '1',
            },
          },
        }

        const response = await callToolHandler(request)

        expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
          message: 'Full notification',
          title: 'Important',
          sound: 'Glass',
          session: 'work',
          window: '2',
          pane: '1',
        })
        expect(response.content[0].text).toBe('Notification sent: "Full notification" (tmux: work)')
      })

      it('should handle missing required message parameter', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'send_notification',
            arguments: {
              title: 'No message',
            },
          },
        }

        const response = await callToolHandler(request)
        expect(response.content[0].text).toBe('Error: Message is required')
      })

      it('should handle notification sending errors', async () => {
        mockNotifier.sendNotification.mockRejectedValue(
          new Error('Failed to send'),
        )

        const request = {
          method: 'tools/call',
          params: {
            name: 'send_notification',
            arguments: {
              message: 'Will fail',
            },
          },
        }

        const response = await callToolHandler(request)
        expect(response.content[0].text).toBe('Error: Failed to send')
      })

      it('should convert non-string parameters to strings', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'send_notification',
            arguments: {
              message: 123,
              title: true,
              window: 456,
              pane: null,
            } as any,
          },
        }

        const response = await callToolHandler(request)

        expect(mockNotifier.sendNotification).toHaveBeenCalledWith({
          message: '123',
          title: 'true',
          window: '456',
          // pane is not included because null is filtered out
        })
      })
    })

    describe('list_tmux_sessions', () => {
      it('should list tmux sessions', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'list_tmux_sessions',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)

        expect(mockNotifier.listSessions).toHaveBeenCalled()
        expect(response.content).toHaveLength(1)
        expect(response.content[0].type).toBe('text')
        expect(response.content[0].text).toContain('session1')
        expect(response.content[0].text).toContain('session2')
      })

      it('should handle empty session list', async () => {
        mockNotifier.listSessions.mockResolvedValue([])

        const request = {
          method: 'tools/call',
          params: {
            name: 'list_tmux_sessions',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)

        expect(response.content[0].text).toBe('No tmux sessions found')
      })

      it('should handle errors when listing sessions', async () => {
        mockNotifier.listSessions.mockRejectedValue(
          new Error('Tmux not available'),
        )

        const request = {
          method: 'tools/call',
          params: {
            name: 'list_tmux_sessions',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)
        expect(response.content[0].text).toBe('Error: Tmux not available')
      })
    })

    describe('get_current_tmux_info', () => {
      it('should get current tmux info', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'get_current_tmux_info',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)

        expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled()
        expect(response.content).toHaveLength(1)
        expect(response.content[0].type).toBe('text')
        const text = response.content[0].text
        expect(text).toContain('Session: current')
        expect(text).toContain('Window: 1')
        expect(text).toContain('Pane: 0')
      })

      it('should handle when not in tmux session', async () => {
        mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null)

        const request = {
          method: 'tools/call',
          params: {
            name: 'get_current_tmux_info',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)

        expect(response.content[0].text).toBe('Not in a tmux session')
      })

      it('should handle errors when getting tmux info', async () => {
        mockNotifier.getCurrentTmuxInfo.mockRejectedValue(
          new Error('Tmux error'),
        )

        const request = {
          method: 'tools/call',
          params: {
            name: 'get_current_tmux_info',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)
        expect(response.content[0].text).toBe('Error: Tmux error')
      })
    })

    describe('unknown tool', () => {
      it('should handle unknown tool name', async () => {
        const request = {
          method: 'tools/call',
          params: {
            name: 'unknown_tool',
            arguments: {},
          },
        }

        const response = await callToolHandler(request)
        expect(response.content[0].text).toBe('Error: Unknown tool: unknown_tool')
      })
    })
  })

  describe('Server Lifecycle', () => {
    it('should create server with correct configuration', async () => {
      await loadServer()

      const { Server } = await import('@modelcontextprotocol/sdk/server/index.js')
      expect(Server).toHaveBeenCalledWith({
        name: 'macos-notify-mcp',
        version: expect.any(String),
      }, expect.any(Object))
    })

    it('should connect transport', async () => {
      await loadServer()

      const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
      expect(StdioServerTransport).toHaveBeenCalled()
    })

    it('should set up error handling', async () => {
      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
      
      await loadServer()
      
      // Simulate an error event
      const errorHandler = mockServer.onerror || mockServer.setRequestHandler.mock.calls.find(
        (call: any) => call[0] === 'error'
      )?.[1]

      if (errorHandler) {
        const testError = new Error('Test error')
        errorHandler(testError)
        expect(consoleErrorSpy).toHaveBeenCalledWith('Server error:', testError)
      }

      consoleErrorSpy.mockRestore()
    })
  })
})
```

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

```typescript
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

interface NotificationOptions {
  title?: string
  message: string
  sound?: string
  session?: string
  window?: string
  pane?: string
}

interface TmuxInfo {
  session: string
  window: string
  pane: string
}

interface CommandError extends Error {
  code?: number
  stderr?: string
  stdout?: string
}

export type TerminalType =
  | 'VSCode'
  | 'Cursor'
  | 'iTerm2'
  | 'Terminal'
  | 'alacritty'
  | 'Unknown'

export class TmuxNotifier {
  private appPath = ''
  private defaultTitle = 'macos-notify-mcp'

  constructor(customAppPath?: string) {
    if (customAppPath) {
      this.appPath = customAppPath
    } else {
      // Try multiple locations
      const possiblePaths = [
        // Relative to the package installation (primary location)
        join(
          dirname(fileURLToPath(import.meta.url)),
          '..',
          'MacOSNotifyMCP',
          'MacOSNotifyMCP.app',
        ),
        // Development path
        join(process.cwd(), 'MacOSNotifyMCP', 'MacOSNotifyMCP.app'),
      ]

      // Find the first existing path
      for (const path of possiblePaths) {
        if (existsSync(path)) {
          this.appPath = path
          break
        }
      }

      // Default to package-relative path
      if (!this.appPath) {
        this.appPath = possiblePaths[0]
      }
    }

    // Get repository name as default title
    this.initializeDefaultTitle()
  }

  /**
   * Initialize default title from git repository name
   */
  private async initializeDefaultTitle(): Promise<void> {
    try {
      const repoName = await this.getGitRepoName()
      if (repoName) {
        this.defaultTitle = repoName
      }
    } catch (_error) {
      // Keep default title if git command fails
    }
  }

  /**
   * Get the active tmux client information
   */
  private async getActiveClientInfo(): Promise<{
    tty: string
    session: string
    activity: string
  } | null> {
    try {
      // Get list of all clients attached to the current session
      const currentSession = process.env.TMUX_PANE
        ? await this.runCommand('tmux', [
            'display-message',
            '-p',
            '#{session_name}',
          ])
        : null

      if (!currentSession) return null

      // Get all clients attached to this session
      const clientsOutput = await this.runCommand('tmux', [
        'list-clients',
        '-t',
        currentSession.trim(),
        '-F',
        '#{client_tty}|#{client_session}|#{client_activity}',
      ])

      const clients = clientsOutput
        .trim()
        .split('\n')
        .filter(Boolean)
        .map((line) => {
          const [tty, session, activity] = line.split('|')
          return { tty, session, activity: Number(activity) }
        })

      // Get the most recently active client
      const activeClient = clients.reduce((prev, curr) =>
        curr.activity > prev.activity ? curr : prev,
      )

      return {
        tty: activeClient.tty,
        session: activeClient.session,
        activity: activeClient.activity.toString(),
      }
    } catch (_error) {
      return null
    }
  }

  /**
   * Detect terminal emulator from client TTY
   */
  private async detectTerminalFromClient(
    clientTty: string,
  ): Promise<TerminalType> {
    try {
      // Find processes using this TTY
      const lsofOutput = await this.runCommand('lsof', [clientTty])
      const lines = lsofOutput.trim().split('\n').slice(1) // Skip header

      for (const line of lines) {
        const parts = line.split(/\s+/)
        if (parts.length < 2) continue

        const pid = parts[1]
        // Get process info
        const psOutput = await this.runCommand('ps', ['-p', pid, '-o', 'comm='])
        const command = psOutput.trim()

        // Check for known terminal emulators
        if (command.includes('Cursor')) return 'Cursor'
        if (command.includes('Code')) return 'VSCode'
        if (command.includes('iTerm2')) return 'iTerm2'
        if (command.includes('Terminal')) return 'Terminal'
        if (command.includes('alacritty')) return 'alacritty'
      }
    } catch (_error) {
      // lsof might fail, continue with other methods
    }

    return 'Unknown'
  }

  /**
   * Detect the parent terminal emulator
   */
  private async detectTerminalEmulator(): Promise<TerminalType> {
    // 1. Check for Cursor via CURSOR_TRACE_ID
    if (process.env.CURSOR_TRACE_ID) {
      return 'Cursor'
    }

    // 2. Check for VSCode/Cursor via VSCODE_IPC_HOOK_CLI
    if (process.env.VSCODE_IPC_HOOK_CLI) {
      // Check if it's Cursor by looking for cursor-specific paths
      if (process.env.VSCODE_IPC_HOOK_CLI.includes('Cursor')) {
        return 'Cursor'
      }
      return 'VSCode'
    }

    // 3. Check for VSCode Remote (for tmux attached from VSCode)
    if (process.env.VSCODE_REMOTE || process.env.VSCODE_PID) {
      return 'VSCode'
    }

    // 4. Check for alacritty via specific environment variables
    if (process.env.ALACRITTY_WINDOW_ID || process.env.ALACRITTY_SOCKET) {
      return 'alacritty'
    }

    // 5. Check TERM_PROGRAM for iTerm2 and Terminal.app
    if (process.env.TERM_PROGRAM) {
      if (process.env.TERM_PROGRAM === 'iTerm.app') {
        return 'iTerm2'
      }
      if (process.env.TERM_PROGRAM === 'Apple_Terminal') {
        return 'Terminal'
      }
      if (process.env.TERM_PROGRAM === 'alacritty') {
        return 'alacritty'
      }
    }

    // 6. If we're in tmux, try to detect the active client's terminal
    if (process.env.TMUX) {
      try {
        // Get active client info
        const clientInfo = await this.getActiveClientInfo()
        if (clientInfo) {
          const detectedTerminal = await this.detectTerminalFromClient(
            clientInfo.tty,
          )
          if (detectedTerminal !== 'Unknown') {
            return detectedTerminal
          }
        }

        // Fallback: Get the tmux client's terminal info
        const clientTerm = await this.runCommand('tmux', [
          'display-message',
          '-p',
          '#{client_termname}',
        ])

        // Check for specific terminal indicators in the client termname
        if (clientTerm.includes('iterm') || clientTerm.includes('iTerm')) {
          return 'iTerm2'
        }
        if (clientTerm.includes('Apple_Terminal')) {
          return 'Terminal'
        }

        // Also check tmux client environment variables
        try {
          const clientEnv = await this.runCommand('tmux', [
            'show-environment',
            '-g',
            'TERM_PROGRAM',
          ])
          if (clientEnv.includes('TERM_PROGRAM=iTerm.app')) {
            return 'iTerm2'
          }
          if (clientEnv.includes('TERM_PROGRAM=Apple_Terminal')) {
            return 'Terminal'
          }
        } catch (_) {
          // Ignore if show-environment fails
        }
      } catch (_) {
        // Ignore tmux command failures
      }
    }

    // 3. Fallback: Check process tree
    try {
      // Get the parent process ID chain
      let currentPid = process.pid
      const maxDepth = 10 // Prevent infinite loops

      for (let i = 0; i < maxDepth; i++) {
        // Get parent process info using ps command
        const psOutput = await this.runCommand('ps', [
          '-p',
          currentPid.toString(),
          '-o',
          'ppid=,comm=',
        ])

        const [ppidStr, command] = psOutput.trim().split(/\s+/, 2)
        const ppid = Number.parseInt(ppidStr)

        if (!ppid || ppid === 1) {
          break // Reached init process
        }

        // Check if the command matches known terminal emulators
        if (command) {
          if (command.includes('Cursor')) {
            return 'Cursor'
          }
          if (command.includes('Code') || command.includes('code-insiders')) {
            return 'VSCode'
          }
          if (command.includes('iTerm2')) {
            return 'iTerm2'
          }
          if (command.includes('Terminal')) {
            return 'Terminal'
          }
        }

        currentPid = ppid
      }
    } catch (_error) {
      // Ignore errors in process tree detection
    }

    return 'Unknown'
  }

  /**
   * Get git repository name from current directory
   */
  private async getGitRepoName(): Promise<string | null> {
    try {
      // Get the remote URL
      const remoteUrl = (
        await this.runCommand('git', ['config', '--get', 'remote.origin.url'])
      ).trim()

      if (!remoteUrl) {
        // If no remote, try to get the directory name of the git root
        const gitRoot = (
          await this.runCommand('git', ['rev-parse', '--show-toplevel'])
        ).trim()
        return gitRoot.split('/').pop() || null
      }

      // Extract repo name from URL
      // Handle both HTTPS and SSH formats
      // https://github.com/user/repo.git
      // [email protected]:user/repo.git
      const match = remoteUrl.match(/[/:]([\w-]+)\/([\w-]+?)(\.git)?$/)
      if (match) {
        return match[2]
      }

      // Fallback to directory name
      const gitRoot = (
        await this.runCommand('git', ['rev-parse', '--show-toplevel'])
      ).trim()
      return gitRoot.split('/').pop() || null
    } catch (_error) {
      return null
    }
  }

  /**
   * Run a command and return the output
   */
  private async runCommand(command: string, args: string[]): Promise<string> {
    return new Promise((resolve, reject) => {
      const proc = spawn(command, args)
      let stdout = ''
      let stderr = ''

      proc.stdout.on('data', (data) => {
        stdout += data.toString()
      })

      proc.stderr.on('data', (data) => {
        stderr += data.toString()
      })

      proc.on('close', (code) => {
        if (code === 0) {
          resolve(stdout)
        } else {
          const error = new Error(
            `Command failed: ${command} ${args.join(' ')}\n${stderr}`,
          ) as CommandError
          error.code = code ?? undefined
          error.stderr = stderr
          error.stdout = stdout
          reject(error)
        }
      })

      proc.on('error', reject)
    })
  }

  /**
   * Get current tmux session info
   */
  async getCurrentTmuxInfo(): Promise<TmuxInfo | null> {
    try {
      const session = (
        await this.runCommand('tmux', [
          'display-message',
          '-p',
          '#{session_name}',
        ])
      ).trim()
      const window = (
        await this.runCommand('tmux', [
          'display-message',
          '-p',
          '#{window_index}',
        ])
      ).trim()
      const pane = (
        await this.runCommand('tmux', [
          'display-message',
          '-p',
          '#{pane_index}',
        ])
      ).trim()

      return { session, window, pane }
    } catch (_error) {
      return null
    }
  }

  /**
   * List tmux sessions
   */
  async listSessions(): Promise<string[]> {
    try {
      const output = await this.runCommand('tmux', [
        'list-sessions',
        '-F',
        '#{session_name}',
      ])
      return output.trim().split('\n').filter(Boolean)
    } catch (_error) {
      return []
    }
  }

  /**
   * Check if a session exists
   */
  async sessionExists(session: string): Promise<boolean> {
    const sessions = await this.listSessions()
    return sessions.includes(session)
  }

  /**
   * Get the detected terminal emulator type
   */
  async getTerminalEmulator(): Promise<TerminalType> {
    return this.detectTerminalEmulator()
  }

  /**
   * Send notification
   */
  async sendNotification(options: NotificationOptions): Promise<void> {
    const {
      title = this.defaultTitle,
      message,
      sound = 'Glass',
      session,
      window,
      pane,
    } = options

    // Check if app path is valid
    if (!this.appPath) {
      throw new Error('MacOSNotifyMCP.app not found')
    }

    // Always detect terminal emulator to pass to notification app
    const terminal = await this.detectTerminalEmulator()

    // Use MacOSNotifyMCP.app for notifications
    const args = [
      '-n',
      this.appPath,
      '--args',
      '-t',
      title,
      '-m',
      message,
      '--sound',
      sound,
      '--terminal',
      terminal,
    ]

    if (session) {
      args.push('-s', session)
      if (window !== undefined && window !== '') {
        args.push('-w', window)
      }
      if (pane !== undefined && pane !== '') {
        args.push('-p', pane)
      }
    }

    await this.runCommand('/usr/bin/open', args)
  }
}

```