# 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 | [](https://mseep.ai/app/turlockmike-apple-notifier-mcp)
2 |
3 | # Apple Notifier MCP Server
4 |
5 | [](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 |
```