# Directory Structure ``` ├── .github │ └── workflows │ ├── node.js.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── categories │ │ ├── calendar.ts │ │ ├── clipboard.ts │ │ ├── finder.ts │ │ ├── iterm.ts │ │ ├── mail.ts │ │ ├── messages.ts │ │ ├── notes.ts │ │ ├── notifications.ts │ │ ├── pages.ts │ │ ├── shortcuts.ts │ │ └── system.ts │ ├── framework.ts │ ├── index.ts │ └── types │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | /dist 6 | .DS_Store 7 | helps/* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # applescript-mcp MCP Server 2 | 3 | A Model Context Protocol server that enables LLM applications to interact with macOS through AppleScript. 4 | This server provides a standardized interface for AI applications to control system functions, manage files, handle notifications, and more. 5 | 6 | [](https://github.com/joshrutkowski/applescript-mcp/actions/workflows/node.js.yml) 7 | 8 | <a href="https://glama.ai/mcp/servers/0t5gydjcqw"><img width="380" height="200" src="https://glama.ai/mcp/servers/0t5gydjcqw/badge" alt="applescript-mcp MCP server" /></a> 9 | 10 | ## Features 11 | 12 | - 🗓️ Calendar management (events, reminders) 13 | - 📋 Clipboard operations 14 | - 🔍 Finder integration 15 | - 🔔 System notifications 16 | - ⚙️ System controls (volume, dark mode, apps) 17 | - 📟 iTerm terminal integration 18 | - 📬 Mail (create new email, list emails, get email) 19 | - 🔄 Shortcuts automation 20 | - 💬 Messages (list chats, get messages, search messages, send a message) 21 | - 🗒️ Notes (create formatted notes, list notes, search notes) 22 | - 📄 Pages (create documents) 23 | 24 | ### Planned Features 25 | 26 | - 🧭 Safari (open in Safari, save page content, get selected page/tab) 27 | - ✅ Reminders (create, get) 28 | 29 | ## Prerequisites 30 | 31 | - macOS 10.15 or later 32 | - Node.js 18 or later 33 | 34 | ## Available Categories 35 | 36 | ### Calendar 37 | 38 | | Command | Description | Parameters | 39 | | ------- | --------------------- | --------------------------------------------------- | 40 | | `add` | Create calendar event | `title`, `startDate`, `endDate`, `calendar` (optional) | 41 | | `list` | List today's events | None | 42 | 43 | #### Examples 44 | 45 | ``` 46 | // Create a new calendar event 47 | Create a calendar event titled "Team Meeting" starting tomorrow at 2pm for 1 hour 48 | 49 | // List today's events 50 | What events do I have scheduled for today? 51 | ``` 52 | 53 | ### Clipboard 54 | 55 | | Command | Description | Parameters | 56 | | ----------------- | ---------------------- | ---------- | 57 | | `set_clipboard` | Copy to clipboard | `content` | 58 | | `get_clipboard` | Get clipboard contents | None | 59 | | `clear_clipboard` | Clear clipboard | None | 60 | 61 | #### Examples 62 | 63 | ``` 64 | // Copy text to clipboard 65 | Copy "Remember to buy groceries" to my clipboard 66 | 67 | // Get clipboard contents 68 | What's currently in my clipboard? 69 | 70 | // Clear clipboard 71 | Clear my clipboard 72 | ``` 73 | 74 | ### Finder 75 | 76 | | Command | Description | Parameters | 77 | | -------------------- | ------------------ | ------------------------------ | 78 | | `get_selected_files` | Get selected files | None | 79 | | `search_files` | Search for files | `query`, `location` (optional) | 80 | | `quick_look` | Preview file | `path` | 81 | 82 | #### Examples 83 | 84 | ``` 85 | // Get selected files in Finder 86 | What files do I currently have selected in Finder? 87 | 88 | // Search for files 89 | Find all PDF files in my Documents folder 90 | 91 | // Preview a file 92 | Show me a preview of ~/Documents/report.pdf 93 | ``` 94 | 95 | ### Notifications 96 | 97 | > Note: Sending notification requires that you enable notifications in System Settings > Notifications > Script Editor. 98 | 99 | | Command | Description | Parameters | 100 | | ----------------------- | ----------------- | -------------------------------------- | 101 | | `send_notification` | Show notification | `title`, `message`, `sound` (optional) | 102 | | `toggle_do_not_disturb` | Toggle DND mode | None | 103 | 104 | #### Examples 105 | 106 | ``` 107 | // Send a notification 108 | Send me a notification with the title "Reminder" and message "Time to take a break" 109 | 110 | // Toggle Do Not Disturb 111 | Turn on Do Not Disturb mode 112 | ``` 113 | 114 | ### System 115 | 116 | | Command | Description | Parameters | 117 | | ------------------- | ----------------- | -------------------------- | 118 | | `volume` | Set system volume | `level` (0-100) | 119 | | `get_frontmost_app` | Get active app | None | 120 | | `launch_app` | Open application | `name` | 121 | | `quit_app` | Close application | `name`, `force` (optional) | 122 | | `toggle_dark_mode` | Toggle dark mode | None | 123 | 124 | #### Examples 125 | 126 | ``` 127 | // Set system volume 128 | Set my Mac's volume to 50% 129 | 130 | // Get active application 131 | What app am I currently using? 132 | 133 | // Launch an application 134 | Open Safari 135 | 136 | // Quit an application 137 | Close Spotify 138 | 139 | // Toggle dark mode 140 | Switch to dark mode 141 | ``` 142 | 143 | ### iTerm 144 | 145 | | Command | Description | Parameters | 146 | | ----------------- | --------------- | --------------------------------- | 147 | | `paste_clipboard` | Paste to iTerm | None | 148 | | `run` | Execute command | `command`, `newWindow` (optional) | 149 | 150 | #### Examples 151 | 152 | ``` 153 | // Paste clipboard to iTerm 154 | Paste my clipboard contents into iTerm 155 | 156 | // Run a command in iTerm 157 | Run "ls -la" in iTerm 158 | 159 | // Run a command in a new iTerm window 160 | Run "top" in a new iTerm window 161 | ``` 162 | 163 | ### Shortcuts 164 | 165 | | Command | Description | Parameters | 166 | | ---------------- | ------------------------------------------ | ---------------------------------------------------- | 167 | | `run_shortcut` | Run a shortcut | `name`, `input` (optional) | 168 | | `list_shortcuts` | List all available shortcuts | `limit` (optional) | 169 | 170 | #### Examples 171 | 172 | ``` 173 | // List available shortcuts 174 | List all my available shortcuts 175 | 176 | // List with limit 177 | Show me my top 5 shortcuts 178 | 179 | // Run a shortcut 180 | Run my "Daily Note in Bear" shortcut 181 | 182 | // Run a shortcut with input 183 | Run my "Add to-do" shortcut with input "Buy groceries" 184 | ``` 185 | 186 | ### Mail 187 | 188 | | Command | Description | Parameters | 189 | | ------------- | -------------------------------- | --------------------------------------------------------- | 190 | | `create_email`| Create a new email in Mail.app | `recipient`, `subject`, `body` | 191 | | `list_emails` | List emails from a mailbox | `mailbox` (optional), `count` (optional), `unreadOnly` (optional) | 192 | | `get_email` | Get a specific email by search | `subject` (optional), `sender` (optional), `dateReceived` (optional), `mailbox` (optional), `account` (optional), `unreadOnly` (optional), `includeBody` (optional) | 193 | 194 | #### Examples 195 | 196 | ``` 197 | // Create a new email 198 | Compose an email to [email protected] with subject "Meeting Tomorrow" and body "Hi John, Can we meet tomorrow at 2pm?" 199 | 200 | // List emails 201 | Show me my 10 most recent unread emails 202 | 203 | // Get a specific email 204 | Find the email from [email protected] about "Project Update" 205 | ``` 206 | 207 | ### Messages 208 | 209 | | Command | Description | Parameters | 210 | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | 211 | | `list_chats` | List available iMessage and SMS chats | `includeParticipantDetails` (optional, default: false) | 212 | | `get_messages` | Get messages from the Messages app | `limit` (optional, default: 100) | 213 | | `search_messages` | Search for messages containing specific text | `searchText`, `sender` (optional), `chatId` (optional), `limit` (optional, default: 50), `daysBack` (optional, default: 30) | 214 | | `compose_message` | Open Messages app with pre-filled message or auto-send | `recipient` (required), `body` (optional), `auto` (optional, default: false) | 215 | 216 | #### Examples 217 | 218 | ``` 219 | // List available chats 220 | Show me my recent message conversations 221 | 222 | // Get recent messages 223 | Show me my last 20 messages 224 | 225 | // Search messages 226 | Find messages containing "dinner plans" from John in the last week 227 | 228 | // Compose a message 229 | Send a message to 555-123-4567 saying "I'll be there in 10 minutes" 230 | ``` 231 | 232 | ### Notes 233 | 234 | | Command | Description | Parameters | 235 | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | 236 | | `create` | Create a note with markdown-like formatting | `title`, `content`, `format` (optional with formatting options) | 237 | | `createRawHtml` | Create a note with direct HTML content | `title`, `html` | 238 | | `list` | List notes, optionally from a specific folder| `folder` (optional) | 239 | | `get` | Get a specific note by title | `title`, `folder` (optional) | 240 | | `search` | Search for notes containing specific text | `query`, `folder` (optional), `limit` (optional, default: 5), `includeBody` (optional, default: true) | 241 | 242 | #### Examples 243 | 244 | ``` 245 | // Create a new note with markdown formatting 246 | Create a note titled "Meeting Minutes" with content "# Discussion Points\n- Project timeline\n- Budget review\n- Next steps" and format headings and lists 247 | 248 | // Create a note with HTML 249 | Create a note titled "Formatted Report" with HTML content "<h1>Quarterly Report</h1><p>Sales increased by <strong>15%</strong></p>" 250 | 251 | // List notes 252 | Show me all my notes in the "Work" folder 253 | 254 | // Get a specific note 255 | Show me my note titled "Shopping List" 256 | 257 | // Search notes 258 | Find notes containing "recipe" in my "Cooking" folder 259 | ``` 260 | 261 | ### Pages 262 | 263 | | Command | Description | Parameters | 264 | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | 265 | | `create_document` | Create a new Pages document with plain text | `content` | 266 | 267 | #### Examples 268 | 269 | ``` 270 | // Create a new Pages document 271 | Create a Pages document with the content "Project Proposal\n\nThis document outlines the scope and timeline for the upcoming project." 272 | ``` 273 | 274 | ## Architecture 275 | 276 | The applescript-mcp server is built using TypeScript and follows a modular architecture: 277 | 278 | ### Core Components 279 | 280 | 1. **AppleScriptFramework** (`framework.ts`): The main server class that: 281 | - Manages MCP protocol communication 282 | - Handles tool registration and execution 283 | - Provides logging functionality 284 | - Executes AppleScript commands 285 | 286 | 2. **Categories** (`src/categories/*.ts`): Modular script collections organized by functionality: 287 | - Each category contains related scripts (e.g., calendar, system, notes) 288 | - Categories are registered with the framework in `index.ts` 289 | 290 | 3. **Types** (`src/types/index.ts`): TypeScript interfaces defining: 291 | - `ScriptDefinition`: Structure for individual scripts 292 | - `ScriptCategory`: Collection of related scripts 293 | - `LogLevel`: Standard logging levels 294 | - `FrameworkOptions`: Configuration options 295 | 296 | ### Execution Flow 297 | 298 | 1. Client sends a tool request via MCP protocol 299 | 2. Server identifies the appropriate category and script 300 | 3. Script content is generated (static or dynamically via function) 301 | 4. AppleScript is executed via macOS `osascript` command 302 | 5. Results are returned to the client 303 | 304 | ### Logging System 305 | 306 | The framework includes a comprehensive logging system that: 307 | - Logs to both stderr and MCP logging protocol 308 | - Supports multiple severity levels (debug, info, warning, error, etc.) 309 | - Provides detailed execution information for troubleshooting 310 | 311 | ## Development 312 | 313 | ### Setup 314 | 315 | ```bash 316 | # Install dependencies 317 | npm install 318 | 319 | # Build the server 320 | npm run build 321 | 322 | # Launch MCP Inspector 323 | # See: https://modelcontextprotocol.io/docs/tools/inspector 324 | npx @modelcontextprotocol/inspector node path/to/server/index.js args... 325 | ``` 326 | 327 | ### Adding New Functionality 328 | 329 | #### 1. Create Category File 330 | 331 | Create `src/categories/newcategory.ts`: 332 | 333 | ```typescript 334 | import { ScriptCategory } from "../types/index.js"; 335 | 336 | export const newCategory: ScriptCategory = { 337 | name: "category_name", 338 | description: "Category description", 339 | scripts: [ 340 | // Scripts will go here 341 | ], 342 | }; 343 | ``` 344 | 345 | #### 2. Add Scripts 346 | 347 | ```typescript 348 | { 349 | name: "script_name", 350 | description: "What the script does", 351 | schema: { 352 | type: "object", 353 | properties: { 354 | paramName: { 355 | type: "string", 356 | description: "Parameter description" 357 | } 358 | }, 359 | required: ["paramName"] 360 | }, 361 | script: (args) => ` 362 | tell application "App" 363 | // AppleScript code using ${args.paramName} 364 | end tell 365 | ` 366 | } 367 | ``` 368 | 369 | #### 3. Register Category 370 | 371 | Update `src/index.ts`: 372 | 373 | ```typescript 374 | import { newCategory } from "./categories/newcategory.js"; 375 | // ... 376 | server.addCategory(newCategory); 377 | ``` 378 | 379 | ### Advanced Script Development 380 | 381 | For more complex scripts, you can: 382 | 383 | 1. **Use dynamic script generation**: 384 | ```typescript 385 | script: (args) => { 386 | // Process arguments and build script dynamically 387 | let scriptContent = `tell application "App"\n`; 388 | 389 | if (args.condition) { 390 | scriptContent += ` // Conditional logic\n`; 391 | } 392 | 393 | scriptContent += `end tell`; 394 | return scriptContent; 395 | } 396 | ``` 397 | 398 | 2. **Process complex data**: 399 | ```typescript 400 | // Example from Notes category 401 | function generateNoteHtml(args: any): string { 402 | // Process markdown-like syntax into HTML 403 | let processedContent = content; 404 | 405 | if (format.headings) { 406 | processedContent = processedContent.replace(/^# (.+)$/gm, '<h1>$1</h1>'); 407 | // ... 408 | } 409 | 410 | return processedContent; 411 | } 412 | ``` 413 | 414 | ## Debugging 415 | 416 | ### Using MCP Inspector 417 | 418 | The MCP Inspector provides a web interface for testing and debugging your server: 419 | 420 | ```bash 421 | npm run inspector 422 | ``` 423 | 424 | ### Logging 425 | 426 | Enable debug logging by setting the environment variable: 427 | 428 | ```bash 429 | DEBUG=applescript-mcp* npm start 430 | ``` 431 | 432 | ### Example configuration 433 | After running `npm run build` add the following to your `mcp.json` file: 434 | 435 | ```json 436 | { 437 | "mcpServers": { 438 | "applescript-mcp-server": { 439 | "command": "node", 440 | "args": ["/path/to/applescript-mcp/dist/index.js"] 441 | } 442 | } 443 | } 444 | ``` 445 | 446 | ### Common Issues 447 | 448 | - **Permission Errors**: Check System Preferences > Security & Privacy > Privacy > Automation 449 | - **Script Failures**: Test scripts directly in Script Editor.app before integration 450 | - **Communication Issues**: Check stdio streams aren't being redirected 451 | - **Database Access**: Some features (like Messages) require Full Disk Access permission 452 | 453 | ## Resources 454 | 455 | - [AppleScript Language Guide](https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html) 456 | - [MCP Protocol Documentation](https://modelcontextprotocol.io) 457 | - [Issue Tracker](https://github.com/joshrutkowski/applescript-mcp/issues) 458 | 459 | ## Contributing 460 | 461 | 1. Fork the repository 462 | 2. Create a feature branch 463 | 3. Commit your changes 464 | 4. Push to the branch 465 | 5. Create a Pull Request 466 | 467 | ## License 468 | 469 | MIT License - see [LICENSE](LICENSE) for details 470 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "applescript-mcp", 3 | "version": "1.0.4", 4 | "description": "AppleScript MCP Framework", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc", 9 | "start": "node dist/index.js", 10 | "dev": "ts-node --esm src/index.ts" 11 | }, 12 | "keywords": [ 13 | "mcp", 14 | "applescript", 15 | "macos" 16 | ], 17 | "author": "Josh Rutkowski", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@modelcontextprotocol/sdk": "^1.1.1" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^20.0.0", 24 | "typescript": "^5.0.0", 25 | "ts-node": "^10.9.0" 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- ```yaml 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x] 19 | # Using macOS runners since this is an AppleScript project 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build 30 | ``` -------------------------------------------------------------------------------- /src/categories/pages.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * Pages-related scripts. 5 | * * create_document: Create a new Pages document with plain text content 6 | */ 7 | export const pagesCategory: ScriptCategory = { 8 | name: "pages", 9 | description: "Pages document operations", 10 | scripts: [ 11 | { 12 | name: "create_document", 13 | description: "Create a new Pages document with plain text content (no formatting)", 14 | schema: { 15 | type: "object", 16 | properties: { 17 | content: { 18 | type: "string", 19 | description: "The plain text content to add to the document (no formatting)" 20 | } 21 | }, 22 | required: ["content"] 23 | }, 24 | script: (args) => ` 25 | try 26 | tell application "Pages" 27 | -- Create new document 28 | set newDoc to make new document 29 | 30 | set the body text of newDoc to "${args.content.replace(/"/g, '\\"')}" 31 | activate 32 | return "Document created successfully with plain text content" 33 | end tell 34 | on error errMsg 35 | return "Failed to create document: " & errMsg 36 | end try 37 | ` 38 | } 39 | ] 40 | }; 41 | ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export interface ScriptDefinition { 2 | /** 3 | * The name of the script. 4 | */ 5 | name: string; 6 | 7 | /** 8 | * A brief description of what the script does. 9 | */ 10 | description: string; 11 | 12 | /** 13 | * The script content, which can be a string or a function that returns a string. 14 | */ 15 | script: string | ((args: any) => string); 16 | 17 | /** 18 | * Optional schema defining the structure of the script's input parameters. 19 | */ 20 | schema?: { 21 | type: "object"; 22 | properties: Record<string, any>; 23 | 24 | /** 25 | * Optional list of required properties in the schema. 26 | */ 27 | required?: string[]; 28 | }; 29 | } 30 | 31 | export interface ScriptCategory { 32 | /** 33 | * The name of the script category. 34 | */ 35 | name: string; 36 | 37 | /** 38 | * A brief description of the script category. 39 | */ 40 | description: string; 41 | 42 | /** 43 | * A list of scripts that belong to this category. 44 | */ 45 | scripts: ScriptDefinition[]; 46 | } 47 | 48 | /** 49 | * Standard log levels for the framework's logging system. 50 | * Follows the RFC 5424 syslog severity levels. 51 | */ 52 | export type LogLevel = 53 | | "emergency" // System is unusable 54 | | "alert" // Action must be taken immediately 55 | | "critical" // Critical conditions 56 | | "error" // Error conditions 57 | | "warning" // Warning conditions 58 | | "notice" // Normal but significant condition 59 | | "info" // Informational messages 60 | | "debug"; // Debug-level messages 61 | 62 | export interface FrameworkOptions { 63 | /** 64 | * Optional name of the framework. 65 | */ 66 | name?: string; 67 | 68 | /** 69 | * Optional version of the framework. 70 | */ 71 | version?: string; 72 | 73 | /** 74 | * Optional flag to enable or disable debug mode. 75 | */ 76 | debug?: boolean; 77 | } 78 | ``` -------------------------------------------------------------------------------- /src/categories/notifications.ts: -------------------------------------------------------------------------------- ```typescript 1 | // src/categories/notifications.ts 2 | import { ScriptCategory } from "../types/index.js"; 3 | 4 | /** 5 | * Notification-related scripts. 6 | * * toggle_do_not_disturb: Toggle Do Not Disturb mode. NOTE: Requires keyboard shortcut to be set up in System Preferences. 7 | * * send_notification: Send a system notification 8 | */ 9 | export const notificationsCategory: ScriptCategory = { 10 | name: "notifications", 11 | description: "Notification management", 12 | scripts: [ 13 | { 14 | name: "toggle_do_not_disturb", 15 | description: "Toggle Do Not Disturb mode using keyboard shortcut", 16 | script: ` 17 | try 18 | tell application "System Events" 19 | keystroke "z" using {control down, option down, command down} 20 | end tell 21 | return "Toggled Do Not Disturb mode" 22 | on error errMsg 23 | return "Failed to toggle Do Not Disturb: " & errMsg 24 | end try 25 | `, 26 | }, 27 | { 28 | name: "send_notification", 29 | description: "Send a system notification", 30 | schema: { 31 | type: "object", 32 | properties: { 33 | title: { 34 | type: "string", 35 | description: "Notification title", 36 | }, 37 | message: { 38 | type: "string", 39 | description: "Notification message", 40 | }, 41 | sound: { 42 | type: "boolean", 43 | description: "Play sound with notification", 44 | default: true, 45 | }, 46 | }, 47 | required: ["title", "message"], 48 | }, 49 | script: (args) => ` 50 | display notification "${args.message}" with title "${args.title}" ${args.sound ? 'sound name "default"' : ""} 51 | `, 52 | }, 53 | ], 54 | }; 55 | ``` -------------------------------------------------------------------------------- /src/categories/iterm.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * iTerm-related scripts. 5 | * * paste_clipboard: Pastes the clipboard to an iTerm window 6 | * * run: Run a command in iTerm 7 | */ 8 | export const itermCategory: ScriptCategory = { 9 | name: "iterm", 10 | description: "iTerm terminal operations", 11 | scripts: [ 12 | { 13 | name: "paste_clipboard", 14 | description: "Paste clipboard content into iTerm", 15 | script: ` 16 | tell application "System Events" to keystroke "c" using {command down} 17 | delay 0.1 18 | tell application "iTerm" 19 | set w to current window 20 | tell w's current session to write text (the clipboard) 21 | activate 22 | end tell 23 | `, 24 | }, 25 | { 26 | name: "run", 27 | description: "Run a command in iTerm", 28 | schema: { 29 | type: "object", 30 | properties: { 31 | command: { 32 | type: "string", 33 | description: "Command to run in iTerm", 34 | }, 35 | newWindow: { 36 | type: "boolean", 37 | description: "Whether to open in a new window (default: false)", 38 | default: false, 39 | }, 40 | }, 41 | required: ["command"], 42 | }, 43 | script: (args) => ` 44 | tell application "iTerm" 45 | ${ 46 | args.newWindow 47 | ? ` 48 | set newWindow to (create window with default profile) 49 | tell current session of newWindow 50 | ` 51 | : ` 52 | set w to current window 53 | tell w's current session 54 | ` 55 | } 56 | write text "${args.command}" 57 | activate 58 | end tell 59 | end tell 60 | `, 61 | }, 62 | ], 63 | }; 64 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Trigger on version tags (v1.0.0, v1.2.3, etc.) 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | repository-projects: write 12 | 13 | jobs: 14 | build: 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20.x' 25 | registry-url: 'https://registry.npmjs.org' 26 | cache: 'npm' 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Build package 32 | run: npm run build 33 | 34 | - name: Get version from tag 35 | id: get_version 36 | run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 37 | 38 | - name: Create package archive 39 | run: | 40 | mkdir -p dist-package 41 | cp -r dist package.json README.md LICENSE dist-package/ 42 | cd dist-package 43 | npm pack 44 | mv *.tgz ../applescript-mcp-${{ steps.get_version.outputs.VERSION }}.tgz 45 | 46 | - name: Create GitHub Release 47 | uses: softprops/action-gh-release@v1 48 | with: 49 | name: Release ${{ github.ref_name }} 50 | draft: false 51 | prerelease: false 52 | body: | 53 | Release of applescript-mcp version ${{ steps.get_version.outputs.VERSION }} 54 | 55 | ## Changes 56 | 57 | <!-- Add your release notes here --> 58 | files: | 59 | ./applescript-mcp-${{ steps.get_version.outputs.VERSION }}.tgz 60 | 61 | # Uncomment the following step if you want to publish to npm 62 | # - name: Publish to npm 63 | # run: npm publish 64 | # env: 65 | # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { AppleScriptFramework } from "./framework.js"; 2 | import { systemCategory } from "./categories/system.js"; 3 | import { calendarCategory } from "./categories/calendar.js"; 4 | import { finderCategory } from "./categories/finder.js"; 5 | import { clipboardCategory } from "./categories/clipboard.js"; 6 | import { notificationsCategory } from "./categories/notifications.js"; 7 | import { itermCategory } from "./categories/iterm.js"; 8 | import { mailCategory } from "./categories/mail.js"; 9 | import { pagesCategory } from "./categories/pages.js"; 10 | import { shortcutsCategory } from "./categories/shortcuts.js"; 11 | import { messagesCategory } from "./categories/messages.js"; 12 | import { notesCategory } from "./categories/notes.js"; 13 | 14 | const server = new AppleScriptFramework({ 15 | name: "applescript-server", 16 | version: "1.0.4", 17 | debug: false, 18 | }); 19 | 20 | // Log startup information using stderr (server isn't connected yet) 21 | console.error(`[INFO] Starting AppleScript MCP server - PID: ${process.pid}`); 22 | 23 | // Add all categories 24 | console.error("[INFO] Registering categories..."); 25 | server.addCategory(systemCategory); 26 | server.addCategory(calendarCategory); 27 | server.addCategory(finderCategory); 28 | server.addCategory(clipboardCategory); 29 | server.addCategory(notificationsCategory); 30 | server.addCategory(itermCategory); 31 | server.addCategory(mailCategory); 32 | server.addCategory(pagesCategory); 33 | server.addCategory(shortcutsCategory); 34 | server.addCategory(messagesCategory); 35 | server.addCategory(notesCategory); 36 | console.error(`[INFO] Registered ${11} categories successfully`); 37 | 38 | // Start the server 39 | console.error("[INFO] Starting server..."); 40 | server.run() 41 | .then(() => { 42 | console.error("[NOTICE] Server started successfully"); 43 | }) 44 | .catch(error => { 45 | const errorMessage = error instanceof Error ? error.message : String(error); 46 | console.error(`[EMERGENCY] Failed to start server: ${errorMessage}`); 47 | console.error(error); 48 | }); 49 | ``` -------------------------------------------------------------------------------- /src/categories/shortcuts.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * Shortcuts-related scripts. 5 | * * run_shortcut: Run a shortcut with optional input 6 | * * list_shortcuts: List available shortcuts 7 | */ 8 | export const shortcutsCategory: ScriptCategory = { 9 | name: "shortcuts", 10 | description: "Shortcuts operations", 11 | scripts: [ 12 | { 13 | name: "run_shortcut", 14 | description: "Run a shortcut with optional input. Uses Shortcuts Events to run in background without opening the app.", 15 | schema: { 16 | type: "object", 17 | properties: { 18 | name: { 19 | type: "string", 20 | description: "Name of the shortcut to run", 21 | }, 22 | input: { 23 | type: "string", 24 | description: "Optional input to provide to the shortcut", 25 | }, 26 | }, 27 | required: ["name"], 28 | }, 29 | script: (args) => ` 30 | try 31 | tell application "Shortcuts Events" 32 | ${args.input ? 33 | `run shortcut "${args.name}" with input "${args.input}"` : 34 | `run shortcut "${args.name}"` 35 | } 36 | end tell 37 | return "Shortcut '${args.name}' executed successfully" 38 | on error errMsg 39 | return "Failed to run shortcut: " & errMsg 40 | end try 41 | `, 42 | }, 43 | { 44 | name: "list_shortcuts", 45 | description: "List all available shortcuts with optional limit", 46 | schema: { 47 | type: "object", 48 | properties: { 49 | limit: { 50 | type: "number", 51 | description: "Optional limit on the number of shortcuts to return", 52 | }, 53 | }, 54 | }, 55 | script: (args) => ` 56 | try 57 | tell application "Shortcuts" 58 | set shortcutNames to name of every shortcut 59 | 60 | ${args.limit ? ` 61 | -- Apply limit if specified 62 | if (count of shortcutNames) > ${args.limit} then 63 | set shortcutNames to items 1 through ${args.limit} of shortcutNames 64 | end if 65 | ` : ``} 66 | end tell 67 | 68 | -- Convert to JSON string manually 69 | set jsonOutput to "{" 70 | set jsonOutput to jsonOutput & "\\"status\\": \\"success\\"," 71 | set jsonOutput to jsonOutput & "\\"shortcuts\\": [" 72 | 73 | repeat with i from 1 to count of shortcutNames 74 | set currentName to item i of shortcutNames 75 | set jsonOutput to jsonOutput & "{\\"name\\": \\"" & currentName & "\\"}" 76 | if i < count of shortcutNames then 77 | set jsonOutput to jsonOutput & ", " 78 | end if 79 | end repeat 80 | 81 | set jsonOutput to jsonOutput & "]}" 82 | return jsonOutput 83 | 84 | on error errMsg 85 | return "{\\"status\\": \\"error\\", \\"message\\": \\"" & errMsg & "\\"}" 86 | end try 87 | `, 88 | }, 89 | ], 90 | }; 91 | ``` -------------------------------------------------------------------------------- /src/categories/clipboard.ts: -------------------------------------------------------------------------------- ```typescript 1 | // src/categories/clipboard.ts 2 | import { ScriptCategory } from "../types/index.js"; 3 | 4 | /** 5 | * Clipboard-related scripts. 6 | * * get_clipboard: Returns the current clipboard content 7 | * * set_clipboard: Sets the clipboard to a specified value 8 | * * clear_clipboard: Resets the clipboard content 9 | */ 10 | export const clipboardCategory: ScriptCategory = { 11 | name: "clipboard", 12 | description: "Clipboard management operations", 13 | scripts: [ 14 | { 15 | name: "get_clipboard", 16 | description: "Get current clipboard content", 17 | schema: { 18 | type: "object", 19 | properties: { 20 | type: { 21 | type: "string", 22 | enum: ["text", "file_paths"], 23 | description: "Type of clipboard content to get", 24 | default: "text", 25 | }, 26 | }, 27 | }, 28 | script: (args) => { 29 | if (args?.type === "file_paths") { 30 | return ` 31 | tell application "System Events" 32 | try 33 | set theClipboard to the clipboard 34 | if theClipboard starts with "file://" then 35 | set AppleScript's text item delimiters to linefeed 36 | set filePaths to {} 37 | repeat with aPath in paragraphs of (the clipboard as string) 38 | if aPath starts with "file://" then 39 | set end of filePaths to (POSIX path of (aPath as alias)) 40 | end if 41 | end repeat 42 | return filePaths as string 43 | else 44 | return "No file paths in clipboard" 45 | end if 46 | on error errMsg 47 | return "Failed to get clipboard: " & errMsg 48 | end try 49 | end tell 50 | `; 51 | } else { 52 | return ` 53 | tell application "System Events" 54 | try 55 | return (the clipboard as text) 56 | on error errMsg 57 | return "Failed to get clipboard: " & errMsg 58 | end try 59 | end tell 60 | `; 61 | } 62 | }, 63 | }, 64 | { 65 | name: "set_clipboard", 66 | description: "Set clipboard content", 67 | schema: { 68 | type: "object", 69 | properties: { 70 | content: { 71 | type: "string", 72 | description: "Content to copy to clipboard", 73 | }, 74 | }, 75 | required: ["content"], 76 | }, 77 | script: (args) => ` 78 | try 79 | set the clipboard to "${args.content}" 80 | return "Clipboard content set successfully" 81 | on error errMsg 82 | return "Failed to set clipboard: " & errMsg 83 | end try 84 | `, 85 | }, 86 | { 87 | name: "clear_clipboard", 88 | description: "Clear clipboard content", 89 | script: ` 90 | try 91 | set the clipboard to "" 92 | return "Clipboard cleared successfully" 93 | on error errMsg 94 | return "Failed to clear clipboard: " & errMsg 95 | end try 96 | `, 97 | }, 98 | ], 99 | }; 100 | ``` -------------------------------------------------------------------------------- /src/categories/system.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * System-related scripts. 5 | * * volume: Set system volume 6 | * * get_frontmost_app: Get the name of the frontmost application 7 | * * launch_app: Launch an application 8 | * * quit_app: Quit an application 9 | * * toggle_dark_mode: Toggle system dark mode 10 | */ 11 | export const systemCategory: ScriptCategory = { 12 | name: "system", 13 | description: "System control and information", 14 | scripts: [ 15 | { 16 | name: "volume", 17 | description: "Set system volume", 18 | schema: { 19 | type: "object", 20 | properties: { 21 | level: { 22 | type: "number", 23 | minimum: 0, 24 | maximum: 100, 25 | }, 26 | }, 27 | required: ["level"], 28 | }, 29 | script: (args) => `set volume ${Math.round((args.level / 100) * 7)}`, 30 | }, 31 | { 32 | name: "get_frontmost_app", 33 | description: "Get the name of the frontmost application", 34 | script: 35 | 'tell application "System Events" to get name of first process whose frontmost is true', 36 | }, 37 | { 38 | name: "launch_app", 39 | description: "Launch an application", 40 | schema: { 41 | type: "object", 42 | properties: { 43 | name: { 44 | type: "string", 45 | description: "Application name", 46 | }, 47 | }, 48 | required: ["name"], 49 | }, 50 | script: (args) => ` 51 | try 52 | tell application "${args.name}" 53 | activate 54 | end tell 55 | return "Application ${args.name} launched successfully" 56 | on error errMsg 57 | return "Failed to launch application: " & errMsg 58 | end try 59 | `, 60 | }, 61 | { 62 | name: "quit_app", 63 | description: "Quit an application", 64 | schema: { 65 | type: "object", 66 | properties: { 67 | name: { 68 | type: "string", 69 | description: "Application name", 70 | }, 71 | force: { 72 | type: "boolean", 73 | description: "Force quit if true", 74 | default: false, 75 | }, 76 | }, 77 | required: ["name"], 78 | }, 79 | script: (args) => ` 80 | try 81 | tell application "${args.name}" 82 | ${args.force ? "quit saving no" : "quit"} 83 | end tell 84 | return "Application ${args.name} quit successfully" 85 | on error errMsg 86 | return "Failed to quit application: " & errMsg 87 | end try 88 | `, 89 | }, 90 | { 91 | name: "toggle_dark_mode", 92 | description: "Toggle system dark mode", 93 | script: ` 94 | tell application "System Events" 95 | tell appearance preferences 96 | set dark mode to not dark mode 97 | return "Dark mode is now " & (dark mode as text) 98 | end tell 99 | end tell 100 | `, 101 | }, 102 | { 103 | name: "get_battery_status", 104 | description: "Get battery level and charging status", 105 | script: ` 106 | try 107 | set powerSource to do shell script "pmset -g batt" 108 | return powerSource 109 | on error errMsg 110 | return "Failed to get battery status: " & errMsg 111 | end try 112 | `, 113 | }, 114 | ], 115 | }; 116 | ``` -------------------------------------------------------------------------------- /src/categories/finder.ts: -------------------------------------------------------------------------------- ```typescript 1 | // src/categories/finder.ts 2 | import { ScriptCategory } from "../types/index.js"; 3 | 4 | /** 5 | * Finder-related scripts. 6 | * * get_selected_files: Get currently selected files in Finder 7 | * * search_files: Search for files by name 8 | * * quick_look_file: Preview a file using Quick Look 9 | * 10 | */ 11 | export const finderCategory: ScriptCategory = { 12 | name: "finder", 13 | description: "Finder and file operations", 14 | scripts: [ 15 | { 16 | name: "get_selected_files", 17 | description: "Get currently selected files in Finder", 18 | script: ` 19 | tell application "Finder" 20 | try 21 | set selectedItems to selection 22 | if selectedItems is {} then 23 | return "No items selected" 24 | end if 25 | 26 | set itemPaths to "" 27 | repeat with theItem in selectedItems 28 | set itemPaths to itemPaths & (POSIX path of (theItem as alias)) & linefeed 29 | end repeat 30 | 31 | return itemPaths 32 | on error errMsg 33 | return "Failed to get selected files: " & errMsg 34 | end try 35 | end tell 36 | `, 37 | }, 38 | { 39 | name: "search_files", 40 | description: "Search for files by name", 41 | schema: { 42 | type: "object", 43 | properties: { 44 | query: { 45 | type: "string", 46 | description: "Search term", 47 | }, 48 | location: { 49 | type: "string", 50 | description: "Search location (default: home folder)", 51 | default: "~", 52 | }, 53 | }, 54 | required: ["query"], 55 | }, 56 | script: (args) => ` 57 | set searchPath to "/Users/joshrutkowski/Downloads" 58 | tell application "Finder" 59 | try 60 | set theFolder to POSIX file searchPath as alias 61 | set theFiles to every file of folder theFolder whose name contains "${args.query}" 62 | set resultList to "" 63 | repeat with aFile in theFiles 64 | set resultList to resultList & (POSIX path of (aFile as alias)) & return 65 | end repeat 66 | if resultList is "" then 67 | return "No files found matching '${args.query}'" 68 | end if 69 | return resultList 70 | on error errMsg 71 | return "Failed to search files: " & errMsg 72 | end try 73 | end tell 74 | `, 75 | }, 76 | { 77 | name: "quick_look_file", 78 | description: "Preview a file using Quick Look", 79 | schema: { 80 | type: "object", 81 | properties: { 82 | path: { 83 | type: "string", 84 | description: "File path to preview", 85 | }, 86 | }, 87 | required: ["path"], 88 | }, 89 | script: (args) => ` 90 | try 91 | set filePath to POSIX file "${args.path}" 92 | tell application "Finder" 93 | activate 94 | select filePath 95 | tell application "System Events" 96 | -- Press Space to trigger Quick Look 97 | delay 0.5 -- Small delay to ensure Finder is ready 98 | key code 49 -- Space key 99 | end tell 100 | end tell 101 | return "Quick Look preview opened for ${args.path}" 102 | on error errMsg 103 | return "Failed to open Quick Look: " & errMsg 104 | end try 105 | `, 106 | }, 107 | ], 108 | }; 109 | ``` -------------------------------------------------------------------------------- /src/categories/calendar.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * Calendar-related scripts. 5 | * * add: adds a new event to Calendar 6 | * * list: List events for today 7 | */ 8 | export const calendarCategory: ScriptCategory = { 9 | name: "calendar", 10 | description: "Calendar operations", 11 | scripts: [ 12 | { 13 | name: "add", 14 | description: "Add a new event to Calendar", 15 | schema: { 16 | type: "object", 17 | properties: { 18 | title: { 19 | type: "string", 20 | description: "Event title", 21 | }, 22 | startDate: { 23 | type: "string", 24 | description: "Start date and time (YYYY-MM-DD HH:MM:SS)", 25 | }, 26 | endDate: { 27 | type: "string", 28 | description: "End date and time (YYYY-MM-DD HH:MM:SS)", 29 | }, 30 | calendar: { 31 | type: "string", 32 | description: "Calendar name (optional)", 33 | default: "Calendar", 34 | }, 35 | }, 36 | required: ["title", "startDate", "endDate"], 37 | }, 38 | script: (args) => ` 39 | tell application "Calendar" 40 | set theStartDate to current date 41 | set hours of theStartDate to ${args.startDate.slice(11, 13)} 42 | set minutes of theStartDate to ${args.startDate.slice(14, 16)} 43 | set seconds of theStartDate to ${args.startDate.slice(17, 19)} 44 | 45 | set theEndDate to theStartDate + (1 * hours) 46 | set hours of theEndDate to ${args.endDate.slice(11, 13)} 47 | set minutes of theEndDate to ${args.endDate.slice(14, 16)} 48 | set seconds of theEndDate to ${args.endDate.slice(17, 19)} 49 | 50 | tell calendar "${args.calendar || "Calendar"}" 51 | make new event with properties {summary:"${args.title}", start date:theStartDate, end date:theEndDate} 52 | end tell 53 | end tell 54 | `, 55 | }, 56 | { 57 | name: "list", 58 | description: "List all events for today", 59 | script: ` 60 | tell application "Calendar" 61 | set todayStart to (current date) 62 | set time of todayStart to 0 63 | set todayEnd to todayStart + 1 * days 64 | set eventList to {} 65 | repeat with calendarAccount in calendars 66 | set eventList to eventList & (every event of calendarAccount whose start date is greater than or equal to todayStart and start date is less than todayEnd) 67 | end repeat 68 | set output to "" 69 | repeat with anEvent in eventList 70 | set eventStartDate to start date of anEvent 71 | set eventEndDate to end date of anEvent 72 | 73 | -- Format the time parts 74 | set startHours to hours of eventStartDate 75 | set startMinutes to minutes of eventStartDate 76 | set endHours to hours of eventEndDate 77 | set endMinutes to minutes of eventEndDate 78 | 79 | set output to output & "Event: " & summary of anEvent & "\n" 80 | set output to output & "Start: " & startHours & ":" & text -2 thru -1 of ("0" & startMinutes) & "\n" 81 | set output to output & "End: " & endHours & ":" & text -2 thru -1 of ("0" & endMinutes) & "\n" 82 | set output to output & "-------------------\n" 83 | end repeat 84 | return output 85 | end tell 86 | `, 87 | }, 88 | ], 89 | }; 90 | ``` -------------------------------------------------------------------------------- /src/categories/messages.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * iMessage related scripts 5 | */ 6 | export const messagesCategory: ScriptCategory = { 7 | name: "messages", 8 | description: "iMessage operations", 9 | scripts: [ 10 | { 11 | name: "list_chats", 12 | description: "List available iMessage and SMS chats", 13 | schema: { 14 | type: "object", 15 | properties: { 16 | includeParticipantDetails: { 17 | type: "boolean", 18 | description: "Include detailed participant information", 19 | default: false 20 | } 21 | } 22 | }, 23 | script: (args) => ` 24 | tell application "Messages" 25 | set chatList to {} 26 | repeat with aChat in chats 27 | set chatName to name of aChat 28 | if chatName is missing value then 29 | set chatName to "" 30 | -- Try to get the contact name for individual chats 31 | try 32 | set theParticipants to participants of aChat 33 | if (count of theParticipants) is 1 then 34 | set theParticipant to item 1 of theParticipants 35 | set chatName to name of theParticipant 36 | end if 37 | end try 38 | end if 39 | 40 | set chatInfo to {id:id of aChat, name:chatName, isGroupChat:(id of aChat contains "+")} 41 | 42 | ${args.includeParticipantDetails ? ` 43 | -- Add participant details if requested 44 | set participantList to {} 45 | repeat with aParticipant in participants of aChat 46 | set participantInfo to {id:id of aParticipant, handle:handle of aParticipant} 47 | try 48 | set participantInfo to participantInfo & {name:name of aParticipant} 49 | end try 50 | copy participantInfo to end of participantList 51 | end repeat 52 | set chatInfo to chatInfo & {participant:participantList} 53 | ` : ''} 54 | 55 | copy chatInfo to end of chatList 56 | end repeat 57 | return chatList 58 | end tell 59 | ` 60 | }, 61 | { 62 | name: "get_messages", 63 | description: "Get messages from the Messages app", 64 | schema: { 65 | type: "object", 66 | properties: { 67 | limit: { 68 | type: "number", 69 | description: "Maximum number of messages to retrieve", 70 | default: 100 71 | } 72 | } 73 | }, 74 | script: (args) => ` 75 | on run 76 | -- Path to the Messages database 77 | set dbPath to (do shell script "echo ~/Library/Messages/chat.db") 78 | 79 | -- Create a temporary SQL file for our query 80 | set tempFile to (do shell script "mktemp /tmp/imessage_query.XXXXXX") 81 | 82 | -- Write SQL query to temp file 83 | do shell script "cat > " & quoted form of tempFile & " << 'EOF' 84 | SELECT 85 | datetime(message.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as message_date, 86 | handle.id as sender, 87 | message.text as message_text, 88 | chat.display_name as chat_name 89 | FROM 90 | message 91 | LEFT JOIN handle ON message.handle_id = handle.ROWID 92 | LEFT JOIN chat_message_join ON message.ROWID = chat_message_join.message_id 93 | LEFT JOIN chat ON chat_message_join.chat_id = chat.ROWID 94 | ORDER BY 95 | message.date DESC 96 | LIMIT ${args.limit}; 97 | EOF" 98 | 99 | -- Execute the query 100 | set queryResult to do shell script "sqlite3 " & quoted form of dbPath & " < " & quoted form of tempFile 101 | 102 | -- Clean up temp file 103 | do shell script "rm " & quoted form of tempFile 104 | 105 | -- Process and display results 106 | set resultList to paragraphs of queryResult 107 | set messageData to {} 108 | 109 | repeat with messageLine in resultList 110 | set messageData to messageData & messageLine 111 | end repeat 112 | 113 | return messageData 114 | end run 115 | ` 116 | }, 117 | { 118 | name: "search_messages", 119 | description: "Search for messages containing specific text or from a specific sender", 120 | schema: { 121 | type: "object", 122 | properties: { 123 | searchText: { 124 | type: "string", 125 | description: "Text to search for in messages", 126 | default: "" 127 | }, 128 | sender: { 129 | type: "string", 130 | description: "Search for messages from a specific sender (phone number or email)", 131 | default: "" 132 | }, 133 | chatId: { 134 | type: "string", 135 | description: "Limit search to a specific chat ID", 136 | default: "" 137 | }, 138 | limit: { 139 | type: "number", 140 | description: "Maximum number of messages to retrieve", 141 | default: 50 142 | }, 143 | daysBack: { 144 | type: "number", 145 | description: "Limit search to messages from the last N days", 146 | default: 30 147 | } 148 | }, 149 | required: ["searchText"] 150 | }, 151 | script: (args) => ` 152 | on run 153 | -- Path to the Messages database 154 | set dbPath to (do shell script "echo ~/Library/Messages/chat.db") 155 | 156 | -- Create a temporary SQL file for our query 157 | set tempFile to (do shell script "mktemp /tmp/imessage_search.XXXXXX") 158 | 159 | -- Build WHERE clause based on provided parameters 160 | set whereClause to "" 161 | 162 | ${args.searchText ? ` 163 | -- Add search text condition if provided 164 | set whereClause to whereClause & "message.text LIKE '%${args.searchText.replace(/'/g, "''")}%' " 165 | ` : ''} 166 | 167 | ${args.sender ? ` 168 | -- Add sender condition if provided 169 | if length of whereClause > 0 then 170 | set whereClause to whereClause & "AND " 171 | end if 172 | set whereClause to whereClause & "handle.id LIKE '%${args.sender.replace(/'/g, "''")}%' " 173 | ` : ''} 174 | 175 | ${args.chatId ? ` 176 | -- Add chat ID condition if provided 177 | if length of whereClause > 0 then 178 | set whereClause to whereClause & "AND " 179 | end if 180 | set whereClause to whereClause & "chat.chat_identifier = '${args.chatId.replace(/'/g, "''")}' " 181 | ` : ''} 182 | 183 | ${args.daysBack ? ` 184 | -- Add date range condition 185 | if length of whereClause > 0 then 186 | set whereClause to whereClause & "AND " 187 | end if 188 | set whereClause to whereClause & "message.date > (strftime('%s', 'now', '-${args.daysBack} days') - strftime('%s', '2001-01-01')) * 1000000000 " 189 | ` : ''} 190 | 191 | -- If no search parameters were provided, add a default condition to avoid returning all messages 192 | if length of whereClause = 0 then 193 | set whereClause to "1=1 " 194 | end if 195 | 196 | -- Write SQL query to temp file 197 | do shell script "cat > " & quoted form of tempFile & " << 'EOF' 198 | SELECT 199 | datetime(message.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as message_date, 200 | handle.id as sender, 201 | message.text as message_text, 202 | chat.display_name as chat_name, 203 | chat.chat_identifier as chat_id 204 | FROM 205 | message 206 | LEFT JOIN handle ON message.handle_id = handle.ROWID 207 | LEFT JOIN chat_message_join ON message.ROWID = chat_message_join.message_id 208 | LEFT JOIN chat ON chat_message_join.chat_id = chat.ROWID 209 | WHERE 210 | " & whereClause & " 211 | ORDER BY 212 | message.date DESC 213 | LIMIT ${args.limit}; 214 | EOF" 215 | 216 | -- Execute the query 217 | set queryResult to do shell script "sqlite3 " & quoted form of dbPath & " < " & quoted form of tempFile 218 | 219 | -- Clean up temp file 220 | do shell script "rm " & quoted form of tempFile 221 | 222 | -- Process and display results 223 | set resultList to paragraphs of queryResult 224 | set messageData to {} 225 | 226 | repeat with messageLine in resultList 227 | set messageData to messageData & messageLine 228 | end repeat 229 | 230 | return messageData 231 | end run 232 | ` 233 | }, 234 | { 235 | name: "compose_message", 236 | description: "Open Messages app with a pre-filled message to a recipient or automatically send a message", 237 | schema: { 238 | type: "object", 239 | properties: { 240 | recipient: { 241 | type: "string", 242 | description: "Phone number or email of the recipient" 243 | }, 244 | body: { 245 | type: "string", 246 | description: "Message body text", 247 | default: "" 248 | }, 249 | auto: { 250 | type: "boolean", 251 | description: "Automatically send the message without user confirmation", 252 | default: false 253 | } 254 | }, 255 | required: ["recipient"] 256 | }, 257 | script: (args) => ` 258 | on run 259 | -- Get the recipient and message body 260 | set recipient to "${args.recipient}" 261 | set messageBody to "${args.body || ''}" 262 | set autoSend to ${args.auto === true ? "true" : "false"} 263 | 264 | if autoSend then 265 | -- Automatically send the message using AppleScript 266 | tell application "Messages" 267 | -- Get the service (iMessage or SMS) 268 | set targetService to 1st service whose service type = iMessage 269 | 270 | -- Send the message 271 | set targetBuddy to buddy "${args.recipient}" of targetService 272 | send "${args.body || ''}" to targetBuddy 273 | 274 | return "Message sent to " & "${args.recipient}" 275 | end tell 276 | else 277 | -- Just open Messages app with pre-filled content 278 | -- Create the SMS URL with proper URL encoding 279 | set smsURL to "sms:" & recipient 280 | 281 | if messageBody is not equal to "" then 282 | -- Use percent encoding for spaces instead of plus signs 283 | set encodedBody to "" 284 | repeat with i from 1 to count of characters of messageBody 285 | set c to character i of messageBody 286 | if c is space then 287 | set encodedBody to encodedBody & "%20" 288 | else 289 | set encodedBody to encodedBody & c 290 | end if 291 | end repeat 292 | 293 | set smsURL to smsURL & "&body=" & encodedBody 294 | end if 295 | 296 | -- Open the URL with the default handler (Messages app) 297 | do shell script "open " & quoted form of smsURL 298 | 299 | return "Opening Messages app with recipient: " & recipient 300 | end if 301 | end run 302 | ` 303 | } 304 | ] 305 | }; 306 | ``` -------------------------------------------------------------------------------- /src/framework.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { 4 | ListToolsRequestSchema, 5 | CallToolRequestSchema, 6 | ErrorCode, 7 | McpError, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import { exec } from "child_process"; 10 | import { promisify } from "util"; 11 | import os from "os"; 12 | import { 13 | ScriptCategory, 14 | ScriptDefinition, 15 | FrameworkOptions, 16 | LogLevel, 17 | } from "./types/index.js"; 18 | 19 | const execAsync = promisify(exec); 20 | 21 | // Get system information for logging 22 | const systemInfo = { 23 | platform: os.platform(), 24 | release: os.release(), 25 | hostname: os.hostname(), 26 | arch: os.arch(), 27 | nodeVersion: process.version 28 | }; 29 | 30 | 31 | 32 | export class AppleScriptFramework { 33 | private server: Server; 34 | private categories: ScriptCategory[] = []; 35 | private _initInfo: Record<string, any> = {}; 36 | private _isConnected: boolean = false; 37 | private _pendingCategories: Array<Record<string, any>> = []; 38 | 39 | /** 40 | * Constructs an instance of AppleScriptFramework. 41 | * @param options - Configuration options for the framework. 42 | */ 43 | constructor(options: FrameworkOptions = {}) { 44 | const serverName = options.name || "applescript-server"; 45 | const serverVersion = options.version || "1.0.0"; 46 | 47 | this.server = new Server( 48 | { 49 | name: serverName, 50 | version: serverVersion, 51 | }, 52 | { 53 | capabilities: { 54 | tools: {}, 55 | logging: {}, // Enable logging capability 56 | }, 57 | }, 58 | ); 59 | 60 | if (options.debug) { 61 | this.enableDebugLogging(); 62 | } 63 | 64 | // Log server initialization with stderr (server isn't connected yet) 65 | console.error(`[INFO] AppleScript MCP server initialized - ${serverName} v${serverVersion}`); 66 | 67 | // Store initialization info for later logging after connection 68 | this._initInfo = { 69 | name: serverName, 70 | version: serverVersion, 71 | debug: !!options.debug, 72 | system: systemInfo 73 | }; 74 | } 75 | 76 | /** 77 | * Enables debug logging for the server. 78 | * Sets up error handlers and configures detailed logging. 79 | */ 80 | private enableDebugLogging(): void { 81 | console.error("[INFO] Debug logging enabled"); 82 | 83 | this.server.onerror = (error) => { 84 | const errorMessage = error instanceof Error ? error.message : String(error); 85 | console.error("[MCP Error]", error); 86 | 87 | // Only use MCP logging if connected 88 | if (this._isConnected) { 89 | this.log("error", "MCP server error", { error: errorMessage }); 90 | } 91 | }; 92 | 93 | // Set up additional debug event handlers if needed 94 | this.server.oninitialized = () => { 95 | this._isConnected = true; 96 | console.error("[DEBUG] Connection initialized"); 97 | 98 | // We'll log initialization info in the run method after connection is fully established 99 | console.error("[DEBUG] Connection initialized"); 100 | }; 101 | 102 | this.server.onclose = () => { 103 | this._isConnected = false; 104 | console.error("[DEBUG] Connection closed"); 105 | 106 | // No MCP logging here since we're disconnected 107 | }; 108 | } 109 | 110 | /** 111 | * Adds a new script category to the framework. 112 | * @param category - The script category to add. 113 | */ 114 | addCategory(category: ScriptCategory): void { 115 | this.categories.push(category); 116 | 117 | // Use console logging since this is called before server connection 118 | console.error(`[DEBUG] Added category: ${category.name} (${category.scripts.length} scripts)`); 119 | 120 | // Store category info to log via MCP after connection 121 | if (!this._pendingCategories) { 122 | this._pendingCategories = []; 123 | } 124 | this._pendingCategories.push({ 125 | categoryName: category.name, 126 | scriptCount: category.scripts.length, 127 | description: category.description 128 | }); 129 | } 130 | 131 | /** 132 | * Logs a message with the specified severity level. 133 | * Uses the MCP server's logging system to record events if available. 134 | * Always logs to console for visibility. 135 | * 136 | * @param level - The severity level of the log message following RFC 5424 syslog levels 137 | * @param message - The message to log 138 | * @param data - Optional additional data to include with the log message 139 | * 140 | * @example 141 | * // Log a debug message 142 | * framework.log("debug", "Processing request", { requestId: "123" }); 143 | * 144 | * @example 145 | * // Log an error 146 | * framework.log("error", "Failed to execute script", { scriptName: "calendar_add" }); 147 | */ 148 | log(level: LogLevel, message: string, data?: Record<string, any>): void { 149 | // Format for console output 150 | const timestamp = new Date().toISOString(); 151 | const dataStr = data ? ` ${JSON.stringify(data)}` : ''; 152 | 153 | // Always log to stderr for visibility 154 | console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}${dataStr}`); 155 | 156 | // Only try to use MCP logging if we're connected 157 | if (this._isConnected) { 158 | try { 159 | this.server.sendLoggingMessage({ 160 | level: level, 161 | message: message, 162 | data: data || {}, 163 | }); 164 | } catch (error) { 165 | // Silently ignore logging errors - we've already logged to console 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Executes an AppleScript and returns the result. 172 | * @param script - The AppleScript to execute. 173 | * @returns The result of the script execution. 174 | * @throws Will throw an error if the script execution fails. 175 | */ 176 | private async executeScript(script: string): Promise<string> { 177 | // Log script execution (truncate long scripts for readability) 178 | const scriptPreview = script.length > 100 ? script.substring(0, 100) + "..." : script; 179 | this.log("debug", "Executing AppleScript", { scriptPreview }); 180 | 181 | try { 182 | const startTime = Date.now(); 183 | const { stdout } = await execAsync( 184 | `osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, 185 | ); 186 | const executionTime = Date.now() - startTime; 187 | 188 | this.log("debug", "AppleScript executed successfully", { 189 | executionTimeMs: executionTime, 190 | outputLength: stdout.length 191 | }); 192 | 193 | return stdout.trim(); 194 | } catch (error) { 195 | // Properly type check the error object 196 | let errorMessage = "Unknown error occurred"; 197 | if (error && typeof error === "object") { 198 | if ("message" in error && typeof error.message === "string") { 199 | errorMessage = error.message; 200 | } else if (error instanceof Error) { 201 | errorMessage = error.message; 202 | } 203 | } else if (typeof error === "string") { 204 | errorMessage = error; 205 | } 206 | 207 | this.log("error", "AppleScript execution failed", { 208 | error: errorMessage, 209 | scriptPreview 210 | }); 211 | 212 | throw new Error(`AppleScript execution failed: ${errorMessage}`); 213 | } 214 | } 215 | 216 | /** 217 | * Sets up request handlers for the server. 218 | */ 219 | private setupHandlers(): void { 220 | // List available tools 221 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 222 | tools: this.categories.flatMap((category) => 223 | category.scripts.map((script) => ({ 224 | name: `${category.name}_${script.name}`, // Changed from dot to underscore 225 | description: `[${category.description}] ${script.description}`, 226 | inputSchema: script.schema || { 227 | type: "object", 228 | properties: {}, 229 | }, 230 | })), 231 | ), 232 | })); 233 | 234 | // Handle tool execution 235 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 236 | const toolName = request.params.name; 237 | this.log("info", "Tool execution requested", { 238 | tool: toolName, 239 | hasArguments: !!request.params.arguments 240 | }); 241 | 242 | try { 243 | // Split on underscore instead of dot 244 | const [categoryName, ...scriptNameParts] = 245 | toolName.split("_"); 246 | const scriptName = scriptNameParts.join("_"); // Rejoin in case script name has underscores 247 | 248 | const category = this.categories.find((c) => c.name === categoryName); 249 | if (!category) { 250 | this.log("warning", "Category not found", { categoryName }); 251 | throw new McpError( 252 | ErrorCode.MethodNotFound, 253 | `Category not found: ${categoryName}`, 254 | ); 255 | } 256 | 257 | const script = category.scripts.find((s) => s.name === scriptName); 258 | if (!script) { 259 | this.log("warning", "Script not found", { 260 | categoryName, 261 | scriptName 262 | }); 263 | throw new McpError( 264 | ErrorCode.MethodNotFound, 265 | `Script not found: ${scriptName}`, 266 | ); 267 | } 268 | 269 | this.log("debug", "Generating script content", { 270 | categoryName, 271 | scriptName, 272 | isFunction: typeof script.script === "function" 273 | }); 274 | 275 | const scriptContent = 276 | typeof script.script === "function" 277 | ? script.script(request.params.arguments) 278 | : script.script; 279 | 280 | const result = await this.executeScript(scriptContent); 281 | 282 | this.log("info", "Tool execution completed successfully", { 283 | tool: toolName, 284 | resultLength: result.length 285 | }); 286 | 287 | return { 288 | content: [ 289 | { 290 | type: "text", 291 | text: result, 292 | }, 293 | ], 294 | }; 295 | } catch (error) { 296 | if (error instanceof McpError) { 297 | this.log("error", "MCP error during tool execution", { 298 | tool: toolName, 299 | errorCode: error.code, 300 | errorMessage: error.message 301 | }); 302 | throw error; 303 | } 304 | 305 | let errorMessage = "Unknown error occurred"; 306 | if (error && typeof error === "object") { 307 | if ("message" in error && typeof error.message === "string") { 308 | errorMessage = error.message; 309 | } else if (error instanceof Error) { 310 | errorMessage = error.message; 311 | } 312 | } else if (typeof error === "string") { 313 | errorMessage = error; 314 | } 315 | 316 | this.log("error", "Error during tool execution", { 317 | tool: toolName, 318 | errorMessage 319 | }); 320 | 321 | return { 322 | content: [ 323 | { 324 | type: "text", 325 | text: `Error: ${errorMessage}`, 326 | }, 327 | ], 328 | isError: true, 329 | }; 330 | } 331 | }); 332 | } 333 | 334 | /** 335 | * Runs the AppleScript framework server. 336 | */ 337 | async run(): Promise<void> { 338 | console.error("[INFO] Setting up request handlers"); 339 | this.setupHandlers(); 340 | 341 | console.error("[INFO] Initializing StdioServerTransport"); 342 | const transport = new StdioServerTransport(); 343 | 344 | try { 345 | console.error("[INFO] Connecting server to transport"); 346 | await this.server.connect(transport); 347 | this._isConnected = true; 348 | 349 | // Log server running status using console only 350 | const totalScripts = this.categories.reduce((count, category) => count + category.scripts.length, 0); 351 | console.error(`[NOTICE] AppleScript MCP server running with ${this.categories.length} categories and ${totalScripts} scripts`); 352 | 353 | console.error("AppleScript MCP server running"); 354 | } catch (error) { 355 | let errorMessage = "Unknown error occurred"; 356 | if (error && typeof error === "object" && error instanceof Error) { 357 | errorMessage = error.message; 358 | } 359 | 360 | console.error("Failed to start AppleScript MCP server:", errorMessage); 361 | throw error; 362 | } 363 | } 364 | } 365 | ``` -------------------------------------------------------------------------------- /src/categories/notes.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * Generates HTML content for a note based on user input 5 | * @param args The arguments containing content specifications 6 | * @returns HTML string for the note 7 | */ 8 | function generateNoteHtml(args: any): string { 9 | const { 10 | title = "New Note", 11 | content = "", 12 | format = { 13 | headings: false, 14 | bold: false, 15 | italic: false, 16 | underline: false, 17 | links: false, 18 | lists: false 19 | } 20 | } = args; 21 | 22 | // Process content based on format options 23 | let processedContent = content; 24 | 25 | // If content contains markdown-like syntax and formatting is enabled, convert it 26 | if (format.headings) { 27 | // Convert # Heading to <h1>Heading</h1>, ## Heading to <h2>Heading</h2>, etc. 28 | processedContent = processedContent.replace(/^# (.+)$/gm, '<h1>$1</h1>'); 29 | processedContent = processedContent.replace(/^## (.+)$/gm, '<h2>$1</h2>'); 30 | processedContent = processedContent.replace(/^### (.+)$/gm, '<h3>$1</h3>'); 31 | } 32 | 33 | if (format.bold) { 34 | // Convert **text** or __text__ to <b>text</b> 35 | processedContent = processedContent.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'); 36 | processedContent = processedContent.replace(/__(.+?)__/g, '<b>$1</b>'); 37 | } 38 | 39 | if (format.italic) { 40 | // Convert *text* or _text_ to <i>text</i> 41 | processedContent = processedContent.replace(/\*(.+?)\*/g, '<i>$1</i>'); 42 | processedContent = processedContent.replace(/_(.+?)_/g, '<i>$1</i>'); 43 | } 44 | 45 | if (format.underline) { 46 | // Convert ~text~ to <u>text</u> 47 | processedContent = processedContent.replace(/~(.+?)~/g, '<u>$1</u>'); 48 | } 49 | 50 | if (format.links) { 51 | // Convert [text](url) to <a href="url">text</a> 52 | processedContent = processedContent.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>'); 53 | } 54 | 55 | if (format.lists) { 56 | // Handle unordered lists 57 | // Look for lines starting with - or * and convert to <li> items 58 | const listItems = processedContent.match(/^[*-] (.+)$/gm); 59 | if (listItems) { 60 | let listHtml = '<ul>'; 61 | for (const item of listItems) { 62 | const content = item.replace(/^[*-] /, ''); 63 | listHtml += `<li>${content}</li>`; 64 | } 65 | listHtml += '</ul>'; 66 | 67 | // Replace the original list items with the HTML list 68 | for (const item of listItems) { 69 | processedContent = processedContent.replace(item, ''); 70 | } 71 | processedContent = processedContent.replace(/\n+/g, '\n') + listHtml; 72 | } 73 | 74 | // Handle ordered lists (1. Item) 75 | const orderedItems = processedContent.match(/^\d+\. (.+)$/gm); 76 | if (orderedItems) { 77 | let listHtml = '<ol>'; 78 | for (const item of orderedItems) { 79 | const content = item.replace(/^\d+\. /, ''); 80 | listHtml += `<li>${content}</li>`; 81 | } 82 | listHtml += '</ol>'; 83 | 84 | // Replace the original list items with the HTML list 85 | for (const item of orderedItems) { 86 | processedContent = processedContent.replace(item, ''); 87 | } 88 | processedContent = processedContent.replace(/\n+/g, '\n') + listHtml; 89 | } 90 | } 91 | 92 | // Wrap paragraphs in <p> tags if they aren't already wrapped in HTML tags 93 | const paragraphs = processedContent.split('\n\n'); 94 | processedContent = paragraphs 95 | .map((p: string) => { 96 | if (p.trim() && !p.trim().startsWith('<')) { 97 | return `<p>${p}</p>`; 98 | } 99 | return p; 100 | }) 101 | .join('\n'); 102 | 103 | return processedContent; 104 | } 105 | 106 | export const notesCategory: ScriptCategory = { 107 | name: "notes", 108 | description: "Apple Notes operations", 109 | scripts: [ 110 | { 111 | name: "create", 112 | description: "Create a new note with optional formatting", 113 | script: (args) => { 114 | const { title = "New Note", content = "", format = {} } = args; 115 | const htmlContent = generateNoteHtml(args); 116 | 117 | return ` 118 | tell application "Notes" 119 | make new note with properties {body:"${htmlContent}", name:"${title}"} 120 | end tell 121 | `; 122 | }, 123 | schema: { 124 | type: "object", 125 | properties: { 126 | title: { 127 | type: "string", 128 | description: "Title of the note" 129 | }, 130 | content: { 131 | type: "string", 132 | description: "Content of the note, can include markdown-like syntax for formatting" 133 | }, 134 | format: { 135 | type: "object", 136 | description: "Formatting options for the note content", 137 | properties: { 138 | headings: { 139 | type: "boolean", 140 | description: "Enable heading formatting (# Heading)" 141 | }, 142 | bold: { 143 | type: "boolean", 144 | description: "Enable bold formatting (**text**)" 145 | }, 146 | italic: { 147 | type: "boolean", 148 | description: "Enable italic formatting (*text*)" 149 | }, 150 | underline: { 151 | type: "boolean", 152 | description: "Enable underline formatting (~text~)" 153 | }, 154 | links: { 155 | type: "boolean", 156 | description: "Enable link formatting ([text](url))" 157 | }, 158 | lists: { 159 | type: "boolean", 160 | description: "Enable list formatting (- item or 1. item)" 161 | } 162 | } 163 | } 164 | }, 165 | required: ["title", "content"] 166 | } 167 | }, 168 | { 169 | name: "createRawHtml", 170 | description: "Create a new note with direct HTML content", 171 | script: (args) => { 172 | const { title = "New Note", html = "" } = args; 173 | 174 | return ` 175 | tell application "Notes" 176 | make new note with properties {body:"${html.replace(/"/g, '\\"')}", name:"${title}"} 177 | end tell 178 | `; 179 | }, 180 | schema: { 181 | type: "object", 182 | properties: { 183 | title: { 184 | type: "string", 185 | description: "Title of the note" 186 | }, 187 | html: { 188 | type: "string", 189 | description: "Raw HTML content for the note" 190 | } 191 | }, 192 | required: ["title", "html"] 193 | } 194 | }, 195 | { 196 | name: "list", 197 | description: "List all notes or notes in a specific folder", 198 | script: (args) => { 199 | const { folder = "" } = args; 200 | 201 | if (folder) { 202 | return ` 203 | tell application "Notes" 204 | set folderList to folders whose name is "${folder}" 205 | if length of folderList > 0 then 206 | set targetFolder to item 1 of folderList 207 | set noteNames to name of notes of targetFolder 208 | return noteNames as string 209 | else 210 | return "Folder not found: ${folder}" 211 | end if 212 | end tell 213 | `; 214 | } else { 215 | return ` 216 | tell application "Notes" 217 | set noteNames to name of notes 218 | return noteNames as string 219 | end tell 220 | `; 221 | } 222 | }, 223 | schema: { 224 | type: "object", 225 | properties: { 226 | folder: { 227 | type: "string", 228 | description: "Optional folder name to list notes from" 229 | } 230 | } 231 | } 232 | }, 233 | { 234 | name: "get", 235 | description: "Get a specific note by title", 236 | script: (args) => { 237 | const { title, folder = "" } = args; 238 | 239 | if (folder) { 240 | return ` 241 | tell application "Notes" 242 | set folderList to folders whose name is "${folder}" 243 | if length of folderList > 0 then 244 | set targetFolder to item 1 of folderList 245 | set matchingNotes to notes of targetFolder whose name is "${title}" 246 | if length of matchingNotes > 0 then 247 | set n to item 1 of matchingNotes 248 | set noteTitle to name of n 249 | set noteBody to body of n 250 | set noteCreationDate to creation date of n 251 | set noteModDate to modification date of n 252 | 253 | set jsonResult to "{\\"title\\": \\"" 254 | set jsonResult to jsonResult & noteTitle & "\\"" 255 | set jsonResult to jsonResult & ", \\"body\\": \\"" & noteBody & "\\"" 256 | set jsonResult to jsonResult & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" 257 | set jsonResult to jsonResult & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" 258 | 259 | return jsonResult 260 | else 261 | return "Note not found: ${title}" 262 | end if 263 | else 264 | return "Folder not found: ${folder}" 265 | end if 266 | end tell 267 | `; 268 | } else { 269 | return ` 270 | tell application "Notes" 271 | set matchingNotes to notes whose name is "${title}" 272 | if length of matchingNotes > 0 then 273 | set n to item 1 of matchingNotes 274 | set noteTitle to name of n 275 | set noteBody to body of n 276 | set noteCreationDate to creation date of n 277 | set noteModDate to modification date of n 278 | 279 | set jsonResult to "{\\"title\\": \\"" 280 | set jsonResult to jsonResult & noteTitle & "\\"" 281 | set jsonResult to jsonResult & ", \\"body\\": \\"" & noteBody & "\\"" 282 | set jsonResult to jsonResult & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" 283 | set jsonResult to jsonResult & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" 284 | 285 | return jsonResult 286 | else 287 | return "Note not found: ${title}" 288 | end if 289 | end tell 290 | `; 291 | } 292 | }, 293 | schema: { 294 | type: "object", 295 | properties: { 296 | title: { 297 | type: "string", 298 | description: "Title of the note to retrieve" 299 | }, 300 | folder: { 301 | type: "string", 302 | description: "Optional folder name to search in" 303 | } 304 | }, 305 | required: ["title"] 306 | } 307 | }, 308 | { 309 | name: "search", 310 | description: "Search for notes containing specific text", 311 | script: (args) => { 312 | const { query, folder = "", limit = 5, includeBody = true } = args; 313 | 314 | if (folder) { 315 | return ` 316 | tell application "Notes" 317 | set folderList to folders whose name is "${folder}" 318 | if length of folderList > 0 then 319 | set targetFolder to item 1 of folderList 320 | set matchingNotes to {} 321 | set allNotes to notes of targetFolder 322 | repeat with n in allNotes 323 | if name of n contains "${query}" or body of n contains "${query}" then 324 | set end of matchingNotes to n 325 | end if 326 | end repeat 327 | 328 | set resultCount to length of matchingNotes 329 | if resultCount > ${limit} then set resultCount to ${limit} 330 | 331 | set jsonResult to "[" 332 | repeat with i from 1 to resultCount 333 | set n to item i of matchingNotes 334 | set noteTitle to name of n 335 | set noteCreationDate to creation date of n 336 | set noteModDate to modification date of n 337 | ${includeBody ? 'set noteBody to body of n' : ''} 338 | 339 | set noteJson to "{\\"title\\": \\"" 340 | set noteJson to noteJson & noteTitle & "\\"" 341 | ${includeBody ? 'set noteJson to noteJson & ", \\"body\\": \\"" & noteBody & "\\""' : ''} 342 | set noteJson to noteJson & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" 343 | set noteJson to noteJson & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" 344 | 345 | set jsonResult to jsonResult & noteJson 346 | if i < resultCount then set jsonResult to jsonResult & ", " 347 | end repeat 348 | set jsonResult to jsonResult & "]" 349 | 350 | return jsonResult 351 | else 352 | return "Folder not found: ${folder}" 353 | end if 354 | end tell 355 | `; 356 | } else { 357 | return ` 358 | tell application "Notes" 359 | set matchingNotes to {} 360 | set allNotes to notes 361 | repeat with n in allNotes 362 | if name of n contains "${query}" or body of n contains "${query}" then 363 | set end of matchingNotes to n 364 | end if 365 | end repeat 366 | 367 | set resultCount to length of matchingNotes 368 | if resultCount > ${limit} then set resultCount to ${limit} 369 | 370 | set jsonResult to "[" 371 | repeat with i from 1 to resultCount 372 | set n to item i of matchingNotes 373 | set noteTitle to name of n 374 | set noteCreationDate to creation date of n 375 | set noteModDate to modification date of n 376 | ${includeBody ? 'set noteBody to body of n' : ''} 377 | 378 | set noteJson to "{\\"title\\": \\"" 379 | set noteJson to noteJson & noteTitle & "\\"" 380 | ${includeBody ? 'set noteJson to noteJson & ", \\"body\\": \\"" & noteBody & "\\""' : ''} 381 | set noteJson to noteJson & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" 382 | set noteJson to noteJson & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" 383 | 384 | set jsonResult to jsonResult & noteJson 385 | if i < resultCount then set jsonResult to jsonResult & ", " 386 | end repeat 387 | set jsonResult to jsonResult & "]" 388 | 389 | return jsonResult 390 | end tell 391 | `; 392 | } 393 | }, 394 | schema: { 395 | type: "object", 396 | properties: { 397 | query: { 398 | type: "string", 399 | description: "Text to search for in notes (title and body)" 400 | }, 401 | folder: { 402 | type: "string", 403 | description: "Optional folder name to search in" 404 | }, 405 | limit: { 406 | type: "number", 407 | description: "Maximum number of results to return (default: 5)" 408 | }, 409 | includeBody: { 410 | type: "boolean", 411 | description: "Whether to include note body in results (default: true)" 412 | } 413 | }, 414 | required: ["query"] 415 | } 416 | } 417 | ] 418 | }; ``` -------------------------------------------------------------------------------- /src/categories/mail.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ScriptCategory } from "../types/index.js"; 2 | 3 | /** 4 | * Mail-related scripts. 5 | * * create_email: Create a new email in Mail.app with specified recipient, subject, and body 6 | * * list_emails: List emails from a specified mailbox in Mail.app 7 | * * get_email: Get a specific email by ID or search criteria from Mail.app 8 | */ 9 | export const mailCategory: ScriptCategory = { 10 | name: "mail", 11 | description: "Mail operations", 12 | scripts: [ 13 | { 14 | name: "create_email", 15 | description: "Create a new email in Mail.app", 16 | schema: { 17 | type: "object", 18 | properties: { 19 | recipient: { 20 | type: "string", 21 | description: "Email recipient", 22 | }, 23 | subject: { 24 | type: "string", 25 | description: "Email subject", 26 | }, 27 | body: { 28 | type: "string", 29 | description: "Email body", 30 | }, 31 | }, 32 | required: ["recipient", "subject", "body"], 33 | }, 34 | script: (args) => ` 35 | set recipient to "${args.recipient}" 36 | set subject to "${args.subject}" 37 | set body to "${args.body}" 38 | 39 | -- URL encode subject and body 40 | set encodedSubject to my urlEncode(subject) 41 | set encodedBody to my urlEncode(body) 42 | 43 | -- Construct the mailto URL 44 | set mailtoURL to "mailto:" & recipient & "?subject=" & encodedSubject & "&body=" & encodedBody 45 | 46 | -- Use Apple Mail's 'mailto' command to create the email 47 | tell application "Mail" 48 | mailto mailtoURL 49 | activate 50 | end tell 51 | 52 | -- Handler to URL-encode text 53 | on urlEncode(theText) 54 | set theEncodedText to "" 55 | set theChars to every character of theText 56 | repeat with aChar in theChars 57 | set charCode to ASCII number aChar 58 | if charCode = 32 then 59 | set theEncodedText to theEncodedText & "%20" -- Space 60 | else if (charCode ≥ 48 and charCode ≤ 57) or (charCode ≥ 65 and charCode ≤ 90) or (charCode ≥ 97 and charCode ≤ 122) or charCode = 45 or charCode = 46 or charCode = 95 or charCode = 126 then 61 | -- Allowed characters: A-Z, a-z, 0-9, -, ., _, ~ 62 | set theEncodedText to theEncodedText & aChar 63 | else 64 | -- Convert to %HH format 65 | set hexCode to do shell script "printf '%02X' " & charCode 66 | set theEncodedText to theEncodedText & "%" & hexCode 67 | end if 68 | end repeat 69 | return theEncodedText 70 | end urlEncode 71 | `, 72 | }, 73 | { 74 | name: "list_emails", 75 | description: "List emails from a specified mailbox in Mail.app", 76 | schema: { 77 | type: "object", 78 | properties: { 79 | mailbox: { 80 | type: "string", 81 | description: "Name of the mailbox to list emails from (e.g., 'Inbox', 'Sent')", 82 | default: "Inbox" 83 | }, 84 | account: { 85 | type: "string", 86 | description: "Name of the account to search in (e.g., 'iCloud', 'Gmail', 'Exchange'). If not specified, searches all accounts with preference for iCloud.", 87 | default: "iCloud" 88 | }, 89 | count: { 90 | type: "number", 91 | description: "Maximum number of emails to retrieve", 92 | default: 10 93 | }, 94 | unreadOnly: { 95 | type: "boolean", 96 | description: "Only show unread emails if true" 97 | } 98 | } 99 | }, 100 | script: (args) => ` 101 | set mailboxName to "${args.mailbox || 'Inbox'}" 102 | set accountName to "${args.account || 'iCloud'}" 103 | set messageCount to ${args.count || 10} 104 | set showUnreadOnly to ${args.unreadOnly ? 'true' : 'false'} 105 | set searchAllAccounts to ${!args.account ? 'true' : 'false'} 106 | 107 | tell application "Mail" 108 | -- Get all messages if no specific mailbox is found 109 | set foundMailbox to false 110 | set emailMessages to {} 111 | set targetAccount to missing value 112 | 113 | -- First try to find the specified account 114 | if not searchAllAccounts then 115 | try 116 | set allAccounts to every account 117 | repeat with acct in allAccounts 118 | if name of acct is accountName then 119 | set targetAccount to acct 120 | exit repeat 121 | end if 122 | end repeat 123 | end try 124 | 125 | -- If account not found, set to search all accounts 126 | if targetAccount is missing value then 127 | set searchAllAccounts to true 128 | end if 129 | end if 130 | 131 | -- If specific account is found, search in that account 132 | if not searchAllAccounts and targetAccount is not missing value then 133 | try 134 | set acctMailboxes to every mailbox of targetAccount 135 | repeat with m in acctMailboxes 136 | if name of m is mailboxName then 137 | set targetMailbox to m 138 | set foundMailbox to true 139 | 140 | -- Get messages from the found mailbox 141 | if showUnreadOnly then 142 | set emailMessages to (messages of targetMailbox whose read status is false) 143 | else 144 | set emailMessages to (messages of targetMailbox) 145 | end if 146 | 147 | exit repeat 148 | end if 149 | end repeat 150 | 151 | -- If mailbox not found in specified account, try to get inbox 152 | if not foundMailbox then 153 | try 154 | set inboxMailbox to inbox of targetAccount 155 | set targetMailbox to inboxMailbox 156 | set foundMailbox to true 157 | 158 | if showUnreadOnly then 159 | set emailMessages to (messages of targetMailbox whose read status is false) 160 | else 161 | set emailMessages to (messages of targetMailbox) 162 | end if 163 | end try 164 | end if 165 | end try 166 | else 167 | -- Search all accounts, with preference for iCloud 168 | set iCloudAccount to missing value 169 | set allAccounts to every account 170 | 171 | -- First look for iCloud account 172 | repeat with acct in allAccounts 173 | if name of acct is "iCloud" then 174 | set iCloudAccount to acct 175 | exit repeat 176 | end if 177 | end repeat 178 | 179 | -- Try to find the mailbox directly 180 | try 181 | set allMailboxes to every mailbox 182 | repeat with m in allMailboxes 183 | if name of m is mailboxName then 184 | set targetMailbox to m 185 | set foundMailbox to true 186 | 187 | -- Get messages from the found mailbox 188 | if showUnreadOnly then 189 | set emailMessages to (messages of targetMailbox whose read status is false) 190 | else 191 | set emailMessages to (messages of targetMailbox) 192 | end if 193 | 194 | exit repeat 195 | end if 196 | end repeat 197 | end try 198 | 199 | -- If not found directly, try to find it in each account (prioritize iCloud) 200 | if not foundMailbox and iCloudAccount is not missing value then 201 | try 202 | set acctMailboxes to every mailbox of iCloudAccount 203 | repeat with m in acctMailboxes 204 | if name of m is mailboxName then 205 | set targetMailbox to m 206 | set foundMailbox to true 207 | 208 | -- Get messages from the found mailbox 209 | if showUnreadOnly then 210 | set emailMessages to (messages of targetMailbox whose read status is false) 211 | else 212 | set emailMessages to (messages of targetMailbox) 213 | end if 214 | 215 | exit repeat 216 | end if 217 | end repeat 218 | end try 219 | end if 220 | 221 | -- If still not found in iCloud, check other accounts 222 | if not foundMailbox then 223 | repeat with acct in allAccounts 224 | if acct is not iCloudAccount then 225 | try 226 | set acctMailboxes to every mailbox of acct 227 | repeat with m in acctMailboxes 228 | if name of m is mailboxName then 229 | set targetMailbox to m 230 | set foundMailbox to true 231 | 232 | -- Get messages from the found mailbox 233 | if showUnreadOnly then 234 | set emailMessages to (messages of targetMailbox whose read status is false) 235 | else 236 | set emailMessages to (messages of targetMailbox) 237 | end if 238 | 239 | exit repeat 240 | end if 241 | end repeat 242 | 243 | if foundMailbox then exit repeat 244 | end try 245 | end if 246 | end repeat 247 | end if 248 | end if 249 | 250 | -- If still not found, get messages from all inboxes 251 | if not foundMailbox then 252 | set emailMessages to {} 253 | set allAccounts to every account 254 | set accountsChecked to 0 255 | 256 | -- First check iCloud if available 257 | repeat with acct in allAccounts 258 | if name of acct is "iCloud" then 259 | try 260 | -- Try to get the inbox for iCloud 261 | set inboxMailbox to inbox of acct 262 | 263 | -- Add messages from this inbox 264 | if showUnreadOnly then 265 | set acctMessages to (messages of inboxMailbox whose read status is false) 266 | else 267 | set acctMessages to (messages of inboxMailbox) 268 | end if 269 | 270 | set emailMessages to emailMessages & acctMessages 271 | set accountsChecked to accountsChecked + 1 272 | end try 273 | exit repeat 274 | end if 275 | end repeat 276 | 277 | -- Then check other accounts if needed 278 | if accountsChecked is 0 then 279 | repeat with acct in allAccounts 280 | try 281 | -- Try to get the inbox for this account 282 | set inboxMailbox to inbox of acct 283 | 284 | -- Add messages from this inbox 285 | if showUnreadOnly then 286 | set acctMessages to (messages of inboxMailbox whose read status is false) 287 | else 288 | set acctMessages to (messages of inboxMailbox) 289 | end if 290 | 291 | set emailMessages to emailMessages & acctMessages 292 | end try 293 | end repeat 294 | end if 295 | 296 | -- Sort combined messages by date (newest first) 297 | set emailMessages to my sortMessagesByDate(emailMessages) 298 | set mailboxName to "All Inboxes" 299 | end if 300 | 301 | -- Limit the number of messages 302 | if (count of emailMessages) > messageCount then 303 | set emailMessages to items 1 thru messageCount of emailMessages 304 | end if 305 | 306 | -- Format the results 307 | set accountInfo to "" 308 | if not searchAllAccounts and targetAccount is not missing value then 309 | set accountInfo to " (" & accountName & ")" 310 | end if 311 | 312 | set emailList to "Recent emails in " & mailboxName & accountInfo & ":" & return & return 313 | 314 | if (count of emailMessages) is 0 then 315 | set emailList to emailList & "No messages found." 316 | else 317 | repeat with theMessage in emailMessages 318 | try 319 | set msgSubject to subject of theMessage 320 | set msgSender to sender of theMessage 321 | set msgDate to date received of theMessage 322 | set msgRead to read status of theMessage 323 | 324 | -- Try to get account name for this message 325 | set msgAccount to "" 326 | try 327 | set msgMailbox to mailbox of theMessage 328 | set msgAcct to account of msgMailbox 329 | set msgAccount to " [" & name of msgAcct & "]" 330 | end try 331 | 332 | set emailList to emailList & "From: " & msgSender & return 333 | set emailList to emailList & "Subject: " & msgSubject & return 334 | set emailList to emailList & "Date: " & msgDate & msgAccount & return 335 | set emailList to emailList & "Read: " & msgRead & return & return 336 | on error errMsg 337 | set emailList to emailList & "Error processing message: " & errMsg & return & return 338 | end try 339 | end repeat 340 | end if 341 | 342 | return emailList 343 | end tell 344 | 345 | -- Helper function to sort messages by date 346 | on sortMessagesByDate(messageList) 347 | tell application "Mail" 348 | set sortedMessages to {} 349 | 350 | -- Simple bubble sort by date received (newest first) 351 | repeat with i from 1 to count of messageList 352 | set currentMsg to item i of messageList 353 | set currentDate to date received of currentMsg 354 | set inserted to false 355 | 356 | if (count of sortedMessages) is 0 then 357 | set sortedMessages to {currentMsg} 358 | else 359 | repeat with j from 1 to count of sortedMessages 360 | set compareMsg to item j of sortedMessages 361 | set compareDate to date received of compareMsg 362 | 363 | if currentDate > compareDate then 364 | if j is 1 then 365 | set sortedMessages to {currentMsg} & sortedMessages 366 | else 367 | set sortedMessages to (items 1 thru (j - 1) of sortedMessages) & currentMsg & (items j thru (count of sortedMessages) of sortedMessages) 368 | end if 369 | set inserted to true 370 | exit repeat 371 | end if 372 | end repeat 373 | 374 | if not inserted then 375 | set sortedMessages to sortedMessages & {currentMsg} 376 | end if 377 | end if 378 | end repeat 379 | 380 | return sortedMessages 381 | end tell 382 | end sortMessagesByDate 383 | `, 384 | }, 385 | { 386 | name: "get_email", 387 | description: "Get a specific email by search criteria from Mail.app", 388 | schema: { 389 | type: "object", 390 | properties: { 391 | mailbox: { 392 | type: "string", 393 | description: "Name of the mailbox to search in (e.g., 'Inbox', 'Sent')", 394 | default: "Inbox" 395 | }, 396 | account: { 397 | type: "string", 398 | description: "Name of the account to search in (e.g., 'iCloud', 'Gmail', 'Exchange'). If not specified, searches all accounts with preference for iCloud.", 399 | default: "iCloud" 400 | }, 401 | subject: { 402 | type: "string", 403 | description: "Subject text to search for (partial match)" 404 | }, 405 | sender: { 406 | type: "string", 407 | description: "Sender email or name to search for (partial match)" 408 | }, 409 | dateReceived: { 410 | type: "string", 411 | description: "Date received to search for (format: YYYY-MM-DD)" 412 | }, 413 | unreadOnly: { 414 | type: "boolean", 415 | description: "Only search unread emails if true" 416 | }, 417 | includeBody: { 418 | type: "boolean", 419 | description: "Include email body in the result if true", 420 | default: false 421 | } 422 | }, 423 | required: [] 424 | }, 425 | script: (args) => ` 426 | set mailboxName to "${args.mailbox || 'Inbox'}" 427 | set accountName to "${args.account || 'iCloud'}" 428 | set searchSubject to "${args.subject || ''}" 429 | set searchSender to "${args.sender || ''}" 430 | set searchDate to "${args.dateReceived || ''}" 431 | set showUnreadOnly to ${args.unreadOnly ? 'true' : 'false'} 432 | set includeBody to ${args.includeBody ? 'true' : 'false'} 433 | set searchAllAccounts to ${!args.account ? 'true' : 'false'} 434 | 435 | tell application "Mail" 436 | -- Get all messages if no specific mailbox is found 437 | set foundMailbox to false 438 | set emailMessages to {} 439 | set targetAccount to missing value 440 | 441 | -- First try to find the specified account 442 | if not searchAllAccounts then 443 | try 444 | set allAccounts to every account 445 | repeat with acct in allAccounts 446 | if name of acct is accountName then 447 | set targetAccount to acct 448 | exit repeat 449 | end if 450 | end repeat 451 | end try 452 | 453 | -- If account not found, set to search all accounts 454 | if targetAccount is missing value then 455 | set searchAllAccounts to true 456 | end if 457 | end if 458 | 459 | -- If specific account is found, search in that account 460 | if not searchAllAccounts and targetAccount is not missing value then 461 | try 462 | set acctMailboxes to every mailbox of targetAccount 463 | repeat with m in acctMailboxes 464 | if name of m is mailboxName then 465 | set targetMailbox to m 466 | set foundMailbox to true 467 | 468 | -- Get messages from the found mailbox 469 | if showUnreadOnly then 470 | set emailMessages to (messages of targetMailbox whose read status is false) 471 | else 472 | set emailMessages to (messages of targetMailbox) 473 | end if 474 | 475 | exit repeat 476 | end if 477 | end repeat 478 | end try 479 | else 480 | -- Search all accounts, with preference for iCloud 481 | set iCloudAccount to missing value 482 | set allAccounts to every account 483 | 484 | -- First look for iCloud account 485 | repeat with acct in allAccounts 486 | if name of acct is "iCloud" then 487 | set iCloudAccount to acct 488 | exit repeat 489 | end if 490 | end repeat 491 | 492 | -- Try to find the mailbox directly 493 | try 494 | set allMailboxes to every mailbox 495 | repeat with m in allMailboxes 496 | if name of m is mailboxName then 497 | set targetMailbox to m 498 | set foundMailbox to true 499 | 500 | -- Get messages from the found mailbox 501 | if showUnreadOnly then 502 | set emailMessages to (messages of targetMailbox whose read status is false) 503 | else 504 | set emailMessages to (messages of targetMailbox) 505 | end if 506 | 507 | exit repeat 508 | end if 509 | end repeat 510 | end try 511 | end if 512 | 513 | -- Filter messages based on search criteria 514 | set filteredMessages to {} 515 | 516 | repeat with theMessage in emailMessages 517 | try 518 | set matchesSubject to true 519 | set matchesSender to true 520 | set matchesDate to true 521 | 522 | -- Check subject if specified 523 | if searchSubject is not "" then 524 | set msgSubject to subject of theMessage 525 | if msgSubject does not contain searchSubject then 526 | set matchesSubject to false 527 | end if 528 | end if 529 | 530 | -- Check sender if specified 531 | if searchSender is not "" then 532 | set msgSender to sender of theMessage 533 | if msgSender does not contain searchSender then 534 | set matchesSender to false 535 | end if 536 | end if 537 | 538 | -- Check date if specified 539 | if searchDate is not "" then 540 | set msgDate to date received of theMessage 541 | set msgDateString to (year of msgDate as string) & "-" & my padNumber(month of msgDate as integer) & "-" & my padNumber(day of msgDate as integer) 542 | if msgDateString is not searchDate then 543 | set matchesDate to false 544 | end if 545 | end if 546 | 547 | -- Add to filtered list if all criteria match 548 | if matchesSubject and matchesSender and matchesDate then 549 | set end of filteredMessages to theMessage 550 | end if 551 | end try 552 | end repeat 553 | 554 | -- Format the results 555 | set emailList to "Search results:" & return & return 556 | 557 | if (count of filteredMessages) is 0 then 558 | set emailList to emailList & "No matching emails found." 559 | else 560 | repeat with theMessage in filteredMessages 561 | try 562 | set msgSubject to subject of theMessage 563 | set msgSender to sender of theMessage 564 | set msgDate to date received of theMessage 565 | set msgRead to read status of theMessage 566 | 567 | -- Try to get account name for this message 568 | set msgAccount to "" 569 | try 570 | set msgMailbox to mailbox of theMessage 571 | set msgAcct to account of msgMailbox 572 | set msgAccount to " [" & name of msgAcct & "]" 573 | end try 574 | 575 | set emailList to emailList & "From: " & msgSender & return 576 | set emailList to emailList & "Subject: " & msgSubject & return 577 | set emailList to emailList & "Date: " & msgDate & msgAccount & return 578 | set emailList to emailList & "Read: " & msgRead & return 579 | 580 | -- Include body if requested 581 | if includeBody then 582 | set msgContent to content of theMessage 583 | set emailList to emailList & "Content: " & return & msgContent & return 584 | end if 585 | 586 | set emailList to emailList & return 587 | on error errMsg 588 | set emailList to emailList & "Error processing message: " & errMsg & return & return 589 | end try 590 | end repeat 591 | end if 592 | 593 | return emailList 594 | end tell 595 | 596 | -- Helper function to pad numbers with leading zero if needed 597 | on padNumber(num) 598 | if num < 10 then 599 | return "0" & num 600 | else 601 | return num as string 602 | end if 603 | end padNumber 604 | `, 605 | }, 606 | ], 607 | }; 608 | ```