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

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

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

```
 1 | # Dependencies
 2 | node_modules/
 3 | .pnpm-store/
 4 | 
 5 | # Build
 6 | build/
 7 | 
 8 | # Logs
 9 | *.log
10 | npm-debug.log*
11 | pnpm-debug.log*
12 | 
13 | # OS
14 | .DS_Store
15 | 
16 | # Lock files
17 | package-lock.json
18 | # Keep pnpm-lock.yaml
19 | 
```

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

```markdown
  1 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/turlockmike-apple-notifier-mcp-badge.png)](https://mseep.ai/app/turlockmike-apple-notifier-mcp)
  2 | 
  3 | # Apple Notifier MCP Server
  4 | 
  5 | [![smithery badge](https://smithery.ai/badge/apple-notifier-mcp)](https://smithery.ai/server/apple-notifier-mcp)
  6 | Send native macOS notifications and interact with system dialogs through any MCP-compatible client like Claude Desktop or Cline.
  7 | 
  8 | <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>
  9 | 
 10 | ## Prerequisites
 11 | 
 12 | - macOS
 13 | - Node.js >= 18
 14 | - An MCP-compatible client (Claude Desktop, Cline)
 15 | 
 16 | ## Installation
 17 | 
 18 | ### Installing via Smithery
 19 | 
 20 | To install Apple Notifier for Claude Desktop automatically via [Smithery](https://smithery.ai/server/apple-notifier-mcp):
 21 | 
 22 | ```bash
 23 | npx -y @smithery/cli install apple-notifier-mcp --client claude
 24 | ```
 25 | 
 26 | ### Manual Installation
 27 | 1. Install the package globally:
 28 | ```bash
 29 | npm install -g apple-notifier-mcp
 30 | ```
 31 | 
 32 | 2. Add to your MCP configuration file:
 33 | 
 34 | For Cline (`cline_mcp_settings.json`):
 35 | ```json
 36 | {
 37 |   "mcpServers": {
 38 |     "apple-notifier": {
 39 |       "command": "apple-notifier-mcp"
 40 |     }
 41 |   }
 42 | }
 43 | ```
 44 | 
 45 | For Claude Desktop (`claude_desktop_config.json`):
 46 | ```json
 47 | {
 48 |   "mcpServers": {
 49 |     "apple-notifier": {
 50 |       "command": "apple-notifier-mcp"
 51 |     }
 52 |   }
 53 | }
 54 | ```
 55 | 
 56 | ## Features
 57 | 
 58 | ### Send Notifications
 59 | 
 60 | Display native macOS notifications with customizable content.
 61 | 
 62 | Parameters:
 63 | - `title` (required): string - The title of the notification
 64 | - `message` (required): string - The main message content
 65 | - `subtitle` (optional): string - A subtitle to display
 66 | - `sound` (optional): boolean - Whether to play the default notification sound (default: true)
 67 | 
 68 | ### Display Prompts
 69 | 
 70 | Show interactive dialog prompts to get user input.
 71 | 
 72 | Parameters:
 73 | - `message` (required): string - Text to display in the prompt dialog
 74 | - `defaultAnswer` (optional): string - Default text to pre-fill
 75 | - `buttons` (optional): string[] - Custom button labels (max 3)
 76 | - `icon` (optional): 'note' | 'stop' | 'caution' - Icon to display
 77 | 
 78 | ### Text-to-Speech
 79 | 
 80 | Use macOS text-to-speech capabilities.
 81 | 
 82 | Parameters:
 83 | - `text` (required): string - Text to speak
 84 | - `voice` (optional): string - Voice to use (defaults to system voice)
 85 | - `rate` (optional): number - Speech rate (-50 to 50, defaults to 0)
 86 | 
 87 | ### Take Screenshots
 88 | 
 89 | Capture screenshots using macOS screencapture.
 90 | 
 91 | Parameters:
 92 | - `path` (required): string - Path where to save the screenshot
 93 | - `type` (required): 'fullscreen' | 'window' | 'selection' - Type of screenshot
 94 | - `format` (optional): 'png' | 'jpg' | 'pdf' | 'tiff' - Image format
 95 | - `hideCursor` (optional): boolean - Whether to hide the cursor
 96 | - `shadow` (optional): boolean - Whether to include window shadow (only for window type)
 97 | - `timestamp` (optional): boolean - Add timestamp to filename
 98 | 
 99 | ### File Selection
100 | 
101 | Open native macOS file picker dialog.
102 | 
103 | Parameters:
104 | - `prompt` (optional): string - Prompt message
105 | - `defaultLocation` (optional): string - Default directory path
106 | - `fileTypes` (optional): object - File type filter (e.g., {"public.image": ["png", "jpg"]})
107 | - `multiple` (optional): boolean - Allow multiple file selection
108 | 
109 | ## Example Usage
110 | 
111 | ```typescript
112 | // Send a notification
113 | await client.use_mcp_tool("apple-notifier", "send_notification", {
114 |   title: "Hello",
115 |   message: "World",
116 |   sound: true
117 | });
118 | 
119 | // Show a prompt
120 | const result = await client.use_mcp_tool("apple-notifier", "prompt_user", {
121 |   message: "What's your name?",
122 |   defaultAnswer: "John Doe",
123 |   buttons: ["OK", "Cancel"]
124 | });
125 | 
126 | // Speak text
127 | await client.use_mcp_tool("apple-notifier", "speak", {
128 |   text: "Hello, world!",
129 |   voice: "Samantha",
130 |   rate: -20
131 | });
132 | 
133 | // Take a screenshot
134 | await client.use_mcp_tool("apple-notifier", "take_screenshot", {
135 |   path: "screenshot.png",
136 |   type: "window",
137 |   format: "png"
138 | });
139 | 
140 | // Select files
141 | const files = await client.use_mcp_tool("apple-notifier", "select_file", {
142 |   prompt: "Select images",
143 |   fileTypes: {
144 |     "public.image": ["png", "jpg", "jpeg"]
145 |   },
146 |   multiple: true
147 | });
148 | ```
149 | 
150 | ## Contributing
151 | 
152 | See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
153 | 
154 | 
155 | 
156 | ## Running evals
157 | 
158 | 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).
159 | 
160 | ```bash
161 | OPENAI_API_KEY=your-key  npx mcp-eval src/evals/evals.ts src/index.ts
162 | ```
163 | ## License
164 | 
165 | MIT License - see the [LICENSE](LICENSE) file for details.
166 | 
```

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

```markdown
  1 | # Contributing to Apple Notifier MCP Server
  2 | 
  3 | 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.
  4 | 
  5 | ## Code of Conduct
  6 | 
  7 | This project and everyone participating in it is governed by our code of conduct. By participating, you are expected to uphold this code.
  8 | 
  9 | ## How Can I Contribute?
 10 | 
 11 | ### Reporting Bugs
 12 | 
 13 | 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:
 14 | 
 15 | * Use a clear and descriptive title
 16 | * Describe the exact steps which reproduce the problem
 17 | * Provide specific examples to demonstrate the steps
 18 | * Describe the behavior you observed after following the steps
 19 | * Explain which behavior you expected to see instead and why
 20 | * Include details about your configuration and environment:
 21 |   * Which version of Node.js are you using?
 22 |   * Which version of macOS are you using?
 23 |   * Which MCP client are you using (Claude Desktop, Cline, etc.)?
 24 | 
 25 | ### Suggesting Enhancements
 26 | 
 27 | Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
 28 | 
 29 | * A clear and descriptive title
 30 | * A detailed description of the proposed functionality
 31 | * Explain why this enhancement would be useful
 32 | * List any alternative solutions or features you've considered
 33 | 
 34 | ### Pull Requests
 35 | 
 36 | * Fill in the required template
 37 | * Do not include issue numbers in the PR title
 38 | * Include screenshots and animated GIFs in your pull request whenever possible
 39 | * Follow the TypeScript styleguide
 40 | * Include thoughtfully-worded, well-structured tests (when we implement testing)
 41 | * Document new code
 42 | * End all files with a newline
 43 | 
 44 | ## Development Process
 45 | 
 46 | 1. Fork the repo and create your branch from `main`
 47 | 2. Install pnpm if you haven't already: `npm install -g pnpm`
 48 | 3. Install dependencies: `pnpm install`
 49 | 4. Make your changes
 50 | 5. Test your changes thoroughly
 51 | 6. Ensure the code lints successfully
 52 | 7. Create a Pull Request
 53 | 
 54 | ### Styleguides
 55 | 
 56 | #### Git Commit Messages
 57 | 
 58 | * Use the present tense ("Add feature" not "Added feature")
 59 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
 60 | * Limit the first line to 72 characters or less
 61 | * Reference issues and pull requests liberally after the first line
 62 | 
 63 | #### TypeScript Styleguide
 64 | 
 65 | * Use 2 spaces for indentation
 66 | * Prefer `const` over `let`
 67 | * Use meaningful variable names
 68 | * Add types for parameters and return values
 69 | * Use async/await over raw promises
 70 | * Document public methods with JSDoc comments
 71 | 
 72 | ## Project Structure
 73 | 
 74 | ```
 75 | apple-notifier-mcp/
 76 | ├── src/                    # Source files
 77 | │   ├── index.ts           # Main MCP server implementation
 78 | │   ├── notifier.ts        # Core notification functionality
 79 | │   └── types.ts           # Type definitions
 80 | ├── build/                 # Compiled files (git ignored)
 81 | ├── package.json           # Project metadata and dependencies
 82 | └── tsconfig.json          # TypeScript configuration
 83 | ```
 84 | 
 85 | ## Setting Up Development Environment
 86 | 
 87 | 1. Ensure you have Node.js >= 18 installed
 88 | 2. Install pnpm: `npm install -g pnpm`
 89 | 3. Clone your fork of the repository
 90 | 4. Run `pnpm install` to install dependencies
 91 | 5. Run `pnpm dev` to start in development mode
 92 | 6. Make your changes
 93 | 7. Test thoroughly with `pnpm test`
 94 | 8. Submit a PR
 95 | 
 96 | ### Available Scripts
 97 | 
 98 | - `pnpm build` - Build the project
 99 | - `pnpm start` - Start the MCP server
100 | - `pnpm dev` - Start in watch mode for development
101 | - `pnpm clean` - Remove build artifacts
102 | - `pnpm test` - Run tests (to be implemented)
103 | 
104 | ## CI/CD
105 | 
106 | This project uses GitHub Actions for continuous integration and deployment:
107 | 
108 | ### Continuous Integration
109 | - Runs on every push and pull request to `main`
110 | - Tests on macOS with Node.js 18.x and 20.x
111 | - Builds the project
112 | - Runs tests
113 | - Verifies osascript functionality
114 | - Tests basic notification functionality
115 | 
116 | ### Publishing to NPM
117 | To publish a new version:
118 | 1. Update version in `package.json`
119 | 2. Create and push a new tag:
120 |    ```bash
121 |    git tag v1.0.0  # Use appropriate version
122 |    git push origin v1.0.0
123 |    ```
124 | 3. Create a new release on GitHub using the tag
125 | 4. The GitHub Action will automatically:
126 |    - Build the project
127 |    - Run tests
128 |    - Publish to NPM
129 | 
130 | ## Support
131 | 
132 | If you encounter any issues or have questions:
133 | 1. Check the [Issues](https://github.com/turlockmike/apple-notifier-mcp/issues) page
134 | 2. Open a new issue if your problem hasn't been reported
135 | 3. Provide as much detail as possible, including:
136 |    - Node.js version
137 |    - macOS version
138 |    - Steps to reproduce
139 |    - Expected vs actual behavior
140 | 
```

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

```typescript
 1 | import { promisify } from 'util';
 2 | import { exec } from 'child_process';
 3 | 
 4 | export const execAsync = promisify(exec);
 5 | 
 6 | /**
 7 |  * Escapes special characters in strings for AppleScript
 8 |  */
 9 | export function escapeString(str: string): string {
10 |   // Escape for both AppleScript and shell
11 |   return str
12 |     .replace(/'/g, "'\\''")
13 |     .replace(/"/g, '\\"');
14 | }
15 | 
```

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

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2020",
 4 |     "module": "ES2020",
 5 |     "moduleResolution": "node",
 6 |     "outDir": "./build",
 7 |     "rootDir": "./src",
 8 |     "strict": true,
 9 |     "esModuleInterop": true,
10 |     "skipLibCheck": true,
11 |     "forceConsistentCasingInFileNames": true,
12 |     "declaration": true
13 |   },
14 |   "include": ["src/**/*"],
15 |   "exclude": ["node_modules", "build"]
16 | }
17 | 
```

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

```json
 1 | {
 2 |   "name": "apple-notifier-mcp",
 3 |   "version": "1.1.0",
 4 |   "description": "An MCP server for sending native macOS notifications",
 5 |   "packageManager": "[email protected]",
 6 |   "main": "build/index.js",
 7 |   "types": "build/index.d.ts",
 8 |   "bin": {
 9 |     "apple-notifier-mcp": "./build/index.js"
10 |   },
11 |   "files": [
12 |     "build/**/*"
13 |   ],
14 |   "engines": {
15 |     "node": ">=18"
16 |   },
17 |   "repository": {
18 |     "type": "git",
19 |     "url": "git+https://github.com/turlockmike/apple-notifier-mcp.git"
20 |   },
21 |   "keywords": [
22 |     "mcp",
23 |     "macos",
24 |     "notifications",
25 |     "osascript",
26 |     "claude",
27 |     "anthropic"
28 |   ],
29 |   "bugs": {
30 |     "url": "https://github.com/turlockmike/apple-notifier-mcp/issues"
31 |   },
32 |   "homepage": "https://github.com/turlockmike/apple-notifier-mcp#readme",
33 |   "scripts": {
34 |     "build": "tsc && chmod +x build/index.js",
35 |     "start": "node build/index.js",
36 |     "dev": "tsc -w",
37 |     "clean": "rm -rf build",
38 |     "prepare": "pnpm clean && pnpm build",
39 |     "prepublishOnly": "pnpm test",
40 |     "test": "echo \"No tests yet\""
41 |   },
42 |   "type": "module",
43 |   "author": "",
44 |   "license": "MIT",
45 |   "devDependencies": {
46 |     "@types/node": "^22.10.2",
47 |     "typescript": "^5.7.2"
48 |   },
49 |   "dependencies": {
50 |     "@modelcontextprotocol/sdk": "^1.0.4",
51 |     "mcp-evals": "^1.0.18"
52 |   }
53 | }
54 | 
```

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

```typescript
 1 | import { SpeechParams, NotificationError, NotificationErrorType } from '../types.js';
 2 | import { execAsync, escapeString } from '../utils/command.js';
 3 | 
 4 | /**
 5 |  * Validates speech parameters
 6 |  */
 7 | function validateSpeechParams(params: SpeechParams): void {
 8 |   if (!params.text || typeof params.text !== 'string') {
 9 |     throw new NotificationError(
10 |       NotificationErrorType.INVALID_PARAMS,
11 |       'Text is required and must be a string'
12 |     );
13 |   }
14 | 
15 |   if (params.voice && typeof params.voice !== 'string') {
16 |     throw new NotificationError(
17 |       NotificationErrorType.INVALID_PARAMS,
18 |       'Voice must be a string'
19 |     );
20 |   }
21 | 
22 |   if (params.rate !== undefined) {
23 |     if (typeof params.rate !== 'number') {
24 |       throw new NotificationError(
25 |         NotificationErrorType.INVALID_PARAMS,
26 |         'Rate must be a number'
27 |       );
28 |     }
29 |     if (params.rate < -50 || params.rate > 50) {
30 |       throw new NotificationError(
31 |         NotificationErrorType.INVALID_PARAMS,
32 |         'Rate must be between -50 and 50'
33 |       );
34 |     }
35 |   }
36 | }
37 | 
38 | /**
39 |  * Builds the say command for text-to-speech
40 |  */
41 | function buildSpeechCommand(params: SpeechParams): string {
42 |   let command = 'say';
43 |   
44 |   if (params.voice) {
45 |     command += ` -v "${escapeString(params.voice)}"`;
46 |   }
47 |   
48 |   if (params.rate !== undefined) {
49 |     command += ` -r ${params.rate}`;
50 |   }
51 |   
52 |   command += ` "${escapeString(params.text)}"`;
53 |   
54 |   return command;
55 | }
56 | 
57 | /**
58 |  * Speaks text using macOS text-to-speech
59 |  */
60 | export async function speak(params: SpeechParams): Promise<void> {
61 |   try {
62 |     validateSpeechParams(params);
63 |     const command = buildSpeechCommand(params);
64 |     await execAsync(command);
65 |   } catch (error) {
66 |     if (error instanceof NotificationError) {
67 |       throw error;
68 |     }
69 | 
70 |     const err = error as Error;
71 |     if (err.message.includes('execution error')) {
72 |       throw new NotificationError(
73 |         NotificationErrorType.COMMAND_FAILED,
74 |         'Failed to execute speech command'
75 |       );
76 |     } else if (err.message.includes('permission')) {
77 |       throw new NotificationError(
78 |         NotificationErrorType.PERMISSION_DENIED,
79 |         'Permission denied when trying to speak'
80 |       );
81 |     } else {
82 |       throw new NotificationError(
83 |         NotificationErrorType.UNKNOWN,
84 |         `Unexpected error: ${err.message}`
85 |       );
86 |     }
87 |   }
88 | }
89 | 
```

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

```typescript
 1 | import { NotificationParams, NotificationError, NotificationErrorType } from '../types.js';
 2 | import { execAsync, escapeString } from '../utils/command.js';
 3 | 
 4 | /**
 5 |  * Validates notification parameters
 6 |  */
 7 | function validateParams(params: NotificationParams): void {
 8 |   if (!params.title || typeof params.title !== 'string') {
 9 |     throw new NotificationError(
10 |       NotificationErrorType.INVALID_PARAMS,
11 |       'Title is required and must be a string'
12 |     );
13 |   }
14 | 
15 |   if (!params.message || typeof params.message !== 'string') {
16 |     throw new NotificationError(
17 |       NotificationErrorType.INVALID_PARAMS,
18 |       'Message is required and must be a string'
19 |     );
20 |   }
21 | 
22 |   if (params.subtitle && typeof params.subtitle !== 'string') {
23 |     throw new NotificationError(
24 |       NotificationErrorType.INVALID_PARAMS,
25 |       'Subtitle must be a string'
26 |     );
27 |   }
28 | }
29 | 
30 | /**
31 |  * Builds the AppleScript command for sending a notification
32 |  */
33 | function buildNotificationCommand(params: NotificationParams): string {
34 |   const { title, message, subtitle, sound = true } = params;
35 |   
36 |   let script = `display notification "${escapeString(message)}" with title "${escapeString(title)}"`;
37 |   
38 |   if (subtitle) {
39 |     script += ` subtitle "${escapeString(subtitle)}"`;
40 |   }
41 |   
42 |   if (sound) {
43 |     script += ` sound name "default"`;
44 |   }
45 |   
46 |   return `osascript -e '${script}'`;
47 | }
48 | 
49 | /**
50 |  * Sends a notification using osascript
51 |  */
52 | export async function sendNotification(params: NotificationParams): Promise<void> {
53 |   try {
54 |     validateParams(params);
55 |     const command = buildNotificationCommand(params);
56 |     await execAsync(command);
57 |   } catch (error) {
58 |     if (error instanceof NotificationError) {
59 |       throw error;
60 |     }
61 | 
62 |     // Handle different types of system errors
63 |     const err = error as Error;
64 |     if (err.message.includes('execution error')) {
65 |       throw new NotificationError(
66 |         NotificationErrorType.COMMAND_FAILED,
67 |         'Failed to execute notification command'
68 |       );
69 |     } else if (err.message.includes('permission')) {
70 |       throw new NotificationError(
71 |         NotificationErrorType.PERMISSION_DENIED,
72 |         'Permission denied when trying to send notification'
73 |       );
74 |     } else {
75 |       throw new NotificationError(
76 |         NotificationErrorType.UNKNOWN,
77 |         `Unexpected error: ${err.message}`
78 |       );
79 |     }
80 |   }
81 | }
82 | 
```

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

```typescript
 1 | //evals.ts
 2 | 
 3 | import { EvalConfig } from 'mcp-evals';
 4 | import { openai } from "@ai-sdk/openai";
 5 | import { grade, EvalFunction } from "mcp-evals";
 6 | 
 7 | const send_notificationEval: EvalFunction = {
 8 |   name: 'Send Notification Tool Evaluation',
 9 |   description: 'Evaluates the send_notification tool by testing macOS notification functionality',
10 |   run: async () => {
11 |     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.");
12 |     return JSON.parse(result);
13 |   }
14 | };
15 | 
16 | const prompt_userEval: EvalFunction = {
17 |     name: 'prompt_user Evaluation',
18 |     description: 'Tests the functionality of the prompt_user tool by verifying dialog prompt input and options',
19 |     run: async () => {
20 |         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'.");
21 |         return JSON.parse(result);
22 |     }
23 | };
24 | 
25 | const speakEval: EvalFunction = {
26 |     name: 'speakEval',
27 |     description: 'Evaluates the macOS text-to-speech speak tool',
28 |     run: async () => {
29 |         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?");
30 |         return JSON.parse(result);
31 |     }
32 | };
33 | 
34 | const take_screenshotEval: EvalFunction = {
35 |     name: 'take_screenshot',
36 |     description: 'Evaluates the take_screenshot tool functionality',
37 |     run: async () => {
38 |         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.");
39 |         return JSON.parse(result);
40 |     }
41 | };
42 | 
43 | const select_fileEval: EvalFunction = {
44 |     name: 'select_fileEval',
45 |     description: 'Evaluates the behavior of the select_file tool by verifying its ability to open a file dialog with specified parameters',
46 |     run: async () => {
47 |         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.");
48 |         return JSON.parse(result);
49 |     }
50 | };
51 | 
52 | const config: EvalConfig = {
53 |     model: openai("gpt-4"),
54 |     evals: [send_notificationEval, prompt_userEval, speakEval, take_screenshotEval, select_fileEval]
55 | };
56 |   
57 | export default config;
58 |   
59 | export const evals = [send_notificationEval, prompt_userEval, speakEval, take_screenshotEval, select_fileEval];
```

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

```typescript
  1 | /**
  2 |  * Parameters for sending a notification
  3 |  */
  4 | export interface NotificationParams {
  5 |   /** Title of the notification */
  6 |   title: string;
  7 |   /** Main message content */
  8 |   message: string;
  9 |   /** Optional subtitle */
 10 |   subtitle?: string;
 11 |   /** Whether to play the default notification sound */
 12 |   sound?: boolean;
 13 | }
 14 | 
 15 | /**
 16 |  * Error types that can occur during notification operations
 17 |  */
 18 | export enum NotificationErrorType {
 19 |   INVALID_PARAMS = 'INVALID_PARAMS',
 20 |   COMMAND_FAILED = 'COMMAND_FAILED',
 21 |   PERMISSION_DENIED = 'PERMISSION_DENIED',
 22 |   PROMPT_CANCELLED = 'PROMPT_CANCELLED',
 23 |   UNKNOWN = 'UNKNOWN'
 24 | }
 25 | 
 26 | /**
 27 |  * Parameters for prompting user input
 28 |  */
 29 | export interface PromptParams {
 30 |   /** Text to display in the prompt dialog */
 31 |   message: string;
 32 |   /** Optional default text to pre-fill */
 33 |   defaultAnswer?: string;
 34 |   /** Optional custom button labels */
 35 |   buttons?: string[];
 36 |   /** Optional icon name to display (note, stop, caution) */
 37 |   icon?: 'note' | 'stop' | 'caution';
 38 | }
 39 | 
 40 | /**
 41 |  * Response from a prompt dialog
 42 |  */
 43 | export interface PromptResult {
 44 |   /** Text entered by the user, or undefined if cancelled */
 45 |   text?: string;
 46 |   /** Index of the button clicked (0-based) */
 47 |   buttonIndex: number;
 48 | }
 49 | 
 50 | /**
 51 |  * Parameters for text-to-speech
 52 |  */
 53 | export interface SpeechParams {
 54 |   /** Text to speak */
 55 |   text: string;
 56 |   /** Voice to use (defaults to system voice) */
 57 |   voice?: string;
 58 |   /** Speech rate (-50 to 50, defaults to 0) */
 59 |   rate?: number;
 60 | }
 61 | 
 62 | /**
 63 |  * Parameters for taking a screenshot
 64 |  */
 65 | export interface ScreenshotParams {
 66 |   /** Path where to save the screenshot */
 67 |   path: string;
 68 |   /** Type of screenshot to take */
 69 |   type: 'fullscreen' | 'window' | 'selection';
 70 |   /** Image format (png, jpg, pdf, tiff) */
 71 |   format?: 'png' | 'jpg' | 'pdf' | 'tiff';
 72 |   /** Whether to hide the cursor */
 73 |   hideCursor?: boolean;
 74 |   /** Whether to include the window shadow (only for window type) */
 75 |   shadow?: boolean;
 76 |   /** Timestamp to add to filename (defaults to current time) */
 77 |   timestamp?: boolean;
 78 | }
 79 | 
 80 | /**
 81 |  * Custom error class for notification operations
 82 |  */
 83 | /**
 84 |  * Parameters for file selection
 85 |  */
 86 | export interface FileSelectParams {
 87 |   /** Optional prompt message */
 88 |   prompt?: string;
 89 |   /** Optional default location */
 90 |   defaultLocation?: string;
 91 |   /** Optional file type filter (e.g., {"public.image": ["png", "jpg"]}) */
 92 |   fileTypes?: Record<string, string[]>;
 93 |   /** Whether to allow multiple selection */
 94 |   multiple?: boolean;
 95 | }
 96 | 
 97 | /**
 98 |  * Result from file selection
 99 |  */
100 | export interface FileSelectResult {
101 |   /** Selected file paths */
102 |   paths: string[];
103 | }
104 | 
105 | export class NotificationError extends Error {
106 |   constructor(
107 |     public type: NotificationErrorType,
108 |     message: string
109 |   ) {
110 |     super(message);
111 |     this.name = 'NotificationError';
112 |   }
113 | }
114 | 
```

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

```markdown
  1 | # Apple Notifier MCP Server
  2 | 
  3 | ## Overview
  4 | 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.
  5 | 
  6 | ## Project Structure
  7 | ```
  8 | apple-notifier-mcp/
  9 | ├── package.json
 10 | ├── tsconfig.json
 11 | ├── src/
 12 | │   ├── index.ts              # Main MCP server implementation
 13 | │   ├── notifier.ts           # Core notification functionality
 14 | │   └── types.ts              # Type definitions
 15 | ├── tests/                    # Test files
 16 | │   └── notifier.test.ts
 17 | └── examples/                 # Example usage
 18 |     └── demo.ts
 19 | ```
 20 | 
 21 | ## Dependencies
 22 | - `@modelcontextprotocol/sdk` - Core MCP SDK
 23 | - `typescript` - For type safety and better development experience
 24 | - No external dependencies needed for notifications as we'll use native `osascript`
 25 | 
 26 | ## Implementation Details
 27 | 
 28 | ### MCP Server Tools
 29 | The server will expose the following tools:
 30 | 
 31 | 1. `send_notification`
 32 |    - Parameters:
 33 |      - title (required): string - Notification title
 34 |      - message (required): string - Notification message
 35 |      - subtitle (optional): string - Notification subtitle
 36 |      - sound (optional): boolean - Play default notification sound (default: true)
 37 | 
 38 | ### Core Notification Implementation
 39 | We'll use the `osascript` command with AppleScript to send notifications:
 40 | 
 41 | ```applescript
 42 | display notification "message" with title "title" subtitle "subtitle" sound name "default"
 43 | ```
 44 | 
 45 | The notifier module will handle:
 46 | - Parameter validation
 47 | - Escaping special characters
 48 | - Command execution
 49 | - Error handling
 50 | 
 51 | ### Error Handling
 52 | The server will handle common error scenarios:
 53 | - Invalid parameters
 54 | - System command failures
 55 | - Permission issues
 56 | - Resource constraints
 57 | 
 58 | ## Example Usage
 59 | 
 60 | ```typescript
 61 | // Using the MCP tool
 62 | const result = await mcp.use_tool('apple-notifier', 'send_notification', {
 63 |   title: 'Hello',
 64 |   message: 'This is a test notification',
 65 |   subtitle: 'Optional subtitle',
 66 |   sound: true
 67 | });
 68 | ```
 69 | 
 70 | ## Testing Strategy
 71 | 
 72 | 1. Unit Tests
 73 |    - Parameter validation
 74 |    - Command string generation
 75 |    - Error handling
 76 | 
 77 | 2. Integration Tests
 78 |    - End-to-end notification sending
 79 |    - MCP tool interface
 80 |    - Error scenarios
 81 | 
 82 | 3. Manual Testing
 83 |    - Visual verification of notifications
 84 |    - Sound testing
 85 |    - Different parameter combinations
 86 | 
 87 | ## Implementation Steps
 88 | 
 89 | 1. Project Setup
 90 |    - Initialize npm project
 91 |    - Configure TypeScript
 92 |    - Set up development environment
 93 | 
 94 | 2. Core Implementation
 95 |    - Create notifier module
 96 |    - Implement command generation
 97 |    - Add error handling
 98 | 
 99 | 3. MCP Server
100 |    - Implement server class
101 |    - Add tool definitions
102 |    - Set up request handlers
103 | 
104 | 4. Testing
105 |    - Write unit tests
106 |    - Add integration tests
107 |    - Perform manual testing
108 | 
109 | 5. Documentation
110 |    - Add JSDoc comments
111 |    - Create usage examples
112 |    - Document error scenarios
113 | 
114 | ## Future Enhancements
115 | - Support for custom notification sounds
116 | - Rich notifications with images
117 | - Scheduled notifications
118 | - Notification center integration
119 | - Support for notification actions
120 | 
```

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

```typescript
  1 | import { ScreenshotParams, NotificationError, NotificationErrorType } from '../types.js';
  2 | import { execAsync, escapeString } from '../utils/command.js';
  3 | 
  4 | /**
  5 |  * Validates screenshot parameters
  6 |  */
  7 | function validateScreenshotParams(params: ScreenshotParams): void {
  8 |   if (!params.path || typeof params.path !== 'string') {
  9 |     throw new NotificationError(
 10 |       NotificationErrorType.INVALID_PARAMS,
 11 |       'Path is required and must be a string'
 12 |     );
 13 |   }
 14 | 
 15 |   if (!params.type || !['fullscreen', 'window', 'selection'].includes(params.type)) {
 16 |     throw new NotificationError(
 17 |       NotificationErrorType.INVALID_PARAMS,
 18 |       'Type must be one of: fullscreen, window, selection'
 19 |     );
 20 |   }
 21 | 
 22 |   if (params.format && !['png', 'jpg', 'pdf', 'tiff'].includes(params.format)) {
 23 |     throw new NotificationError(
 24 |       NotificationErrorType.INVALID_PARAMS,
 25 |       'Format must be one of: png, jpg, pdf, tiff'
 26 |     );
 27 |   }
 28 | 
 29 |   if (params.hideCursor !== undefined && typeof params.hideCursor !== 'boolean') {
 30 |     throw new NotificationError(
 31 |       NotificationErrorType.INVALID_PARAMS,
 32 |       'HideCursor must be a boolean'
 33 |     );
 34 |   }
 35 | 
 36 |   if (params.shadow !== undefined && typeof params.shadow !== 'boolean') {
 37 |     throw new NotificationError(
 38 |       NotificationErrorType.INVALID_PARAMS,
 39 |       'Shadow must be a boolean'
 40 |     );
 41 |   }
 42 | 
 43 |   if (params.timestamp !== undefined && typeof params.timestamp !== 'boolean') {
 44 |     throw new NotificationError(
 45 |       NotificationErrorType.INVALID_PARAMS,
 46 |       'Timestamp must be a boolean'
 47 |     );
 48 |   }
 49 | }
 50 | 
 51 | /**
 52 |  * Builds the screencapture command
 53 |  */
 54 | function buildScreenshotCommand(params: ScreenshotParams): string {
 55 |   let command = 'screencapture';
 56 |   
 57 |   // Screenshot type
 58 |   switch (params.type) {
 59 |     case 'window':
 60 |       command += ' -w'; // Capture window
 61 |       break;
 62 |     case 'selection':
 63 |       command += ' -s'; // Interactive selection
 64 |       break;
 65 |     // fullscreen is default, no flag needed
 66 |   }
 67 |   
 68 |   // Optional flags
 69 |   if (params.format) {
 70 |     command += ` -t ${params.format}`;
 71 |   }
 72 |   
 73 |   if (params.hideCursor) {
 74 |     command += ' -C'; // Hide cursor
 75 |   }
 76 |   
 77 |   if (params.type === 'window' && params.shadow === false) {
 78 |     command += ' -o'; // No window shadow
 79 |   }
 80 |   
 81 |   // Add timestamp to filename if requested
 82 |   let path = params.path;
 83 |   if (params.timestamp) {
 84 |     const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 85 |     const ext = params.format || 'png';
 86 |     path = path.replace(new RegExp(`\\.${ext}$`), `-${timestamp}.${ext}`);
 87 |   }
 88 |   
 89 |   command += ` "${escapeString(path)}"`;
 90 |   
 91 |   return command;
 92 | }
 93 | 
 94 | /**
 95 |  * Takes a screenshot using screencapture
 96 |  */
 97 | export async function takeScreenshot(params: ScreenshotParams): Promise<void> {
 98 |   try {
 99 |     validateScreenshotParams(params);
100 |     const command = buildScreenshotCommand(params);
101 |     await execAsync(command);
102 |   } catch (error) {
103 |     if (error instanceof NotificationError) {
104 |       throw error;
105 |     }
106 | 
107 |     const err = error as Error;
108 |     if (err.message.includes('execution error')) {
109 |       throw new NotificationError(
110 |         NotificationErrorType.COMMAND_FAILED,
111 |         'Failed to capture screenshot'
112 |       );
113 |     } else if (err.message.includes('permission')) {
114 |       throw new NotificationError(
115 |         NotificationErrorType.PERMISSION_DENIED,
116 |         'Permission denied when trying to capture screenshot'
117 |       );
118 |     } else {
119 |       throw new NotificationError(
120 |         NotificationErrorType.UNKNOWN,
121 |         `Unexpected error: ${err.message}`
122 |       );
123 |     }
124 |   }
125 | }
126 | 
```

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

```typescript
  1 | import { FileSelectParams, FileSelectResult, NotificationError, NotificationErrorType } from '../types.js';
  2 | import { execAsync, escapeString } from '../utils/command.js';
  3 | 
  4 | /**
  5 |  * Validates file selection parameters
  6 |  */
  7 | function validateFileSelectParams(params: FileSelectParams): void {
  8 |   if (params.prompt && typeof params.prompt !== 'string') {
  9 |     throw new NotificationError(
 10 |       NotificationErrorType.INVALID_PARAMS,
 11 |       'Prompt must be a string'
 12 |     );
 13 |   }
 14 | 
 15 |   if (params.defaultLocation && typeof params.defaultLocation !== 'string') {
 16 |     throw new NotificationError(
 17 |       NotificationErrorType.INVALID_PARAMS,
 18 |       'Default location must be a string'
 19 |     );
 20 |   }
 21 | 
 22 |   if (params.multiple !== undefined && typeof params.multiple !== 'boolean') {
 23 |     throw new NotificationError(
 24 |       NotificationErrorType.INVALID_PARAMS,
 25 |       'Multiple selection flag must be a boolean'
 26 |     );
 27 |   }
 28 | 
 29 |   if (params.fileTypes) {
 30 |     if (typeof params.fileTypes !== 'object' || params.fileTypes === null) {
 31 |       throw new NotificationError(
 32 |         NotificationErrorType.INVALID_PARAMS,
 33 |         'File types must be an object'
 34 |       );
 35 |     }
 36 | 
 37 |     for (const [_, extensions] of Object.entries(params.fileTypes)) {
 38 |       if (!Array.isArray(extensions) || !extensions.every(ext => typeof ext === 'string')) {
 39 |         throw new NotificationError(
 40 |           NotificationErrorType.INVALID_PARAMS,
 41 |           'File type extensions must be an array of strings'
 42 |         );
 43 |       }
 44 |     }
 45 |   }
 46 | }
 47 | 
 48 | /**
 49 |  * Builds the AppleScript command for file selection
 50 |  */
 51 | function buildFileSelectCommand(params: FileSelectParams): string {
 52 |   let script = 'choose file';
 53 |   
 54 |   if (params.multiple) {
 55 |     script += ' with multiple selections allowed';
 56 |   }
 57 |   
 58 |   if (params.prompt) {
 59 |     script += ` with prompt "${escapeString(params.prompt)}"`;
 60 |   }
 61 |   
 62 |   if (params.defaultLocation) {
 63 |     script += ` default location "${escapeString(params.defaultLocation)}"`;
 64 |   }
 65 |   
 66 |   // Handle file type filtering if specified
 67 |   if (params.fileTypes) {
 68 |     const extensions = Object.values(params.fileTypes).flat();
 69 |     if (extensions.length > 0) {
 70 |       script += ` of type {${extensions.map(ext => `"${ext}"`).join(', ')}}`;
 71 |     }
 72 |   }
 73 |   
 74 |   return `osascript -e '${script}'`;
 75 | }
 76 | 
 77 | /**
 78 |  * Prompts user to select file(s) using native macOS file picker
 79 |  */
 80 | export async function selectFile(params: FileSelectParams): Promise<FileSelectResult> {
 81 |   try {
 82 |     validateFileSelectParams(params);
 83 |     const command = buildFileSelectCommand(params);
 84 |     const { stdout } = await execAsync(command);
 85 |     
 86 |     // Parse the AppleScript result
 87 |     // Format: alias "path1", alias "path2", ...
 88 |     const paths = stdout
 89 |       .trim()
 90 |       .split(', ')
 91 |       .map(path => path.replace(/^alias "|"$/g, ''))
 92 |       .map(path => path.replace(/:/g, '/'))
 93 |       .map(path => `/${path}`); // Add leading slash
 94 |     
 95 |     return { paths };
 96 |   } catch (error) {
 97 |     if (error instanceof NotificationError) {
 98 |       throw error;
 99 |     }
100 | 
101 |     const err = error as Error;
102 |     if (err.message.includes('User canceled')) {
103 |       throw new NotificationError(
104 |         NotificationErrorType.PROMPT_CANCELLED,
105 |         'File selection was cancelled'
106 |       );
107 |     } else if (err.message.includes('execution error')) {
108 |       throw new NotificationError(
109 |         NotificationErrorType.COMMAND_FAILED,
110 |         'Failed to execute file selection command'
111 |       );
112 |     } else if (err.message.includes('permission')) {
113 |       throw new NotificationError(
114 |         NotificationErrorType.PERMISSION_DENIED,
115 |         'Permission denied when trying to select file'
116 |       );
117 |     } else {
118 |       throw new NotificationError(
119 |         NotificationErrorType.UNKNOWN,
120 |         `Unexpected error: ${err.message}`
121 |       );
122 |     }
123 |   }
124 | }
125 | 
```

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

```typescript
  1 | import { PromptParams, PromptResult, NotificationError, NotificationErrorType } from '../types.js';
  2 | import { execAsync, escapeString } from '../utils/command.js';
  3 | 
  4 | /**
  5 |  * Validates prompt parameters
  6 |  */
  7 | function validatePromptParams(params: PromptParams): void {
  8 |   if (!params.message || typeof params.message !== 'string') {
  9 |     throw new NotificationError(
 10 |       NotificationErrorType.INVALID_PARAMS,
 11 |       'Message is required and must be a string'
 12 |     );
 13 |   }
 14 | 
 15 |   if (params.defaultAnswer && typeof params.defaultAnswer !== 'string') {
 16 |     throw new NotificationError(
 17 |       NotificationErrorType.INVALID_PARAMS,
 18 |       'Default answer must be a string'
 19 |     );
 20 |   }
 21 | 
 22 |   if (params.buttons) {
 23 |     if (!Array.isArray(params.buttons) || !params.buttons.every(b => typeof b === 'string')) {
 24 |       throw new NotificationError(
 25 |         NotificationErrorType.INVALID_PARAMS,
 26 |         'Buttons must be an array of strings'
 27 |       );
 28 |     }
 29 |     if (params.buttons.length > 3) {
 30 |       throw new NotificationError(
 31 |         NotificationErrorType.INVALID_PARAMS,
 32 |         'Maximum of 3 buttons allowed'
 33 |       );
 34 |     }
 35 |   }
 36 | 
 37 |   if (params.icon && !['note', 'stop', 'caution'].includes(params.icon)) {
 38 |     throw new NotificationError(
 39 |       NotificationErrorType.INVALID_PARAMS,
 40 |       'Icon must be one of: note, stop, caution'
 41 |     );
 42 |   }
 43 | }
 44 | 
 45 | /**
 46 |  * Builds the AppleScript command for displaying a prompt
 47 |  */
 48 | function buildPromptCommand(params: PromptParams): string {
 49 |   let script = 'display dialog';
 50 |   
 51 |   script += ` "${escapeString(params.message)}"`;
 52 |   
 53 |   if (params.defaultAnswer !== undefined) {
 54 |     script += ` default answer "${escapeString(params.defaultAnswer)}"`;
 55 |   }
 56 |   
 57 |   if (params.buttons && params.buttons.length > 0) {
 58 |     script += ` buttons {${params.buttons.map(b => `"${escapeString(b)}"`).join(', ')}}`;
 59 |     script += ` default button ${params.buttons.length}`;
 60 |   } else {
 61 |     script += ' buttons {"Cancel", "OK"} default button 2';
 62 |   }
 63 |   
 64 |   if (params.icon) {
 65 |     script += ` with icon ${params.icon}`;
 66 |   }
 67 |   
 68 |   return `osascript -e '${script}'`;
 69 | }
 70 | 
 71 | /**
 72 |  * Prompts the user for input using osascript
 73 |  */
 74 | export async function promptUser(params: PromptParams): Promise<PromptResult> {
 75 |   try {
 76 |     validatePromptParams(params);
 77 |     const command = buildPromptCommand(params);
 78 |     const { stdout } = await execAsync(command);
 79 |     
 80 |     // Parse the AppleScript result
 81 |     // Format: button returned:OK, text returned:user input
 82 |     const match = stdout.match(/button returned:([^,]+)(?:, text returned:(.+))?/);
 83 |     if (!match) {
 84 |       throw new Error('Failed to parse dialog result');
 85 |     }
 86 |     
 87 |     const buttonText = match[1];
 88 |     const text = match[2];
 89 |     
 90 |     // Find the index of the clicked button
 91 |     const buttons = params.buttons || ['Cancel', 'OK'];
 92 |     const buttonIndex = buttons.findIndex(b => b === buttonText);
 93 |     
 94 |     return {
 95 |       text: text,
 96 |       buttonIndex: buttonIndex !== -1 ? buttonIndex : 0
 97 |     };
 98 |   } catch (error) {
 99 |     if (error instanceof NotificationError) {
100 |       throw error;
101 |     }
102 | 
103 |     const err = error as Error;
104 |     if (err.message.includes('User canceled')) {
105 |       throw new NotificationError(
106 |         NotificationErrorType.PROMPT_CANCELLED,
107 |         'User cancelled the prompt'
108 |       );
109 |     } else if (err.message.includes('execution error')) {
110 |       throw new NotificationError(
111 |         NotificationErrorType.COMMAND_FAILED,
112 |         'Failed to execute prompt command'
113 |       );
114 |     } else if (err.message.includes('permission')) {
115 |       throw new NotificationError(
116 |         NotificationErrorType.PERMISSION_DENIED,
117 |         'Permission denied when trying to show prompt'
118 |       );
119 |     } else {
120 |       throw new NotificationError(
121 |         NotificationErrorType.UNKNOWN,
122 |         `Unexpected error: ${err.message}`
123 |       );
124 |     }
125 |   }
126 | }
127 | 
```

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

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
  4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
  5 | import {
  6 |   CallToolRequestSchema,
  7 |   ErrorCode,
  8 |   ListToolsRequestSchema,
  9 |   McpError,
 10 | } from '@modelcontextprotocol/sdk/types.js';
 11 | import { sendNotification } from './features/notification.js';
 12 | import { promptUser } from './features/prompt.js';
 13 | import { speak } from './features/speech.js';
 14 | import { takeScreenshot } from './features/screenshot.js';
 15 | import { selectFile } from './features/fileSelect.js';
 16 | import { 
 17 |   NotificationError, 
 18 |   NotificationErrorType, 
 19 |   NotificationParams, 
 20 |   PromptParams, 
 21 |   PromptResult, 
 22 |   SpeechParams, 
 23 |   ScreenshotParams,
 24 |   FileSelectParams,
 25 |   FileSelectResult 
 26 | } from './types.js';
 27 | 
 28 | class AppleNotifierServer {
 29 |   private server: Server;
 30 | 
 31 |   constructor() {
 32 |     this.server = new Server(
 33 |       {
 34 |         name: 'apple-notifier',
 35 |         version: '1.0.0',
 36 |       },
 37 |       {
 38 |         capabilities: {
 39 |           tools: {},
 40 |         },
 41 |       }
 42 |     );
 43 | 
 44 |     this.setupToolHandlers();
 45 |     
 46 |     // Error handling
 47 |     this.server.onerror = (error) => console.error('[MCP Error]', error);
 48 |     process.on('SIGINT', async () => {
 49 |       await this.server.close();
 50 |       process.exit(0);
 51 |     });
 52 |   }
 53 | 
 54 |   private setupToolHandlers() {
 55 |     // List available tools
 56 |     this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
 57 |       tools: [
 58 |         {
 59 |           name: 'send_notification',
 60 |           description: 'Send a notification on macOS using osascript',
 61 |           inputSchema: {
 62 |             type: 'object',
 63 |             properties: {
 64 |               title: {
 65 |                 type: 'string',
 66 |                 description: 'Title of the notification',
 67 |               },
 68 |               message: {
 69 |                 type: 'string',
 70 |                 description: 'Main message content',
 71 |               },
 72 |               subtitle: {
 73 |                 type: 'string',
 74 |                 description: 'Optional subtitle',
 75 |               },
 76 |               sound: {
 77 |                 type: 'boolean',
 78 |                 description: 'Whether to play the default notification sound',
 79 |                 default: true,
 80 |               },
 81 |             },
 82 |             required: ['title', 'message'],
 83 |             additionalProperties: false,
 84 |           },
 85 |         },
 86 |         {
 87 |           name: 'prompt_user',
 88 |           description: 'Display a dialog prompt to get user input',
 89 |           inputSchema: {
 90 |             type: 'object',
 91 |             properties: {
 92 |               message: {
 93 |                 type: 'string',
 94 |                 description: 'Text to display in the prompt dialog',
 95 |               },
 96 |               defaultAnswer: {
 97 |                 type: 'string',
 98 |                 description: 'Optional default text to pre-fill',
 99 |               },
100 |               buttons: {
101 |                 type: 'array',
102 |                 items: {
103 |                   type: 'string'
104 |                 },
105 |                 description: 'Optional custom button labels (max 3)',
106 |                 maxItems: 3
107 |               },
108 |               icon: {
109 |                 type: 'string',
110 |                 enum: ['note', 'stop', 'caution'],
111 |                 description: 'Optional icon to display'
112 |               }
113 |             },
114 |             required: ['message'],
115 |             additionalProperties: false,
116 |           },
117 |         },
118 |         {
119 |           name: 'speak',
120 |           description: 'Speak text using macOS text-to-speech',
121 |           inputSchema: {
122 |             type: 'object',
123 |             properties: {
124 |               text: {
125 |                 type: 'string',
126 |                 description: 'Text to speak',
127 |               },
128 |               voice: {
129 |                 type: 'string',
130 |                 description: 'Voice to use (defaults to system voice)',
131 |               },
132 |               rate: {
133 |                 type: 'number',
134 |                 description: 'Speech rate (-50 to 50, defaults to 0)',
135 |                 minimum: -50,
136 |                 maximum: 50
137 |               }
138 |             },
139 |             required: ['text'],
140 |             additionalProperties: false,
141 |           },
142 |         },
143 |         {
144 |           name: 'take_screenshot',
145 |           description: 'Take a screenshot using macOS screencapture',
146 |           inputSchema: {
147 |             type: 'object',
148 |             properties: {
149 |               path: {
150 |                 type: 'string',
151 |                 description: 'Path where to save the screenshot',
152 |               },
153 |               type: {
154 |                 type: 'string',
155 |                 enum: ['fullscreen', 'window', 'selection'],
156 |                 description: 'Type of screenshot to take',
157 |               },
158 |               format: {
159 |                 type: 'string',
160 |                 enum: ['png', 'jpg', 'pdf', 'tiff'],
161 |                 description: 'Image format',
162 |               },
163 |               hideCursor: {
164 |                 type: 'boolean',
165 |                 description: 'Whether to hide the cursor',
166 |               },
167 |               shadow: {
168 |                 type: 'boolean',
169 |                 description: 'Whether to include the window shadow (only for window type)',
170 |               },
171 |               timestamp: {
172 |                 type: 'boolean',
173 |                 description: 'Timestamp to add to filename',
174 |               }
175 |             },
176 |             required: ['path', 'type'],
177 |             additionalProperties: false,
178 |           },
179 |         },
180 |         {
181 |           name: 'select_file',
182 |           description: 'Open native file picker dialog',
183 |           inputSchema: {
184 |             type: 'object',
185 |             properties: {
186 |               prompt: {
187 |                 type: 'string',
188 |                 description: 'Optional prompt message'
189 |               },
190 |               defaultLocation: {
191 |                 type: 'string',
192 |                 description: 'Optional default directory path'
193 |               },
194 |               fileTypes: {
195 |                 type: 'object',
196 |                 description: 'Optional file type filter (e.g., {"public.image": ["png", "jpg"]})',
197 |                 additionalProperties: {
198 |                   type: 'array',
199 |                   items: {
200 |                     type: 'string'
201 |                   }
202 |                 }
203 |               },
204 |               multiple: {
205 |                 type: 'boolean',
206 |                 description: 'Whether to allow multiple selection'
207 |               }
208 |             },
209 |             additionalProperties: false
210 |           }
211 |         },
212 |       ],
213 |     }));
214 | 
215 |     // Handle tool execution
216 |     this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
217 |       try {
218 |         if (!request.params.arguments || typeof request.params.arguments !== 'object') {
219 |           throw new McpError(ErrorCode.InvalidParams, 'Invalid parameters');
220 |         }
221 | 
222 |         switch (request.params.name) {
223 |           case 'send_notification': {
224 |             const { title, message, subtitle, sound } = request.params.arguments as Record<string, unknown>;
225 |             
226 |             if (typeof title !== 'string' || typeof message !== 'string') {
227 |               throw new McpError(ErrorCode.InvalidParams, 'Title and message must be strings');
228 |             }
229 | 
230 |             const params: NotificationParams = {
231 |               title,
232 |               message,
233 |               subtitle: typeof subtitle === 'string' ? subtitle : undefined,
234 |               sound: typeof sound === 'boolean' ? sound : undefined
235 |             };
236 | 
237 |             await sendNotification(params);
238 |             return {
239 |               content: [
240 |                 {
241 |                   type: 'text',
242 |                   text: 'Notification sent successfully',
243 |                 },
244 |               ],
245 |             };
246 |           }
247 | 
248 |           case 'prompt_user': {
249 |             const { message, defaultAnswer, buttons, icon } = request.params.arguments as Record<string, unknown>;
250 |             
251 |             const params: PromptParams = {
252 |               message: message as string,
253 |               defaultAnswer: typeof defaultAnswer === 'string' ? defaultAnswer : undefined,
254 |               buttons: Array.isArray(buttons) ? buttons as string[] : undefined,
255 |               icon: ['note', 'stop', 'caution'].includes(icon as string) ? icon as 'note' | 'stop' | 'caution' : undefined
256 |             };
257 | 
258 |             const result = await promptUser(params);
259 |             return {
260 |               content: [
261 |                 {
262 |                   type: 'text',
263 |                   text: JSON.stringify(result),
264 |                 },
265 |               ],
266 |             };
267 |           }
268 | 
269 |           case 'speak': {
270 |             const { text, voice, rate } = request.params.arguments as Record<string, unknown>;
271 |             
272 |             const params: SpeechParams = {
273 |               text: text as string,
274 |               voice: typeof voice === 'string' ? voice : undefined,
275 |               rate: typeof rate === 'number' ? rate : undefined
276 |             };
277 | 
278 |             await speak(params);
279 |             return {
280 |               content: [
281 |                 {
282 |                   type: 'text',
283 |                   text: 'Speech completed successfully',
284 |                 },
285 |               ],
286 |             };
287 |           }
288 | 
289 |           case 'take_screenshot': {
290 |             const { path, type, format, hideCursor, shadow, timestamp } = request.params.arguments as Record<string, unknown>;
291 |             
292 |             const params: ScreenshotParams = {
293 |               path: path as string,
294 |               type: type as 'fullscreen' | 'window' | 'selection',
295 |               format: format as 'png' | 'jpg' | 'pdf' | 'tiff' | undefined,
296 |               hideCursor: typeof hideCursor === 'boolean' ? hideCursor : undefined,
297 |               shadow: typeof shadow === 'boolean' ? shadow : undefined,
298 |               timestamp: typeof timestamp === 'boolean' ? timestamp : undefined
299 |             };
300 | 
301 |             await takeScreenshot(params);
302 |             return {
303 |               content: [
304 |                 {
305 |                   type: 'text',
306 |                   text: 'Screenshot saved successfully',
307 |                 },
308 |               ],
309 |             };
310 |           }
311 | 
312 |           case 'select_file': {
313 |             const { prompt, defaultLocation, fileTypes, multiple } = request.params.arguments as Record<string, unknown>;
314 |             
315 |             const params: FileSelectParams = {
316 |               prompt: typeof prompt === 'string' ? prompt : undefined,
317 |               defaultLocation: typeof defaultLocation === 'string' ? defaultLocation : undefined,
318 |               fileTypes: typeof fileTypes === 'object' && fileTypes !== null ? fileTypes as Record<string, string[]> : undefined,
319 |               multiple: typeof multiple === 'boolean' ? multiple : undefined
320 |             };
321 | 
322 |             const result = await selectFile(params);
323 |             return {
324 |               content: [
325 |                 {
326 |                   type: 'text',
327 |                   text: JSON.stringify(result),
328 |                 },
329 |               ],
330 |             };
331 |           }
332 | 
333 |           default:
334 |             throw new McpError(
335 |               ErrorCode.MethodNotFound,
336 |               `Unknown tool: ${request.params.name}`
337 |             );
338 |         }
339 |       } catch (error) {
340 |         if (error instanceof NotificationError) {
341 |           let errorCode: ErrorCode;
342 |           switch (error.type) {
343 |             case NotificationErrorType.INVALID_PARAMS:
344 |               errorCode = ErrorCode.InvalidParams;
345 |               break;
346 |             default:
347 |               errorCode = ErrorCode.InternalError;
348 |           }
349 |           throw new McpError(errorCode, error.message);
350 |         }
351 |         throw error;
352 |       }
353 |     });
354 |   }
355 | 
356 |   async run() {
357 |     const transport = new StdioServerTransport();
358 |     await this.server.connect(transport);
359 |     console.error('Apple Notifier MCP server running on stdio');
360 |   }
361 | }
362 | 
363 | const server = new AppleNotifierServer();
364 | server.run().catch(console.error);
365 | 
```