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

```
├── .gitignore
├── .npmrc
├── CONTRIBUTING.md
├── LICENSE
├── package.json
├── plan.md
├── pnpm-lock.yaml
├── README.md
├── src
│   ├── evals
│   │   └── evals.ts
│   ├── features
│   │   ├── fileSelect.ts
│   │   ├── notification.ts
│   │   ├── prompt.ts
│   │   ├── screenshot.ts
│   │   └── speech.ts
│   ├── index.ts
│   ├── types.ts
│   └── utils
│       └── command.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------

```
registry=https://registry.npmjs.org/
```

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

```
# Dependencies
node_modules/
.pnpm-store/

# Build
build/

# Logs
*.log
npm-debug.log*
pnpm-debug.log*

# OS
.DS_Store

# Lock files
package-lock.json
# Keep pnpm-lock.yaml

```

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

```markdown
[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/turlockmike-apple-notifier-mcp-badge.png)](https://mseep.ai/app/turlockmike-apple-notifier-mcp)

# Apple Notifier MCP Server

[![smithery badge](https://smithery.ai/badge/apple-notifier-mcp)](https://smithery.ai/server/apple-notifier-mcp)
Send native macOS notifications and interact with system dialogs through any MCP-compatible client like Claude Desktop or Cline.

<a href="https://glama.ai/mcp/servers/t1w1dq4wy4"><img width="380" height="200" src="https://glama.ai/mcp/servers/t1w1dq4wy4/badge" alt="apple-notifier-mcp MCP server" /></a>

## Prerequisites

- macOS
- Node.js >= 18
- An MCP-compatible client (Claude Desktop, Cline)

## Installation

### Installing via Smithery

To install Apple Notifier for Claude Desktop automatically via [Smithery](https://smithery.ai/server/apple-notifier-mcp):

```bash
npx -y @smithery/cli install apple-notifier-mcp --client claude
```

### Manual Installation
1. Install the package globally:
```bash
npm install -g apple-notifier-mcp
```

2. Add to your MCP configuration file:

For Cline (`cline_mcp_settings.json`):
```json
{
  "mcpServers": {
    "apple-notifier": {
      "command": "apple-notifier-mcp"
    }
  }
}
```

For Claude Desktop (`claude_desktop_config.json`):
```json
{
  "mcpServers": {
    "apple-notifier": {
      "command": "apple-notifier-mcp"
    }
  }
}
```

## Features

### Send Notifications

Display native macOS notifications with customizable content.

Parameters:
- `title` (required): string - The title of the notification
- `message` (required): string - The main message content
- `subtitle` (optional): string - A subtitle to display
- `sound` (optional): boolean - Whether to play the default notification sound (default: true)

### Display Prompts

Show interactive dialog prompts to get user input.

Parameters:
- `message` (required): string - Text to display in the prompt dialog
- `defaultAnswer` (optional): string - Default text to pre-fill
- `buttons` (optional): string[] - Custom button labels (max 3)
- `icon` (optional): 'note' | 'stop' | 'caution' - Icon to display

### Text-to-Speech

Use macOS text-to-speech capabilities.

Parameters:
- `text` (required): string - Text to speak
- `voice` (optional): string - Voice to use (defaults to system voice)
- `rate` (optional): number - Speech rate (-50 to 50, defaults to 0)

### Take Screenshots

Capture screenshots using macOS screencapture.

Parameters:
- `path` (required): string - Path where to save the screenshot
- `type` (required): 'fullscreen' | 'window' | 'selection' - Type of screenshot
- `format` (optional): 'png' | 'jpg' | 'pdf' | 'tiff' - Image format
- `hideCursor` (optional): boolean - Whether to hide the cursor
- `shadow` (optional): boolean - Whether to include window shadow (only for window type)
- `timestamp` (optional): boolean - Add timestamp to filename

### File Selection

Open native macOS file picker dialog.

Parameters:
- `prompt` (optional): string - Prompt message
- `defaultLocation` (optional): string - Default directory path
- `fileTypes` (optional): object - File type filter (e.g., {"public.image": ["png", "jpg"]})
- `multiple` (optional): boolean - Allow multiple file selection

## Example Usage

```typescript
// Send a notification
await client.use_mcp_tool("apple-notifier", "send_notification", {
  title: "Hello",
  message: "World",
  sound: true
});

// Show a prompt
const result = await client.use_mcp_tool("apple-notifier", "prompt_user", {
  message: "What's your name?",
  defaultAnswer: "John Doe",
  buttons: ["OK", "Cancel"]
});

// Speak text
await client.use_mcp_tool("apple-notifier", "speak", {
  text: "Hello, world!",
  voice: "Samantha",
  rate: -20
});

// Take a screenshot
await client.use_mcp_tool("apple-notifier", "take_screenshot", {
  path: "screenshot.png",
  type: "window",
  format: "png"
});

// Select files
const files = await client.use_mcp_tool("apple-notifier", "select_file", {
  prompt: "Select images",
  fileTypes: {
    "public.image": ["png", "jpg", "jpeg"]
  },
  multiple: true
});
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.



## Running evals

The evals package loads an mcp client that then runs the index.ts file, so there is no need to rebuild between tests. You can load environment variables by prefixing the npx command. Full documentation can be found [here](https://www.mcpevals.io/docs).

```bash
OPENAI_API_KEY=your-key  npx mcp-eval src/evals/evals.ts src/index.ts
```
## License

MIT License - see the [LICENSE](LICENSE) file for details.

```

--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------

```markdown
# Contributing to Apple Notifier MCP Server

First off, thank you for considering contributing to the Apple Notifier MCP Server! It's people like you that make it a great tool for everyone.

## Code of Conduct

This project and everyone participating in it is governed by our code of conduct. By participating, you are expected to uphold this code.

## How Can I Contribute?

### Reporting Bugs

Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:

* Use a clear and descriptive title
* Describe the exact steps which reproduce the problem
* Provide specific examples to demonstrate the steps
* Describe the behavior you observed after following the steps
* Explain which behavior you expected to see instead and why
* Include details about your configuration and environment:
  * Which version of Node.js are you using?
  * Which version of macOS are you using?
  * Which MCP client are you using (Claude Desktop, Cline, etc.)?

### Suggesting Enhancements

Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:

* A clear and descriptive title
* A detailed description of the proposed functionality
* Explain why this enhancement would be useful
* List any alternative solutions or features you've considered

### Pull Requests

* Fill in the required template
* Do not include issue numbers in the PR title
* Include screenshots and animated GIFs in your pull request whenever possible
* Follow the TypeScript styleguide
* Include thoughtfully-worded, well-structured tests (when we implement testing)
* Document new code
* End all files with a newline

## Development Process

1. Fork the repo and create your branch from `main`
2. Install pnpm if you haven't already: `npm install -g pnpm`
3. Install dependencies: `pnpm install`
4. Make your changes
5. Test your changes thoroughly
6. Ensure the code lints successfully
7. Create a Pull Request

### Styleguides

#### Git Commit Messages

* Use the present tense ("Add feature" not "Added feature")
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
* Limit the first line to 72 characters or less
* Reference issues and pull requests liberally after the first line

#### TypeScript Styleguide

* Use 2 spaces for indentation
* Prefer `const` over `let`
* Use meaningful variable names
* Add types for parameters and return values
* Use async/await over raw promises
* Document public methods with JSDoc comments

## Project Structure

```
apple-notifier-mcp/
├── src/                    # Source files
│   ├── index.ts           # Main MCP server implementation
│   ├── notifier.ts        # Core notification functionality
│   └── types.ts           # Type definitions
├── build/                 # Compiled files (git ignored)
├── package.json           # Project metadata and dependencies
└── tsconfig.json          # TypeScript configuration
```

## Setting Up Development Environment

1. Ensure you have Node.js >= 18 installed
2. Install pnpm: `npm install -g pnpm`
3. Clone your fork of the repository
4. Run `pnpm install` to install dependencies
5. Run `pnpm dev` to start in development mode
6. Make your changes
7. Test thoroughly with `pnpm test`
8. Submit a PR

### Available Scripts

- `pnpm build` - Build the project
- `pnpm start` - Start the MCP server
- `pnpm dev` - Start in watch mode for development
- `pnpm clean` - Remove build artifacts
- `pnpm test` - Run tests (to be implemented)

## CI/CD

This project uses GitHub Actions for continuous integration and deployment:

### Continuous Integration
- Runs on every push and pull request to `main`
- Tests on macOS with Node.js 18.x and 20.x
- Builds the project
- Runs tests
- Verifies osascript functionality
- Tests basic notification functionality

### Publishing to NPM
To publish a new version:
1. Update version in `package.json`
2. Create and push a new tag:
   ```bash
   git tag v1.0.0  # Use appropriate version
   git push origin v1.0.0
   ```
3. Create a new release on GitHub using the tag
4. The GitHub Action will automatically:
   - Build the project
   - Run tests
   - Publish to NPM

## Support

If you encounter any issues or have questions:
1. Check the [Issues](https://github.com/turlockmike/apple-notifier-mcp/issues) page
2. Open a new issue if your problem hasn't been reported
3. Provide as much detail as possible, including:
   - Node.js version
   - macOS version
   - Steps to reproduce
   - Expected vs actual behavior

```

--------------------------------------------------------------------------------
/src/utils/command.ts:
--------------------------------------------------------------------------------

```typescript
import { promisify } from 'util';
import { exec } from 'child_process';

export const execAsync = promisify(exec);

/**
 * Escapes special characters in strings for AppleScript
 */
export function escapeString(str: string): string {
  // Escape for both AppleScript and shell
  return str
    .replace(/'/g, "'\\''")
    .replace(/"/g, '\\"');
}

```

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

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "node",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}

```

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

```json
{
  "name": "apple-notifier-mcp",
  "version": "1.1.0",
  "description": "An MCP server for sending native macOS notifications",
  "packageManager": "[email protected]",
  "main": "build/index.js",
  "types": "build/index.d.ts",
  "bin": {
    "apple-notifier-mcp": "./build/index.js"
  },
  "files": [
    "build/**/*"
  ],
  "engines": {
    "node": ">=18"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/turlockmike/apple-notifier-mcp.git"
  },
  "keywords": [
    "mcp",
    "macos",
    "notifications",
    "osascript",
    "claude",
    "anthropic"
  ],
  "bugs": {
    "url": "https://github.com/turlockmike/apple-notifier-mcp/issues"
  },
  "homepage": "https://github.com/turlockmike/apple-notifier-mcp#readme",
  "scripts": {
    "build": "tsc && chmod +x build/index.js",
    "start": "node build/index.js",
    "dev": "tsc -w",
    "clean": "rm -rf build",
    "prepare": "pnpm clean && pnpm build",
    "prepublishOnly": "pnpm test",
    "test": "echo \"No tests yet\""
  },
  "type": "module",
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "@types/node": "^22.10.2",
    "typescript": "^5.7.2"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.4",
    "mcp-evals": "^1.0.18"
  }
}

```

--------------------------------------------------------------------------------
/src/features/speech.ts:
--------------------------------------------------------------------------------

```typescript
import { SpeechParams, NotificationError, NotificationErrorType } from '../types.js';
import { execAsync, escapeString } from '../utils/command.js';

/**
 * Validates speech parameters
 */
function validateSpeechParams(params: SpeechParams): void {
  if (!params.text || typeof params.text !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Text is required and must be a string'
    );
  }

  if (params.voice && typeof params.voice !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Voice must be a string'
    );
  }

  if (params.rate !== undefined) {
    if (typeof params.rate !== 'number') {
      throw new NotificationError(
        NotificationErrorType.INVALID_PARAMS,
        'Rate must be a number'
      );
    }
    if (params.rate < -50 || params.rate > 50) {
      throw new NotificationError(
        NotificationErrorType.INVALID_PARAMS,
        'Rate must be between -50 and 50'
      );
    }
  }
}

/**
 * Builds the say command for text-to-speech
 */
function buildSpeechCommand(params: SpeechParams): string {
  let command = 'say';
  
  if (params.voice) {
    command += ` -v "${escapeString(params.voice)}"`;
  }
  
  if (params.rate !== undefined) {
    command += ` -r ${params.rate}`;
  }
  
  command += ` "${escapeString(params.text)}"`;
  
  return command;
}

/**
 * Speaks text using macOS text-to-speech
 */
export async function speak(params: SpeechParams): Promise<void> {
  try {
    validateSpeechParams(params);
    const command = buildSpeechCommand(params);
    await execAsync(command);
  } catch (error) {
    if (error instanceof NotificationError) {
      throw error;
    }

    const err = error as Error;
    if (err.message.includes('execution error')) {
      throw new NotificationError(
        NotificationErrorType.COMMAND_FAILED,
        'Failed to execute speech command'
      );
    } else if (err.message.includes('permission')) {
      throw new NotificationError(
        NotificationErrorType.PERMISSION_DENIED,
        'Permission denied when trying to speak'
      );
    } else {
      throw new NotificationError(
        NotificationErrorType.UNKNOWN,
        `Unexpected error: ${err.message}`
      );
    }
  }
}

```

--------------------------------------------------------------------------------
/src/features/notification.ts:
--------------------------------------------------------------------------------

```typescript
import { NotificationParams, NotificationError, NotificationErrorType } from '../types.js';
import { execAsync, escapeString } from '../utils/command.js';

/**
 * Validates notification parameters
 */
function validateParams(params: NotificationParams): void {
  if (!params.title || typeof params.title !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Title is required and must be a string'
    );
  }

  if (!params.message || typeof params.message !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Message is required and must be a string'
    );
  }

  if (params.subtitle && typeof params.subtitle !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Subtitle must be a string'
    );
  }
}

/**
 * Builds the AppleScript command for sending a notification
 */
function buildNotificationCommand(params: NotificationParams): string {
  const { title, message, subtitle, sound = true } = params;
  
  let script = `display notification "${escapeString(message)}" with title "${escapeString(title)}"`;
  
  if (subtitle) {
    script += ` subtitle "${escapeString(subtitle)}"`;
  }
  
  if (sound) {
    script += ` sound name "default"`;
  }
  
  return `osascript -e '${script}'`;
}

/**
 * Sends a notification using osascript
 */
export async function sendNotification(params: NotificationParams): Promise<void> {
  try {
    validateParams(params);
    const command = buildNotificationCommand(params);
    await execAsync(command);
  } catch (error) {
    if (error instanceof NotificationError) {
      throw error;
    }

    // Handle different types of system errors
    const err = error as Error;
    if (err.message.includes('execution error')) {
      throw new NotificationError(
        NotificationErrorType.COMMAND_FAILED,
        'Failed to execute notification command'
      );
    } else if (err.message.includes('permission')) {
      throw new NotificationError(
        NotificationErrorType.PERMISSION_DENIED,
        'Permission denied when trying to send notification'
      );
    } else {
      throw new NotificationError(
        NotificationErrorType.UNKNOWN,
        `Unexpected error: ${err.message}`
      );
    }
  }
}

```

--------------------------------------------------------------------------------
/src/evals/evals.ts:
--------------------------------------------------------------------------------

```typescript
//evals.ts

import { EvalConfig } from 'mcp-evals';
import { openai } from "@ai-sdk/openai";
import { grade, EvalFunction } from "mcp-evals";

const send_notificationEval: EvalFunction = {
  name: 'Send Notification Tool Evaluation',
  description: 'Evaluates the send_notification tool by testing macOS notification functionality',
  run: async () => {
    const result = await grade(openai("gpt-4"), "Please send a macOS notification using the 'send_notification' tool with the title 'Greetings', message 'Hello from AI', subtitle 'Testing notifications', and disable sound.");
    return JSON.parse(result);
  }
};

const prompt_userEval: EvalFunction = {
    name: 'prompt_user Evaluation',
    description: 'Tests the functionality of the prompt_user tool by verifying dialog prompt input and options',
    run: async () => {
        const result = await grade(openai("gpt-4"), "Display a dialog prompt with the message 'Please enter your name:' using 'John Doe' as the default answer, custom buttons labeled 'Confirm' and 'Cancel', and an icon of 'note'.");
        return JSON.parse(result);
    }
};

const speakEval: EvalFunction = {
    name: 'speakEval',
    description: 'Evaluates the macOS text-to-speech speak tool',
    run: async () => {
        const result = await grade(openai("gpt-4"), "Could you please speak the text 'Hello, how are you today?' using the voice 'Samantha' at a rate of -10?");
        return JSON.parse(result);
    }
};

const take_screenshotEval: EvalFunction = {
    name: 'take_screenshot',
    description: 'Evaluates the take_screenshot tool functionality',
    run: async () => {
        const result = await grade(openai("gpt-4"), "Please take a fullscreen screenshot in PNG format and save it to /Users/myuser/Desktop/screenshot.png with the cursor hidden.");
        return JSON.parse(result);
    }
};

const select_fileEval: EvalFunction = {
    name: 'select_fileEval',
    description: 'Evaluates the behavior of the select_file tool by verifying its ability to open a file dialog with specified parameters',
    run: async () => {
        const result = await grade(openai("gpt-4"), "Open a file picker dialog with a prompt 'Select an image', default location '~/Pictures', file type filter to .png and .jpg, and allow multiple selections.");
        return JSON.parse(result);
    }
};

const config: EvalConfig = {
    model: openai("gpt-4"),
    evals: [send_notificationEval, prompt_userEval, speakEval, take_screenshotEval, select_fileEval]
};
  
export default config;
  
export const evals = [send_notificationEval, prompt_userEval, speakEval, take_screenshotEval, select_fileEval];
```

--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Parameters for sending a notification
 */
export interface NotificationParams {
  /** Title of the notification */
  title: string;
  /** Main message content */
  message: string;
  /** Optional subtitle */
  subtitle?: string;
  /** Whether to play the default notification sound */
  sound?: boolean;
}

/**
 * Error types that can occur during notification operations
 */
export enum NotificationErrorType {
  INVALID_PARAMS = 'INVALID_PARAMS',
  COMMAND_FAILED = 'COMMAND_FAILED',
  PERMISSION_DENIED = 'PERMISSION_DENIED',
  PROMPT_CANCELLED = 'PROMPT_CANCELLED',
  UNKNOWN = 'UNKNOWN'
}

/**
 * Parameters for prompting user input
 */
export interface PromptParams {
  /** Text to display in the prompt dialog */
  message: string;
  /** Optional default text to pre-fill */
  defaultAnswer?: string;
  /** Optional custom button labels */
  buttons?: string[];
  /** Optional icon name to display (note, stop, caution) */
  icon?: 'note' | 'stop' | 'caution';
}

/**
 * Response from a prompt dialog
 */
export interface PromptResult {
  /** Text entered by the user, or undefined if cancelled */
  text?: string;
  /** Index of the button clicked (0-based) */
  buttonIndex: number;
}

/**
 * Parameters for text-to-speech
 */
export interface SpeechParams {
  /** Text to speak */
  text: string;
  /** Voice to use (defaults to system voice) */
  voice?: string;
  /** Speech rate (-50 to 50, defaults to 0) */
  rate?: number;
}

/**
 * Parameters for taking a screenshot
 */
export interface ScreenshotParams {
  /** Path where to save the screenshot */
  path: string;
  /** Type of screenshot to take */
  type: 'fullscreen' | 'window' | 'selection';
  /** Image format (png, jpg, pdf, tiff) */
  format?: 'png' | 'jpg' | 'pdf' | 'tiff';
  /** Whether to hide the cursor */
  hideCursor?: boolean;
  /** Whether to include the window shadow (only for window type) */
  shadow?: boolean;
  /** Timestamp to add to filename (defaults to current time) */
  timestamp?: boolean;
}

/**
 * Custom error class for notification operations
 */
/**
 * Parameters for file selection
 */
export interface FileSelectParams {
  /** Optional prompt message */
  prompt?: string;
  /** Optional default location */
  defaultLocation?: string;
  /** Optional file type filter (e.g., {"public.image": ["png", "jpg"]}) */
  fileTypes?: Record<string, string[]>;
  /** Whether to allow multiple selection */
  multiple?: boolean;
}

/**
 * Result from file selection
 */
export interface FileSelectResult {
  /** Selected file paths */
  paths: string[];
}

export class NotificationError extends Error {
  constructor(
    public type: NotificationErrorType,
    message: string
  ) {
    super(message);
    this.name = 'NotificationError';
  }
}

```

--------------------------------------------------------------------------------
/plan.md:
--------------------------------------------------------------------------------

```markdown
# Apple Notifier MCP Server

## Overview
This MCP server will provide tools to send notifications on Apple devices using the `osascript` command. The server will expose a simple interface to display notifications with customizable titles, messages, and sound options.

## Project Structure
```
apple-notifier-mcp/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts              # Main MCP server implementation
│   ├── notifier.ts           # Core notification functionality
│   └── types.ts              # Type definitions
├── tests/                    # Test files
│   └── notifier.test.ts
└── examples/                 # Example usage
    └── demo.ts
```

## Dependencies
- `@modelcontextprotocol/sdk` - Core MCP SDK
- `typescript` - For type safety and better development experience
- No external dependencies needed for notifications as we'll use native `osascript`

## Implementation Details

### MCP Server Tools
The server will expose the following tools:

1. `send_notification`
   - Parameters:
     - title (required): string - Notification title
     - message (required): string - Notification message
     - subtitle (optional): string - Notification subtitle
     - sound (optional): boolean - Play default notification sound (default: true)

### Core Notification Implementation
We'll use the `osascript` command with AppleScript to send notifications:

```applescript
display notification "message" with title "title" subtitle "subtitle" sound name "default"
```

The notifier module will handle:
- Parameter validation
- Escaping special characters
- Command execution
- Error handling

### Error Handling
The server will handle common error scenarios:
- Invalid parameters
- System command failures
- Permission issues
- Resource constraints

## Example Usage

```typescript
// Using the MCP tool
const result = await mcp.use_tool('apple-notifier', 'send_notification', {
  title: 'Hello',
  message: 'This is a test notification',
  subtitle: 'Optional subtitle',
  sound: true
});
```

## Testing Strategy

1. Unit Tests
   - Parameter validation
   - Command string generation
   - Error handling

2. Integration Tests
   - End-to-end notification sending
   - MCP tool interface
   - Error scenarios

3. Manual Testing
   - Visual verification of notifications
   - Sound testing
   - Different parameter combinations

## Implementation Steps

1. Project Setup
   - Initialize npm project
   - Configure TypeScript
   - Set up development environment

2. Core Implementation
   - Create notifier module
   - Implement command generation
   - Add error handling

3. MCP Server
   - Implement server class
   - Add tool definitions
   - Set up request handlers

4. Testing
   - Write unit tests
   - Add integration tests
   - Perform manual testing

5. Documentation
   - Add JSDoc comments
   - Create usage examples
   - Document error scenarios

## Future Enhancements
- Support for custom notification sounds
- Rich notifications with images
- Scheduled notifications
- Notification center integration
- Support for notification actions

```

--------------------------------------------------------------------------------
/src/features/screenshot.ts:
--------------------------------------------------------------------------------

```typescript
import { ScreenshotParams, NotificationError, NotificationErrorType } from '../types.js';
import { execAsync, escapeString } from '../utils/command.js';

/**
 * Validates screenshot parameters
 */
function validateScreenshotParams(params: ScreenshotParams): void {
  if (!params.path || typeof params.path !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Path is required and must be a string'
    );
  }

  if (!params.type || !['fullscreen', 'window', 'selection'].includes(params.type)) {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Type must be one of: fullscreen, window, selection'
    );
  }

  if (params.format && !['png', 'jpg', 'pdf', 'tiff'].includes(params.format)) {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Format must be one of: png, jpg, pdf, tiff'
    );
  }

  if (params.hideCursor !== undefined && typeof params.hideCursor !== 'boolean') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'HideCursor must be a boolean'
    );
  }

  if (params.shadow !== undefined && typeof params.shadow !== 'boolean') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Shadow must be a boolean'
    );
  }

  if (params.timestamp !== undefined && typeof params.timestamp !== 'boolean') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Timestamp must be a boolean'
    );
  }
}

/**
 * Builds the screencapture command
 */
function buildScreenshotCommand(params: ScreenshotParams): string {
  let command = 'screencapture';
  
  // Screenshot type
  switch (params.type) {
    case 'window':
      command += ' -w'; // Capture window
      break;
    case 'selection':
      command += ' -s'; // Interactive selection
      break;
    // fullscreen is default, no flag needed
  }
  
  // Optional flags
  if (params.format) {
    command += ` -t ${params.format}`;
  }
  
  if (params.hideCursor) {
    command += ' -C'; // Hide cursor
  }
  
  if (params.type === 'window' && params.shadow === false) {
    command += ' -o'; // No window shadow
  }
  
  // Add timestamp to filename if requested
  let path = params.path;
  if (params.timestamp) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const ext = params.format || 'png';
    path = path.replace(new RegExp(`\\.${ext}$`), `-${timestamp}.${ext}`);
  }
  
  command += ` "${escapeString(path)}"`;
  
  return command;
}

/**
 * Takes a screenshot using screencapture
 */
export async function takeScreenshot(params: ScreenshotParams): Promise<void> {
  try {
    validateScreenshotParams(params);
    const command = buildScreenshotCommand(params);
    await execAsync(command);
  } catch (error) {
    if (error instanceof NotificationError) {
      throw error;
    }

    const err = error as Error;
    if (err.message.includes('execution error')) {
      throw new NotificationError(
        NotificationErrorType.COMMAND_FAILED,
        'Failed to capture screenshot'
      );
    } else if (err.message.includes('permission')) {
      throw new NotificationError(
        NotificationErrorType.PERMISSION_DENIED,
        'Permission denied when trying to capture screenshot'
      );
    } else {
      throw new NotificationError(
        NotificationErrorType.UNKNOWN,
        `Unexpected error: ${err.message}`
      );
    }
  }
}

```

--------------------------------------------------------------------------------
/src/features/fileSelect.ts:
--------------------------------------------------------------------------------

```typescript
import { FileSelectParams, FileSelectResult, NotificationError, NotificationErrorType } from '../types.js';
import { execAsync, escapeString } from '../utils/command.js';

/**
 * Validates file selection parameters
 */
function validateFileSelectParams(params: FileSelectParams): void {
  if (params.prompt && typeof params.prompt !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Prompt must be a string'
    );
  }

  if (params.defaultLocation && typeof params.defaultLocation !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Default location must be a string'
    );
  }

  if (params.multiple !== undefined && typeof params.multiple !== 'boolean') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Multiple selection flag must be a boolean'
    );
  }

  if (params.fileTypes) {
    if (typeof params.fileTypes !== 'object' || params.fileTypes === null) {
      throw new NotificationError(
        NotificationErrorType.INVALID_PARAMS,
        'File types must be an object'
      );
    }

    for (const [_, extensions] of Object.entries(params.fileTypes)) {
      if (!Array.isArray(extensions) || !extensions.every(ext => typeof ext === 'string')) {
        throw new NotificationError(
          NotificationErrorType.INVALID_PARAMS,
          'File type extensions must be an array of strings'
        );
      }
    }
  }
}

/**
 * Builds the AppleScript command for file selection
 */
function buildFileSelectCommand(params: FileSelectParams): string {
  let script = 'choose file';
  
  if (params.multiple) {
    script += ' with multiple selections allowed';
  }
  
  if (params.prompt) {
    script += ` with prompt "${escapeString(params.prompt)}"`;
  }
  
  if (params.defaultLocation) {
    script += ` default location "${escapeString(params.defaultLocation)}"`;
  }
  
  // Handle file type filtering if specified
  if (params.fileTypes) {
    const extensions = Object.values(params.fileTypes).flat();
    if (extensions.length > 0) {
      script += ` of type {${extensions.map(ext => `"${ext}"`).join(', ')}}`;
    }
  }
  
  return `osascript -e '${script}'`;
}

/**
 * Prompts user to select file(s) using native macOS file picker
 */
export async function selectFile(params: FileSelectParams): Promise<FileSelectResult> {
  try {
    validateFileSelectParams(params);
    const command = buildFileSelectCommand(params);
    const { stdout } = await execAsync(command);
    
    // Parse the AppleScript result
    // Format: alias "path1", alias "path2", ...
    const paths = stdout
      .trim()
      .split(', ')
      .map(path => path.replace(/^alias "|"$/g, ''))
      .map(path => path.replace(/:/g, '/'))
      .map(path => `/${path}`); // Add leading slash
    
    return { paths };
  } catch (error) {
    if (error instanceof NotificationError) {
      throw error;
    }

    const err = error as Error;
    if (err.message.includes('User canceled')) {
      throw new NotificationError(
        NotificationErrorType.PROMPT_CANCELLED,
        'File selection was cancelled'
      );
    } else if (err.message.includes('execution error')) {
      throw new NotificationError(
        NotificationErrorType.COMMAND_FAILED,
        'Failed to execute file selection command'
      );
    } else if (err.message.includes('permission')) {
      throw new NotificationError(
        NotificationErrorType.PERMISSION_DENIED,
        'Permission denied when trying to select file'
      );
    } else {
      throw new NotificationError(
        NotificationErrorType.UNKNOWN,
        `Unexpected error: ${err.message}`
      );
    }
  }
}

```

--------------------------------------------------------------------------------
/src/features/prompt.ts:
--------------------------------------------------------------------------------

```typescript
import { PromptParams, PromptResult, NotificationError, NotificationErrorType } from '../types.js';
import { execAsync, escapeString } from '../utils/command.js';

/**
 * Validates prompt parameters
 */
function validatePromptParams(params: PromptParams): void {
  if (!params.message || typeof params.message !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Message is required and must be a string'
    );
  }

  if (params.defaultAnswer && typeof params.defaultAnswer !== 'string') {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Default answer must be a string'
    );
  }

  if (params.buttons) {
    if (!Array.isArray(params.buttons) || !params.buttons.every(b => typeof b === 'string')) {
      throw new NotificationError(
        NotificationErrorType.INVALID_PARAMS,
        'Buttons must be an array of strings'
      );
    }
    if (params.buttons.length > 3) {
      throw new NotificationError(
        NotificationErrorType.INVALID_PARAMS,
        'Maximum of 3 buttons allowed'
      );
    }
  }

  if (params.icon && !['note', 'stop', 'caution'].includes(params.icon)) {
    throw new NotificationError(
      NotificationErrorType.INVALID_PARAMS,
      'Icon must be one of: note, stop, caution'
    );
  }
}

/**
 * Builds the AppleScript command for displaying a prompt
 */
function buildPromptCommand(params: PromptParams): string {
  let script = 'display dialog';
  
  script += ` "${escapeString(params.message)}"`;
  
  if (params.defaultAnswer !== undefined) {
    script += ` default answer "${escapeString(params.defaultAnswer)}"`;
  }
  
  if (params.buttons && params.buttons.length > 0) {
    script += ` buttons {${params.buttons.map(b => `"${escapeString(b)}"`).join(', ')}}`;
    script += ` default button ${params.buttons.length}`;
  } else {
    script += ' buttons {"Cancel", "OK"} default button 2';
  }
  
  if (params.icon) {
    script += ` with icon ${params.icon}`;
  }
  
  return `osascript -e '${script}'`;
}

/**
 * Prompts the user for input using osascript
 */
export async function promptUser(params: PromptParams): Promise<PromptResult> {
  try {
    validatePromptParams(params);
    const command = buildPromptCommand(params);
    const { stdout } = await execAsync(command);
    
    // Parse the AppleScript result
    // Format: button returned:OK, text returned:user input
    const match = stdout.match(/button returned:([^,]+)(?:, text returned:(.+))?/);
    if (!match) {
      throw new Error('Failed to parse dialog result');
    }
    
    const buttonText = match[1];
    const text = match[2];
    
    // Find the index of the clicked button
    const buttons = params.buttons || ['Cancel', 'OK'];
    const buttonIndex = buttons.findIndex(b => b === buttonText);
    
    return {
      text: text,
      buttonIndex: buttonIndex !== -1 ? buttonIndex : 0
    };
  } catch (error) {
    if (error instanceof NotificationError) {
      throw error;
    }

    const err = error as Error;
    if (err.message.includes('User canceled')) {
      throw new NotificationError(
        NotificationErrorType.PROMPT_CANCELLED,
        'User cancelled the prompt'
      );
    } else if (err.message.includes('execution error')) {
      throw new NotificationError(
        NotificationErrorType.COMMAND_FAILED,
        'Failed to execute prompt command'
      );
    } else if (err.message.includes('permission')) {
      throw new NotificationError(
        NotificationErrorType.PERMISSION_DENIED,
        'Permission denied when trying to show prompt'
      );
    } else {
      throw new NotificationError(
        NotificationErrorType.UNKNOWN,
        `Unexpected error: ${err.message}`
      );
    }
  }
}

```

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

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

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { sendNotification } from './features/notification.js';
import { promptUser } from './features/prompt.js';
import { speak } from './features/speech.js';
import { takeScreenshot } from './features/screenshot.js';
import { selectFile } from './features/fileSelect.js';
import { 
  NotificationError, 
  NotificationErrorType, 
  NotificationParams, 
  PromptParams, 
  PromptResult, 
  SpeechParams, 
  ScreenshotParams,
  FileSelectParams,
  FileSelectResult 
} from './types.js';

class AppleNotifierServer {
  private server: Server;

  constructor() {
    this.server = new Server(
      {
        name: 'apple-notifier',
        version: '1.0.0',
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );

    this.setupToolHandlers();
    
    // Error handling
    this.server.onerror = (error) => console.error('[MCP Error]', error);
    process.on('SIGINT', async () => {
      await this.server.close();
      process.exit(0);
    });
  }

  private setupToolHandlers() {
    // List available tools
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'send_notification',
          description: 'Send a notification on macOS using osascript',
          inputSchema: {
            type: 'object',
            properties: {
              title: {
                type: 'string',
                description: 'Title of the notification',
              },
              message: {
                type: 'string',
                description: 'Main message content',
              },
              subtitle: {
                type: 'string',
                description: 'Optional subtitle',
              },
              sound: {
                type: 'boolean',
                description: 'Whether to play the default notification sound',
                default: true,
              },
            },
            required: ['title', 'message'],
            additionalProperties: false,
          },
        },
        {
          name: 'prompt_user',
          description: 'Display a dialog prompt to get user input',
          inputSchema: {
            type: 'object',
            properties: {
              message: {
                type: 'string',
                description: 'Text to display in the prompt dialog',
              },
              defaultAnswer: {
                type: 'string',
                description: 'Optional default text to pre-fill',
              },
              buttons: {
                type: 'array',
                items: {
                  type: 'string'
                },
                description: 'Optional custom button labels (max 3)',
                maxItems: 3
              },
              icon: {
                type: 'string',
                enum: ['note', 'stop', 'caution'],
                description: 'Optional icon to display'
              }
            },
            required: ['message'],
            additionalProperties: false,
          },
        },
        {
          name: 'speak',
          description: 'Speak text using macOS text-to-speech',
          inputSchema: {
            type: 'object',
            properties: {
              text: {
                type: 'string',
                description: 'Text to speak',
              },
              voice: {
                type: 'string',
                description: 'Voice to use (defaults to system voice)',
              },
              rate: {
                type: 'number',
                description: 'Speech rate (-50 to 50, defaults to 0)',
                minimum: -50,
                maximum: 50
              }
            },
            required: ['text'],
            additionalProperties: false,
          },
        },
        {
          name: 'take_screenshot',
          description: 'Take a screenshot using macOS screencapture',
          inputSchema: {
            type: 'object',
            properties: {
              path: {
                type: 'string',
                description: 'Path where to save the screenshot',
              },
              type: {
                type: 'string',
                enum: ['fullscreen', 'window', 'selection'],
                description: 'Type of screenshot to take',
              },
              format: {
                type: 'string',
                enum: ['png', 'jpg', 'pdf', 'tiff'],
                description: 'Image format',
              },
              hideCursor: {
                type: 'boolean',
                description: 'Whether to hide the cursor',
              },
              shadow: {
                type: 'boolean',
                description: 'Whether to include the window shadow (only for window type)',
              },
              timestamp: {
                type: 'boolean',
                description: 'Timestamp to add to filename',
              }
            },
            required: ['path', 'type'],
            additionalProperties: false,
          },
        },
        {
          name: 'select_file',
          description: 'Open native file picker dialog',
          inputSchema: {
            type: 'object',
            properties: {
              prompt: {
                type: 'string',
                description: 'Optional prompt message'
              },
              defaultLocation: {
                type: 'string',
                description: 'Optional default directory path'
              },
              fileTypes: {
                type: 'object',
                description: 'Optional file type filter (e.g., {"public.image": ["png", "jpg"]})',
                additionalProperties: {
                  type: 'array',
                  items: {
                    type: 'string'
                  }
                }
              },
              multiple: {
                type: 'boolean',
                description: 'Whether to allow multiple selection'
              }
            },
            additionalProperties: false
          }
        },
      ],
    }));

    // Handle tool execution
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      try {
        if (!request.params.arguments || typeof request.params.arguments !== 'object') {
          throw new McpError(ErrorCode.InvalidParams, 'Invalid parameters');
        }

        switch (request.params.name) {
          case 'send_notification': {
            const { title, message, subtitle, sound } = request.params.arguments as Record<string, unknown>;
            
            if (typeof title !== 'string' || typeof message !== 'string') {
              throw new McpError(ErrorCode.InvalidParams, 'Title and message must be strings');
            }

            const params: NotificationParams = {
              title,
              message,
              subtitle: typeof subtitle === 'string' ? subtitle : undefined,
              sound: typeof sound === 'boolean' ? sound : undefined
            };

            await sendNotification(params);
            return {
              content: [
                {
                  type: 'text',
                  text: 'Notification sent successfully',
                },
              ],
            };
          }

          case 'prompt_user': {
            const { message, defaultAnswer, buttons, icon } = request.params.arguments as Record<string, unknown>;
            
            const params: PromptParams = {
              message: message as string,
              defaultAnswer: typeof defaultAnswer === 'string' ? defaultAnswer : undefined,
              buttons: Array.isArray(buttons) ? buttons as string[] : undefined,
              icon: ['note', 'stop', 'caution'].includes(icon as string) ? icon as 'note' | 'stop' | 'caution' : undefined
            };

            const result = await promptUser(params);
            return {
              content: [
                {
                  type: 'text',
                  text: JSON.stringify(result),
                },
              ],
            };
          }

          case 'speak': {
            const { text, voice, rate } = request.params.arguments as Record<string, unknown>;
            
            const params: SpeechParams = {
              text: text as string,
              voice: typeof voice === 'string' ? voice : undefined,
              rate: typeof rate === 'number' ? rate : undefined
            };

            await speak(params);
            return {
              content: [
                {
                  type: 'text',
                  text: 'Speech completed successfully',
                },
              ],
            };
          }

          case 'take_screenshot': {
            const { path, type, format, hideCursor, shadow, timestamp } = request.params.arguments as Record<string, unknown>;
            
            const params: ScreenshotParams = {
              path: path as string,
              type: type as 'fullscreen' | 'window' | 'selection',
              format: format as 'png' | 'jpg' | 'pdf' | 'tiff' | undefined,
              hideCursor: typeof hideCursor === 'boolean' ? hideCursor : undefined,
              shadow: typeof shadow === 'boolean' ? shadow : undefined,
              timestamp: typeof timestamp === 'boolean' ? timestamp : undefined
            };

            await takeScreenshot(params);
            return {
              content: [
                {
                  type: 'text',
                  text: 'Screenshot saved successfully',
                },
              ],
            };
          }

          case 'select_file': {
            const { prompt, defaultLocation, fileTypes, multiple } = request.params.arguments as Record<string, unknown>;
            
            const params: FileSelectParams = {
              prompt: typeof prompt === 'string' ? prompt : undefined,
              defaultLocation: typeof defaultLocation === 'string' ? defaultLocation : undefined,
              fileTypes: typeof fileTypes === 'object' && fileTypes !== null ? fileTypes as Record<string, string[]> : undefined,
              multiple: typeof multiple === 'boolean' ? multiple : undefined
            };

            const result = await selectFile(params);
            return {
              content: [
                {
                  type: 'text',
                  text: JSON.stringify(result),
                },
              ],
            };
          }

          default:
            throw new McpError(
              ErrorCode.MethodNotFound,
              `Unknown tool: ${request.params.name}`
            );
        }
      } catch (error) {
        if (error instanceof NotificationError) {
          let errorCode: ErrorCode;
          switch (error.type) {
            case NotificationErrorType.INVALID_PARAMS:
              errorCode = ErrorCode.InvalidParams;
              break;
            default:
              errorCode = ErrorCode.InternalError;
          }
          throw new McpError(errorCode, error.message);
        }
        throw error;
      }
    });
  }

  async run() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error('Apple Notifier MCP server running on stdio');
  }
}

const server = new AppleNotifierServer();
server.run().catch(console.error);

```