# 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: -------------------------------------------------------------------------------- ``` node_modules/ build/ *.log .env* /dist .DS_Store helps/* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # applescript-mcp MCP Server A Model Context Protocol server that enables LLM applications to interact with macOS through AppleScript. This server provides a standardized interface for AI applications to control system functions, manage files, handle notifications, and more. [](https://github.com/joshrutkowski/applescript-mcp/actions/workflows/node.js.yml) <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> ## Features - 🗓️ Calendar management (events, reminders) - 📋 Clipboard operations - 🔍 Finder integration - 🔔 System notifications - ⚙️ System controls (volume, dark mode, apps) - 📟 iTerm terminal integration - 📬 Mail (create new email, list emails, get email) - 🔄 Shortcuts automation - 💬 Messages (list chats, get messages, search messages, send a message) - 🗒️ Notes (create formatted notes, list notes, search notes) - 📄 Pages (create documents) ### Planned Features - 🧭 Safari (open in Safari, save page content, get selected page/tab) - ✅ Reminders (create, get) ## Prerequisites - macOS 10.15 or later - Node.js 18 or later ## Available Categories ### Calendar | Command | Description | Parameters | | ------- | --------------------- | --------------------------------------------------- | | `add` | Create calendar event | `title`, `startDate`, `endDate`, `calendar` (optional) | | `list` | List today's events | None | #### Examples ``` // Create a new calendar event Create a calendar event titled "Team Meeting" starting tomorrow at 2pm for 1 hour // List today's events What events do I have scheduled for today? ``` ### Clipboard | Command | Description | Parameters | | ----------------- | ---------------------- | ---------- | | `set_clipboard` | Copy to clipboard | `content` | | `get_clipboard` | Get clipboard contents | None | | `clear_clipboard` | Clear clipboard | None | #### Examples ``` // Copy text to clipboard Copy "Remember to buy groceries" to my clipboard // Get clipboard contents What's currently in my clipboard? // Clear clipboard Clear my clipboard ``` ### Finder | Command | Description | Parameters | | -------------------- | ------------------ | ------------------------------ | | `get_selected_files` | Get selected files | None | | `search_files` | Search for files | `query`, `location` (optional) | | `quick_look` | Preview file | `path` | #### Examples ``` // Get selected files in Finder What files do I currently have selected in Finder? // Search for files Find all PDF files in my Documents folder // Preview a file Show me a preview of ~/Documents/report.pdf ``` ### Notifications > Note: Sending notification requires that you enable notifications in System Settings > Notifications > Script Editor. | Command | Description | Parameters | | ----------------------- | ----------------- | -------------------------------------- | | `send_notification` | Show notification | `title`, `message`, `sound` (optional) | | `toggle_do_not_disturb` | Toggle DND mode | None | #### Examples ``` // Send a notification Send me a notification with the title "Reminder" and message "Time to take a break" // Toggle Do Not Disturb Turn on Do Not Disturb mode ``` ### System | Command | Description | Parameters | | ------------------- | ----------------- | -------------------------- | | `volume` | Set system volume | `level` (0-100) | | `get_frontmost_app` | Get active app | None | | `launch_app` | Open application | `name` | | `quit_app` | Close application | `name`, `force` (optional) | | `toggle_dark_mode` | Toggle dark mode | None | #### Examples ``` // Set system volume Set my Mac's volume to 50% // Get active application What app am I currently using? // Launch an application Open Safari // Quit an application Close Spotify // Toggle dark mode Switch to dark mode ``` ### iTerm | Command | Description | Parameters | | ----------------- | --------------- | --------------------------------- | | `paste_clipboard` | Paste to iTerm | None | | `run` | Execute command | `command`, `newWindow` (optional) | #### Examples ``` // Paste clipboard to iTerm Paste my clipboard contents into iTerm // Run a command in iTerm Run "ls -la" in iTerm // Run a command in a new iTerm window Run "top" in a new iTerm window ``` ### Shortcuts | Command | Description | Parameters | | ---------------- | ------------------------------------------ | ---------------------------------------------------- | | `run_shortcut` | Run a shortcut | `name`, `input` (optional) | | `list_shortcuts` | List all available shortcuts | `limit` (optional) | #### Examples ``` // List available shortcuts List all my available shortcuts // List with limit Show me my top 5 shortcuts // Run a shortcut Run my "Daily Note in Bear" shortcut // Run a shortcut with input Run my "Add to-do" shortcut with input "Buy groceries" ``` ### Mail | Command | Description | Parameters | | ------------- | -------------------------------- | --------------------------------------------------------- | | `create_email`| Create a new email in Mail.app | `recipient`, `subject`, `body` | | `list_emails` | List emails from a mailbox | `mailbox` (optional), `count` (optional), `unreadOnly` (optional) | | `get_email` | Get a specific email by search | `subject` (optional), `sender` (optional), `dateReceived` (optional), `mailbox` (optional), `account` (optional), `unreadOnly` (optional), `includeBody` (optional) | #### Examples ``` // Create a new email Compose an email to [email protected] with subject "Meeting Tomorrow" and body "Hi John, Can we meet tomorrow at 2pm?" // List emails Show me my 10 most recent unread emails // Get a specific email Find the email from [email protected] about "Project Update" ``` ### Messages | Command | Description | Parameters | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | | `list_chats` | List available iMessage and SMS chats | `includeParticipantDetails` (optional, default: false) | | `get_messages` | Get messages from the Messages app | `limit` (optional, default: 100) | | `search_messages` | Search for messages containing specific text | `searchText`, `sender` (optional), `chatId` (optional), `limit` (optional, default: 50), `daysBack` (optional, default: 30) | | `compose_message` | Open Messages app with pre-filled message or auto-send | `recipient` (required), `body` (optional), `auto` (optional, default: false) | #### Examples ``` // List available chats Show me my recent message conversations // Get recent messages Show me my last 20 messages // Search messages Find messages containing "dinner plans" from John in the last week // Compose a message Send a message to 555-123-4567 saying "I'll be there in 10 minutes" ``` ### Notes | Command | Description | Parameters | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | | `create` | Create a note with markdown-like formatting | `title`, `content`, `format` (optional with formatting options) | | `createRawHtml` | Create a note with direct HTML content | `title`, `html` | | `list` | List notes, optionally from a specific folder| `folder` (optional) | | `get` | Get a specific note by title | `title`, `folder` (optional) | | `search` | Search for notes containing specific text | `query`, `folder` (optional), `limit` (optional, default: 5), `includeBody` (optional, default: true) | #### Examples ``` // Create a new note with markdown formatting Create a note titled "Meeting Minutes" with content "# Discussion Points\n- Project timeline\n- Budget review\n- Next steps" and format headings and lists // Create a note with HTML Create a note titled "Formatted Report" with HTML content "<h1>Quarterly Report</h1><p>Sales increased by <strong>15%</strong></p>" // List notes Show me all my notes in the "Work" folder // Get a specific note Show me my note titled "Shopping List" // Search notes Find notes containing "recipe" in my "Cooking" folder ``` ### Pages | Command | Description | Parameters | | ----------------- | -------------------------------------------- | --------------------------------------------------------- | | `create_document` | Create a new Pages document with plain text | `content` | #### Examples ``` // Create a new Pages document Create a Pages document with the content "Project Proposal\n\nThis document outlines the scope and timeline for the upcoming project." ``` ## Architecture The applescript-mcp server is built using TypeScript and follows a modular architecture: ### Core Components 1. **AppleScriptFramework** (`framework.ts`): The main server class that: - Manages MCP protocol communication - Handles tool registration and execution - Provides logging functionality - Executes AppleScript commands 2. **Categories** (`src/categories/*.ts`): Modular script collections organized by functionality: - Each category contains related scripts (e.g., calendar, system, notes) - Categories are registered with the framework in `index.ts` 3. **Types** (`src/types/index.ts`): TypeScript interfaces defining: - `ScriptDefinition`: Structure for individual scripts - `ScriptCategory`: Collection of related scripts - `LogLevel`: Standard logging levels - `FrameworkOptions`: Configuration options ### Execution Flow 1. Client sends a tool request via MCP protocol 2. Server identifies the appropriate category and script 3. Script content is generated (static or dynamically via function) 4. AppleScript is executed via macOS `osascript` command 5. Results are returned to the client ### Logging System The framework includes a comprehensive logging system that: - Logs to both stderr and MCP logging protocol - Supports multiple severity levels (debug, info, warning, error, etc.) - Provides detailed execution information for troubleshooting ## Development ### Setup ```bash # Install dependencies npm install # Build the server npm run build # Launch MCP Inspector # See: https://modelcontextprotocol.io/docs/tools/inspector npx @modelcontextprotocol/inspector node path/to/server/index.js args... ``` ### Adding New Functionality #### 1. Create Category File Create `src/categories/newcategory.ts`: ```typescript import { ScriptCategory } from "../types/index.js"; export const newCategory: ScriptCategory = { name: "category_name", description: "Category description", scripts: [ // Scripts will go here ], }; ``` #### 2. Add Scripts ```typescript { name: "script_name", description: "What the script does", schema: { type: "object", properties: { paramName: { type: "string", description: "Parameter description" } }, required: ["paramName"] }, script: (args) => ` tell application "App" // AppleScript code using ${args.paramName} end tell ` } ``` #### 3. Register Category Update `src/index.ts`: ```typescript import { newCategory } from "./categories/newcategory.js"; // ... server.addCategory(newCategory); ``` ### Advanced Script Development For more complex scripts, you can: 1. **Use dynamic script generation**: ```typescript script: (args) => { // Process arguments and build script dynamically let scriptContent = `tell application "App"\n`; if (args.condition) { scriptContent += ` // Conditional logic\n`; } scriptContent += `end tell`; return scriptContent; } ``` 2. **Process complex data**: ```typescript // Example from Notes category function generateNoteHtml(args: any): string { // Process markdown-like syntax into HTML let processedContent = content; if (format.headings) { processedContent = processedContent.replace(/^# (.+)$/gm, '<h1>$1</h1>'); // ... } return processedContent; } ``` ## Debugging ### Using MCP Inspector The MCP Inspector provides a web interface for testing and debugging your server: ```bash npm run inspector ``` ### Logging Enable debug logging by setting the environment variable: ```bash DEBUG=applescript-mcp* npm start ``` ### Example configuration After running `npm run build` add the following to your `mcp.json` file: ```json { "mcpServers": { "applescript-mcp-server": { "command": "node", "args": ["/path/to/applescript-mcp/dist/index.js"] } } } ``` ### Common Issues - **Permission Errors**: Check System Preferences > Security & Privacy > Privacy > Automation - **Script Failures**: Test scripts directly in Script Editor.app before integration - **Communication Issues**: Check stdio streams aren't being redirected - **Database Access**: Some features (like Messages) require Full Disk Access permission ## Resources - [AppleScript Language Guide](https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html) - [MCP Protocol Documentation](https://modelcontextprotocol.io) - [Issue Tracker](https://github.com/joshrutkowski/applescript-mcp/issues) ## Contributing 1. Fork the repository 2. Create a feature branch 3. Commit your changes 4. Push to the branch 5. Create a Pull Request ## License MIT License - see [LICENSE](LICENSE) for details ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "applescript-mcp", "version": "1.0.4", "description": "AppleScript MCP Framework", "main": "dist/index.js", "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "ts-node --esm src/index.ts" }, "keywords": [ "mcp", "applescript", "macos" ], "author": "Josh Rutkowski", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.1.1" }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0", "ts-node": "^10.9.0" } } ``` -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- ```yaml # 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 # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs name: Node.js CI on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: build: runs-on: macos-latest strategy: matrix: node-version: [18.x, 20.x] # Using macOS runners since this is an AppleScript project steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - run: npm run build ``` -------------------------------------------------------------------------------- /src/categories/pages.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * Pages-related scripts. * * create_document: Create a new Pages document with plain text content */ export const pagesCategory: ScriptCategory = { name: "pages", description: "Pages document operations", scripts: [ { name: "create_document", description: "Create a new Pages document with plain text content (no formatting)", schema: { type: "object", properties: { content: { type: "string", description: "The plain text content to add to the document (no formatting)" } }, required: ["content"] }, script: (args) => ` try tell application "Pages" -- Create new document set newDoc to make new document set the body text of newDoc to "${args.content.replace(/"/g, '\\"')}" activate return "Document created successfully with plain text content" end tell on error errMsg return "Failed to create document: " & errMsg end try ` } ] }; ``` -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- ```typescript export interface ScriptDefinition { /** * The name of the script. */ name: string; /** * A brief description of what the script does. */ description: string; /** * The script content, which can be a string or a function that returns a string. */ script: string | ((args: any) => string); /** * Optional schema defining the structure of the script's input parameters. */ schema?: { type: "object"; properties: Record<string, any>; /** * Optional list of required properties in the schema. */ required?: string[]; }; } export interface ScriptCategory { /** * The name of the script category. */ name: string; /** * A brief description of the script category. */ description: string; /** * A list of scripts that belong to this category. */ scripts: ScriptDefinition[]; } /** * Standard log levels for the framework's logging system. * Follows the RFC 5424 syslog severity levels. */ export type LogLevel = | "emergency" // System is unusable | "alert" // Action must be taken immediately | "critical" // Critical conditions | "error" // Error conditions | "warning" // Warning conditions | "notice" // Normal but significant condition | "info" // Informational messages | "debug"; // Debug-level messages export interface FrameworkOptions { /** * Optional name of the framework. */ name?: string; /** * Optional version of the framework. */ version?: string; /** * Optional flag to enable or disable debug mode. */ debug?: boolean; } ``` -------------------------------------------------------------------------------- /src/categories/notifications.ts: -------------------------------------------------------------------------------- ```typescript // src/categories/notifications.ts import { ScriptCategory } from "../types/index.js"; /** * Notification-related scripts. * * toggle_do_not_disturb: Toggle Do Not Disturb mode. NOTE: Requires keyboard shortcut to be set up in System Preferences. * * send_notification: Send a system notification */ export const notificationsCategory: ScriptCategory = { name: "notifications", description: "Notification management", scripts: [ { name: "toggle_do_not_disturb", description: "Toggle Do Not Disturb mode using keyboard shortcut", script: ` try tell application "System Events" keystroke "z" using {control down, option down, command down} end tell return "Toggled Do Not Disturb mode" on error errMsg return "Failed to toggle Do Not Disturb: " & errMsg end try `, }, { name: "send_notification", description: "Send a system notification", schema: { type: "object", properties: { title: { type: "string", description: "Notification title", }, message: { type: "string", description: "Notification message", }, sound: { type: "boolean", description: "Play sound with notification", default: true, }, }, required: ["title", "message"], }, script: (args) => ` display notification "${args.message}" with title "${args.title}" ${args.sound ? 'sound name "default"' : ""} `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/iterm.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * iTerm-related scripts. * * paste_clipboard: Pastes the clipboard to an iTerm window * * run: Run a command in iTerm */ export const itermCategory: ScriptCategory = { name: "iterm", description: "iTerm terminal operations", scripts: [ { name: "paste_clipboard", description: "Paste clipboard content into iTerm", script: ` tell application "System Events" to keystroke "c" using {command down} delay 0.1 tell application "iTerm" set w to current window tell w's current session to write text (the clipboard) activate end tell `, }, { name: "run", description: "Run a command in iTerm", schema: { type: "object", properties: { command: { type: "string", description: "Command to run in iTerm", }, newWindow: { type: "boolean", description: "Whether to open in a new window (default: false)", default: false, }, }, required: ["command"], }, script: (args) => ` tell application "iTerm" ${ args.newWindow ? ` set newWindow to (create window with default profile) tell current session of newWindow ` : ` set w to current window tell w's current session ` } write text "${args.command}" activate end tell end tell `, }, ], }; ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml name: Create Release on: push: tags: - 'v*' # Trigger on version tags (v1.0.0, v1.2.3, etc.) permissions: contents: write pull-requests: write repository-projects: write jobs: build: runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' cache: 'npm' - name: Install dependencies run: npm ci - name: Build package run: npm run build - name: Get version from tag id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Create package archive run: | mkdir -p dist-package cp -r dist package.json README.md LICENSE dist-package/ cd dist-package npm pack mv *.tgz ../applescript-mcp-${{ steps.get_version.outputs.VERSION }}.tgz - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: name: Release ${{ github.ref_name }} draft: false prerelease: false body: | Release of applescript-mcp version ${{ steps.get_version.outputs.VERSION }} ## Changes <!-- Add your release notes here --> files: | ./applescript-mcp-${{ steps.get_version.outputs.VERSION }}.tgz # Uncomment the following step if you want to publish to npm # - name: Publish to npm # run: npm publish # env: # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import { AppleScriptFramework } from "./framework.js"; import { systemCategory } from "./categories/system.js"; import { calendarCategory } from "./categories/calendar.js"; import { finderCategory } from "./categories/finder.js"; import { clipboardCategory } from "./categories/clipboard.js"; import { notificationsCategory } from "./categories/notifications.js"; import { itermCategory } from "./categories/iterm.js"; import { mailCategory } from "./categories/mail.js"; import { pagesCategory } from "./categories/pages.js"; import { shortcutsCategory } from "./categories/shortcuts.js"; import { messagesCategory } from "./categories/messages.js"; import { notesCategory } from "./categories/notes.js"; const server = new AppleScriptFramework({ name: "applescript-server", version: "1.0.4", debug: false, }); // Log startup information using stderr (server isn't connected yet) console.error(`[INFO] Starting AppleScript MCP server - PID: ${process.pid}`); // Add all categories console.error("[INFO] Registering categories..."); server.addCategory(systemCategory); server.addCategory(calendarCategory); server.addCategory(finderCategory); server.addCategory(clipboardCategory); server.addCategory(notificationsCategory); server.addCategory(itermCategory); server.addCategory(mailCategory); server.addCategory(pagesCategory); server.addCategory(shortcutsCategory); server.addCategory(messagesCategory); server.addCategory(notesCategory); console.error(`[INFO] Registered ${11} categories successfully`); // Start the server console.error("[INFO] Starting server..."); server.run() .then(() => { console.error("[NOTICE] Server started successfully"); }) .catch(error => { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[EMERGENCY] Failed to start server: ${errorMessage}`); console.error(error); }); ``` -------------------------------------------------------------------------------- /src/categories/shortcuts.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * Shortcuts-related scripts. * * run_shortcut: Run a shortcut with optional input * * list_shortcuts: List available shortcuts */ export const shortcutsCategory: ScriptCategory = { name: "shortcuts", description: "Shortcuts operations", scripts: [ { name: "run_shortcut", description: "Run a shortcut with optional input. Uses Shortcuts Events to run in background without opening the app.", schema: { type: "object", properties: { name: { type: "string", description: "Name of the shortcut to run", }, input: { type: "string", description: "Optional input to provide to the shortcut", }, }, required: ["name"], }, script: (args) => ` try tell application "Shortcuts Events" ${args.input ? `run shortcut "${args.name}" with input "${args.input}"` : `run shortcut "${args.name}"` } end tell return "Shortcut '${args.name}' executed successfully" on error errMsg return "Failed to run shortcut: " & errMsg end try `, }, { name: "list_shortcuts", description: "List all available shortcuts with optional limit", schema: { type: "object", properties: { limit: { type: "number", description: "Optional limit on the number of shortcuts to return", }, }, }, script: (args) => ` try tell application "Shortcuts" set shortcutNames to name of every shortcut ${args.limit ? ` -- Apply limit if specified if (count of shortcutNames) > ${args.limit} then set shortcutNames to items 1 through ${args.limit} of shortcutNames end if ` : ``} end tell -- Convert to JSON string manually set jsonOutput to "{" set jsonOutput to jsonOutput & "\\"status\\": \\"success\\"," set jsonOutput to jsonOutput & "\\"shortcuts\\": [" repeat with i from 1 to count of shortcutNames set currentName to item i of shortcutNames set jsonOutput to jsonOutput & "{\\"name\\": \\"" & currentName & "\\"}" if i < count of shortcutNames then set jsonOutput to jsonOutput & ", " end if end repeat set jsonOutput to jsonOutput & "]}" return jsonOutput on error errMsg return "{\\"status\\": \\"error\\", \\"message\\": \\"" & errMsg & "\\"}" end try `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/clipboard.ts: -------------------------------------------------------------------------------- ```typescript // src/categories/clipboard.ts import { ScriptCategory } from "../types/index.js"; /** * Clipboard-related scripts. * * get_clipboard: Returns the current clipboard content * * set_clipboard: Sets the clipboard to a specified value * * clear_clipboard: Resets the clipboard content */ export const clipboardCategory: ScriptCategory = { name: "clipboard", description: "Clipboard management operations", scripts: [ { name: "get_clipboard", description: "Get current clipboard content", schema: { type: "object", properties: { type: { type: "string", enum: ["text", "file_paths"], description: "Type of clipboard content to get", default: "text", }, }, }, script: (args) => { if (args?.type === "file_paths") { return ` tell application "System Events" try set theClipboard to the clipboard if theClipboard starts with "file://" then set AppleScript's text item delimiters to linefeed set filePaths to {} repeat with aPath in paragraphs of (the clipboard as string) if aPath starts with "file://" then set end of filePaths to (POSIX path of (aPath as alias)) end if end repeat return filePaths as string else return "No file paths in clipboard" end if on error errMsg return "Failed to get clipboard: " & errMsg end try end tell `; } else { return ` tell application "System Events" try return (the clipboard as text) on error errMsg return "Failed to get clipboard: " & errMsg end try end tell `; } }, }, { name: "set_clipboard", description: "Set clipboard content", schema: { type: "object", properties: { content: { type: "string", description: "Content to copy to clipboard", }, }, required: ["content"], }, script: (args) => ` try set the clipboard to "${args.content}" return "Clipboard content set successfully" on error errMsg return "Failed to set clipboard: " & errMsg end try `, }, { name: "clear_clipboard", description: "Clear clipboard content", script: ` try set the clipboard to "" return "Clipboard cleared successfully" on error errMsg return "Failed to clear clipboard: " & errMsg end try `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/system.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * System-related scripts. * * volume: Set system volume * * get_frontmost_app: Get the name of the frontmost application * * launch_app: Launch an application * * quit_app: Quit an application * * toggle_dark_mode: Toggle system dark mode */ export const systemCategory: ScriptCategory = { name: "system", description: "System control and information", scripts: [ { name: "volume", description: "Set system volume", schema: { type: "object", properties: { level: { type: "number", minimum: 0, maximum: 100, }, }, required: ["level"], }, script: (args) => `set volume ${Math.round((args.level / 100) * 7)}`, }, { name: "get_frontmost_app", description: "Get the name of the frontmost application", script: 'tell application "System Events" to get name of first process whose frontmost is true', }, { name: "launch_app", description: "Launch an application", schema: { type: "object", properties: { name: { type: "string", description: "Application name", }, }, required: ["name"], }, script: (args) => ` try tell application "${args.name}" activate end tell return "Application ${args.name} launched successfully" on error errMsg return "Failed to launch application: " & errMsg end try `, }, { name: "quit_app", description: "Quit an application", schema: { type: "object", properties: { name: { type: "string", description: "Application name", }, force: { type: "boolean", description: "Force quit if true", default: false, }, }, required: ["name"], }, script: (args) => ` try tell application "${args.name}" ${args.force ? "quit saving no" : "quit"} end tell return "Application ${args.name} quit successfully" on error errMsg return "Failed to quit application: " & errMsg end try `, }, { name: "toggle_dark_mode", description: "Toggle system dark mode", script: ` tell application "System Events" tell appearance preferences set dark mode to not dark mode return "Dark mode is now " & (dark mode as text) end tell end tell `, }, { name: "get_battery_status", description: "Get battery level and charging status", script: ` try set powerSource to do shell script "pmset -g batt" return powerSource on error errMsg return "Failed to get battery status: " & errMsg end try `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/finder.ts: -------------------------------------------------------------------------------- ```typescript // src/categories/finder.ts import { ScriptCategory } from "../types/index.js"; /** * Finder-related scripts. * * get_selected_files: Get currently selected files in Finder * * search_files: Search for files by name * * quick_look_file: Preview a file using Quick Look * */ export const finderCategory: ScriptCategory = { name: "finder", description: "Finder and file operations", scripts: [ { name: "get_selected_files", description: "Get currently selected files in Finder", script: ` tell application "Finder" try set selectedItems to selection if selectedItems is {} then return "No items selected" end if set itemPaths to "" repeat with theItem in selectedItems set itemPaths to itemPaths & (POSIX path of (theItem as alias)) & linefeed end repeat return itemPaths on error errMsg return "Failed to get selected files: " & errMsg end try end tell `, }, { name: "search_files", description: "Search for files by name", schema: { type: "object", properties: { query: { type: "string", description: "Search term", }, location: { type: "string", description: "Search location (default: home folder)", default: "~", }, }, required: ["query"], }, script: (args) => ` set searchPath to "/Users/joshrutkowski/Downloads" tell application "Finder" try set theFolder to POSIX file searchPath as alias set theFiles to every file of folder theFolder whose name contains "${args.query}" set resultList to "" repeat with aFile in theFiles set resultList to resultList & (POSIX path of (aFile as alias)) & return end repeat if resultList is "" then return "No files found matching '${args.query}'" end if return resultList on error errMsg return "Failed to search files: " & errMsg end try end tell `, }, { name: "quick_look_file", description: "Preview a file using Quick Look", schema: { type: "object", properties: { path: { type: "string", description: "File path to preview", }, }, required: ["path"], }, script: (args) => ` try set filePath to POSIX file "${args.path}" tell application "Finder" activate select filePath tell application "System Events" -- Press Space to trigger Quick Look delay 0.5 -- Small delay to ensure Finder is ready key code 49 -- Space key end tell end tell return "Quick Look preview opened for ${args.path}" on error errMsg return "Failed to open Quick Look: " & errMsg end try `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/calendar.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * Calendar-related scripts. * * add: adds a new event to Calendar * * list: List events for today */ export const calendarCategory: ScriptCategory = { name: "calendar", description: "Calendar operations", scripts: [ { name: "add", description: "Add a new event to Calendar", schema: { type: "object", properties: { title: { type: "string", description: "Event title", }, startDate: { type: "string", description: "Start date and time (YYYY-MM-DD HH:MM:SS)", }, endDate: { type: "string", description: "End date and time (YYYY-MM-DD HH:MM:SS)", }, calendar: { type: "string", description: "Calendar name (optional)", default: "Calendar", }, }, required: ["title", "startDate", "endDate"], }, script: (args) => ` tell application "Calendar" set theStartDate to current date set hours of theStartDate to ${args.startDate.slice(11, 13)} set minutes of theStartDate to ${args.startDate.slice(14, 16)} set seconds of theStartDate to ${args.startDate.slice(17, 19)} set theEndDate to theStartDate + (1 * hours) set hours of theEndDate to ${args.endDate.slice(11, 13)} set minutes of theEndDate to ${args.endDate.slice(14, 16)} set seconds of theEndDate to ${args.endDate.slice(17, 19)} tell calendar "${args.calendar || "Calendar"}" make new event with properties {summary:"${args.title}", start date:theStartDate, end date:theEndDate} end tell end tell `, }, { name: "list", description: "List all events for today", script: ` tell application "Calendar" set todayStart to (current date) set time of todayStart to 0 set todayEnd to todayStart + 1 * days set eventList to {} repeat with calendarAccount in calendars 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) end repeat set output to "" repeat with anEvent in eventList set eventStartDate to start date of anEvent set eventEndDate to end date of anEvent -- Format the time parts set startHours to hours of eventStartDate set startMinutes to minutes of eventStartDate set endHours to hours of eventEndDate set endMinutes to minutes of eventEndDate set output to output & "Event: " & summary of anEvent & "\n" set output to output & "Start: " & startHours & ":" & text -2 thru -1 of ("0" & startMinutes) & "\n" set output to output & "End: " & endHours & ":" & text -2 thru -1 of ("0" & endMinutes) & "\n" set output to output & "-------------------\n" end repeat return output end tell `, }, ], }; ``` -------------------------------------------------------------------------------- /src/categories/messages.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * iMessage related scripts */ export const messagesCategory: ScriptCategory = { name: "messages", description: "iMessage operations", scripts: [ { name: "list_chats", description: "List available iMessage and SMS chats", schema: { type: "object", properties: { includeParticipantDetails: { type: "boolean", description: "Include detailed participant information", default: false } } }, script: (args) => ` tell application "Messages" set chatList to {} repeat with aChat in chats set chatName to name of aChat if chatName is missing value then set chatName to "" -- Try to get the contact name for individual chats try set theParticipants to participants of aChat if (count of theParticipants) is 1 then set theParticipant to item 1 of theParticipants set chatName to name of theParticipant end if end try end if set chatInfo to {id:id of aChat, name:chatName, isGroupChat:(id of aChat contains "+")} ${args.includeParticipantDetails ? ` -- Add participant details if requested set participantList to {} repeat with aParticipant in participants of aChat set participantInfo to {id:id of aParticipant, handle:handle of aParticipant} try set participantInfo to participantInfo & {name:name of aParticipant} end try copy participantInfo to end of participantList end repeat set chatInfo to chatInfo & {participant:participantList} ` : ''} copy chatInfo to end of chatList end repeat return chatList end tell ` }, { name: "get_messages", description: "Get messages from the Messages app", schema: { type: "object", properties: { limit: { type: "number", description: "Maximum number of messages to retrieve", default: 100 } } }, script: (args) => ` on run -- Path to the Messages database set dbPath to (do shell script "echo ~/Library/Messages/chat.db") -- Create a temporary SQL file for our query set tempFile to (do shell script "mktemp /tmp/imessage_query.XXXXXX") -- Write SQL query to temp file do shell script "cat > " & quoted form of tempFile & " << 'EOF' SELECT datetime(message.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as message_date, handle.id as sender, message.text as message_text, chat.display_name as chat_name FROM message LEFT JOIN handle ON message.handle_id = handle.ROWID LEFT JOIN chat_message_join ON message.ROWID = chat_message_join.message_id LEFT JOIN chat ON chat_message_join.chat_id = chat.ROWID ORDER BY message.date DESC LIMIT ${args.limit}; EOF" -- Execute the query set queryResult to do shell script "sqlite3 " & quoted form of dbPath & " < " & quoted form of tempFile -- Clean up temp file do shell script "rm " & quoted form of tempFile -- Process and display results set resultList to paragraphs of queryResult set messageData to {} repeat with messageLine in resultList set messageData to messageData & messageLine end repeat return messageData end run ` }, { name: "search_messages", description: "Search for messages containing specific text or from a specific sender", schema: { type: "object", properties: { searchText: { type: "string", description: "Text to search for in messages", default: "" }, sender: { type: "string", description: "Search for messages from a specific sender (phone number or email)", default: "" }, chatId: { type: "string", description: "Limit search to a specific chat ID", default: "" }, limit: { type: "number", description: "Maximum number of messages to retrieve", default: 50 }, daysBack: { type: "number", description: "Limit search to messages from the last N days", default: 30 } }, required: ["searchText"] }, script: (args) => ` on run -- Path to the Messages database set dbPath to (do shell script "echo ~/Library/Messages/chat.db") -- Create a temporary SQL file for our query set tempFile to (do shell script "mktemp /tmp/imessage_search.XXXXXX") -- Build WHERE clause based on provided parameters set whereClause to "" ${args.searchText ? ` -- Add search text condition if provided set whereClause to whereClause & "message.text LIKE '%${args.searchText.replace(/'/g, "''")}%' " ` : ''} ${args.sender ? ` -- Add sender condition if provided if length of whereClause > 0 then set whereClause to whereClause & "AND " end if set whereClause to whereClause & "handle.id LIKE '%${args.sender.replace(/'/g, "''")}%' " ` : ''} ${args.chatId ? ` -- Add chat ID condition if provided if length of whereClause > 0 then set whereClause to whereClause & "AND " end if set whereClause to whereClause & "chat.chat_identifier = '${args.chatId.replace(/'/g, "''")}' " ` : ''} ${args.daysBack ? ` -- Add date range condition if length of whereClause > 0 then set whereClause to whereClause & "AND " end if set whereClause to whereClause & "message.date > (strftime('%s', 'now', '-${args.daysBack} days') - strftime('%s', '2001-01-01')) * 1000000000 " ` : ''} -- If no search parameters were provided, add a default condition to avoid returning all messages if length of whereClause = 0 then set whereClause to "1=1 " end if -- Write SQL query to temp file do shell script "cat > " & quoted form of tempFile & " << 'EOF' SELECT datetime(message.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as message_date, handle.id as sender, message.text as message_text, chat.display_name as chat_name, chat.chat_identifier as chat_id FROM message LEFT JOIN handle ON message.handle_id = handle.ROWID LEFT JOIN chat_message_join ON message.ROWID = chat_message_join.message_id LEFT JOIN chat ON chat_message_join.chat_id = chat.ROWID WHERE " & whereClause & " ORDER BY message.date DESC LIMIT ${args.limit}; EOF" -- Execute the query set queryResult to do shell script "sqlite3 " & quoted form of dbPath & " < " & quoted form of tempFile -- Clean up temp file do shell script "rm " & quoted form of tempFile -- Process and display results set resultList to paragraphs of queryResult set messageData to {} repeat with messageLine in resultList set messageData to messageData & messageLine end repeat return messageData end run ` }, { name: "compose_message", description: "Open Messages app with a pre-filled message to a recipient or automatically send a message", schema: { type: "object", properties: { recipient: { type: "string", description: "Phone number or email of the recipient" }, body: { type: "string", description: "Message body text", default: "" }, auto: { type: "boolean", description: "Automatically send the message without user confirmation", default: false } }, required: ["recipient"] }, script: (args) => ` on run -- Get the recipient and message body set recipient to "${args.recipient}" set messageBody to "${args.body || ''}" set autoSend to ${args.auto === true ? "true" : "false"} if autoSend then -- Automatically send the message using AppleScript tell application "Messages" -- Get the service (iMessage or SMS) set targetService to 1st service whose service type = iMessage -- Send the message set targetBuddy to buddy "${args.recipient}" of targetService send "${args.body || ''}" to targetBuddy return "Message sent to " & "${args.recipient}" end tell else -- Just open Messages app with pre-filled content -- Create the SMS URL with proper URL encoding set smsURL to "sms:" & recipient if messageBody is not equal to "" then -- Use percent encoding for spaces instead of plus signs set encodedBody to "" repeat with i from 1 to count of characters of messageBody set c to character i of messageBody if c is space then set encodedBody to encodedBody & "%20" else set encodedBody to encodedBody & c end if end repeat set smsURL to smsURL & "&body=" & encodedBody end if -- Open the URL with the default handler (Messages app) do shell script "open " & quoted form of smsURL return "Opening Messages app with recipient: " & recipient end if end run ` } ] }; ``` -------------------------------------------------------------------------------- /src/framework.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { exec } from "child_process"; import { promisify } from "util"; import os from "os"; import { ScriptCategory, ScriptDefinition, FrameworkOptions, LogLevel, } from "./types/index.js"; const execAsync = promisify(exec); // Get system information for logging const systemInfo = { platform: os.platform(), release: os.release(), hostname: os.hostname(), arch: os.arch(), nodeVersion: process.version }; export class AppleScriptFramework { private server: Server; private categories: ScriptCategory[] = []; private _initInfo: Record<string, any> = {}; private _isConnected: boolean = false; private _pendingCategories: Array<Record<string, any>> = []; /** * Constructs an instance of AppleScriptFramework. * @param options - Configuration options for the framework. */ constructor(options: FrameworkOptions = {}) { const serverName = options.name || "applescript-server"; const serverVersion = options.version || "1.0.0"; this.server = new Server( { name: serverName, version: serverVersion, }, { capabilities: { tools: {}, logging: {}, // Enable logging capability }, }, ); if (options.debug) { this.enableDebugLogging(); } // Log server initialization with stderr (server isn't connected yet) console.error(`[INFO] AppleScript MCP server initialized - ${serverName} v${serverVersion}`); // Store initialization info for later logging after connection this._initInfo = { name: serverName, version: serverVersion, debug: !!options.debug, system: systemInfo }; } /** * Enables debug logging for the server. * Sets up error handlers and configures detailed logging. */ private enableDebugLogging(): void { console.error("[INFO] Debug logging enabled"); this.server.onerror = (error) => { const errorMessage = error instanceof Error ? error.message : String(error); console.error("[MCP Error]", error); // Only use MCP logging if connected if (this._isConnected) { this.log("error", "MCP server error", { error: errorMessage }); } }; // Set up additional debug event handlers if needed this.server.oninitialized = () => { this._isConnected = true; console.error("[DEBUG] Connection initialized"); // We'll log initialization info in the run method after connection is fully established console.error("[DEBUG] Connection initialized"); }; this.server.onclose = () => { this._isConnected = false; console.error("[DEBUG] Connection closed"); // No MCP logging here since we're disconnected }; } /** * Adds a new script category to the framework. * @param category - The script category to add. */ addCategory(category: ScriptCategory): void { this.categories.push(category); // Use console logging since this is called before server connection console.error(`[DEBUG] Added category: ${category.name} (${category.scripts.length} scripts)`); // Store category info to log via MCP after connection if (!this._pendingCategories) { this._pendingCategories = []; } this._pendingCategories.push({ categoryName: category.name, scriptCount: category.scripts.length, description: category.description }); } /** * Logs a message with the specified severity level. * Uses the MCP server's logging system to record events if available. * Always logs to console for visibility. * * @param level - The severity level of the log message following RFC 5424 syslog levels * @param message - The message to log * @param data - Optional additional data to include with the log message * * @example * // Log a debug message * framework.log("debug", "Processing request", { requestId: "123" }); * * @example * // Log an error * framework.log("error", "Failed to execute script", { scriptName: "calendar_add" }); */ log(level: LogLevel, message: string, data?: Record<string, any>): void { // Format for console output const timestamp = new Date().toISOString(); const dataStr = data ? ` ${JSON.stringify(data)}` : ''; // Always log to stderr for visibility console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}${dataStr}`); // Only try to use MCP logging if we're connected if (this._isConnected) { try { this.server.sendLoggingMessage({ level: level, message: message, data: data || {}, }); } catch (error) { // Silently ignore logging errors - we've already logged to console } } } /** * Executes an AppleScript and returns the result. * @param script - The AppleScript to execute. * @returns The result of the script execution. * @throws Will throw an error if the script execution fails. */ private async executeScript(script: string): Promise<string> { // Log script execution (truncate long scripts for readability) const scriptPreview = script.length > 100 ? script.substring(0, 100) + "..." : script; this.log("debug", "Executing AppleScript", { scriptPreview }); try { const startTime = Date.now(); const { stdout } = await execAsync( `osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, ); const executionTime = Date.now() - startTime; this.log("debug", "AppleScript executed successfully", { executionTimeMs: executionTime, outputLength: stdout.length }); return stdout.trim(); } catch (error) { // Properly type check the error object let errorMessage = "Unknown error occurred"; if (error && typeof error === "object") { if ("message" in error && typeof error.message === "string") { errorMessage = error.message; } else if (error instanceof Error) { errorMessage = error.message; } } else if (typeof error === "string") { errorMessage = error; } this.log("error", "AppleScript execution failed", { error: errorMessage, scriptPreview }); throw new Error(`AppleScript execution failed: ${errorMessage}`); } } /** * Sets up request handlers for the server. */ private setupHandlers(): void { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.categories.flatMap((category) => category.scripts.map((script) => ({ name: `${category.name}_${script.name}`, // Changed from dot to underscore description: `[${category.description}] ${script.description}`, inputSchema: script.schema || { type: "object", properties: {}, }, })), ), })); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; this.log("info", "Tool execution requested", { tool: toolName, hasArguments: !!request.params.arguments }); try { // Split on underscore instead of dot const [categoryName, ...scriptNameParts] = toolName.split("_"); const scriptName = scriptNameParts.join("_"); // Rejoin in case script name has underscores const category = this.categories.find((c) => c.name === categoryName); if (!category) { this.log("warning", "Category not found", { categoryName }); throw new McpError( ErrorCode.MethodNotFound, `Category not found: ${categoryName}`, ); } const script = category.scripts.find((s) => s.name === scriptName); if (!script) { this.log("warning", "Script not found", { categoryName, scriptName }); throw new McpError( ErrorCode.MethodNotFound, `Script not found: ${scriptName}`, ); } this.log("debug", "Generating script content", { categoryName, scriptName, isFunction: typeof script.script === "function" }); const scriptContent = typeof script.script === "function" ? script.script(request.params.arguments) : script.script; const result = await this.executeScript(scriptContent); this.log("info", "Tool execution completed successfully", { tool: toolName, resultLength: result.length }); return { content: [ { type: "text", text: result, }, ], }; } catch (error) { if (error instanceof McpError) { this.log("error", "MCP error during tool execution", { tool: toolName, errorCode: error.code, errorMessage: error.message }); throw error; } let errorMessage = "Unknown error occurred"; if (error && typeof error === "object") { if ("message" in error && typeof error.message === "string") { errorMessage = error.message; } else if (error instanceof Error) { errorMessage = error.message; } } else if (typeof error === "string") { errorMessage = error; } this.log("error", "Error during tool execution", { tool: toolName, errorMessage }); return { content: [ { type: "text", text: `Error: ${errorMessage}`, }, ], isError: true, }; } }); } /** * Runs the AppleScript framework server. */ async run(): Promise<void> { console.error("[INFO] Setting up request handlers"); this.setupHandlers(); console.error("[INFO] Initializing StdioServerTransport"); const transport = new StdioServerTransport(); try { console.error("[INFO] Connecting server to transport"); await this.server.connect(transport); this._isConnected = true; // Log server running status using console only const totalScripts = this.categories.reduce((count, category) => count + category.scripts.length, 0); console.error(`[NOTICE] AppleScript MCP server running with ${this.categories.length} categories and ${totalScripts} scripts`); console.error("AppleScript MCP server running"); } catch (error) { let errorMessage = "Unknown error occurred"; if (error && typeof error === "object" && error instanceof Error) { errorMessage = error.message; } console.error("Failed to start AppleScript MCP server:", errorMessage); throw error; } } } ``` -------------------------------------------------------------------------------- /src/categories/notes.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * Generates HTML content for a note based on user input * @param args The arguments containing content specifications * @returns HTML string for the note */ function generateNoteHtml(args: any): string { const { title = "New Note", content = "", format = { headings: false, bold: false, italic: false, underline: false, links: false, lists: false } } = args; // Process content based on format options let processedContent = content; // If content contains markdown-like syntax and formatting is enabled, convert it if (format.headings) { // Convert # Heading to <h1>Heading</h1>, ## Heading to <h2>Heading</h2>, etc. processedContent = processedContent.replace(/^# (.+)$/gm, '<h1>$1</h1>'); processedContent = processedContent.replace(/^## (.+)$/gm, '<h2>$1</h2>'); processedContent = processedContent.replace(/^### (.+)$/gm, '<h3>$1</h3>'); } if (format.bold) { // Convert **text** or __text__ to <b>text</b> processedContent = processedContent.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'); processedContent = processedContent.replace(/__(.+?)__/g, '<b>$1</b>'); } if (format.italic) { // Convert *text* or _text_ to <i>text</i> processedContent = processedContent.replace(/\*(.+?)\*/g, '<i>$1</i>'); processedContent = processedContent.replace(/_(.+?)_/g, '<i>$1</i>'); } if (format.underline) { // Convert ~text~ to <u>text</u> processedContent = processedContent.replace(/~(.+?)~/g, '<u>$1</u>'); } if (format.links) { // Convert [text](url) to <a href="url">text</a> processedContent = processedContent.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>'); } if (format.lists) { // Handle unordered lists // Look for lines starting with - or * and convert to <li> items const listItems = processedContent.match(/^[*-] (.+)$/gm); if (listItems) { let listHtml = '<ul>'; for (const item of listItems) { const content = item.replace(/^[*-] /, ''); listHtml += `<li>${content}</li>`; } listHtml += '</ul>'; // Replace the original list items with the HTML list for (const item of listItems) { processedContent = processedContent.replace(item, ''); } processedContent = processedContent.replace(/\n+/g, '\n') + listHtml; } // Handle ordered lists (1. Item) const orderedItems = processedContent.match(/^\d+\. (.+)$/gm); if (orderedItems) { let listHtml = '<ol>'; for (const item of orderedItems) { const content = item.replace(/^\d+\. /, ''); listHtml += `<li>${content}</li>`; } listHtml += '</ol>'; // Replace the original list items with the HTML list for (const item of orderedItems) { processedContent = processedContent.replace(item, ''); } processedContent = processedContent.replace(/\n+/g, '\n') + listHtml; } } // Wrap paragraphs in <p> tags if they aren't already wrapped in HTML tags const paragraphs = processedContent.split('\n\n'); processedContent = paragraphs .map((p: string) => { if (p.trim() && !p.trim().startsWith('<')) { return `<p>${p}</p>`; } return p; }) .join('\n'); return processedContent; } export const notesCategory: ScriptCategory = { name: "notes", description: "Apple Notes operations", scripts: [ { name: "create", description: "Create a new note with optional formatting", script: (args) => { const { title = "New Note", content = "", format = {} } = args; const htmlContent = generateNoteHtml(args); return ` tell application "Notes" make new note with properties {body:"${htmlContent}", name:"${title}"} end tell `; }, schema: { type: "object", properties: { title: { type: "string", description: "Title of the note" }, content: { type: "string", description: "Content of the note, can include markdown-like syntax for formatting" }, format: { type: "object", description: "Formatting options for the note content", properties: { headings: { type: "boolean", description: "Enable heading formatting (# Heading)" }, bold: { type: "boolean", description: "Enable bold formatting (**text**)" }, italic: { type: "boolean", description: "Enable italic formatting (*text*)" }, underline: { type: "boolean", description: "Enable underline formatting (~text~)" }, links: { type: "boolean", description: "Enable link formatting ([text](url))" }, lists: { type: "boolean", description: "Enable list formatting (- item or 1. item)" } } } }, required: ["title", "content"] } }, { name: "createRawHtml", description: "Create a new note with direct HTML content", script: (args) => { const { title = "New Note", html = "" } = args; return ` tell application "Notes" make new note with properties {body:"${html.replace(/"/g, '\\"')}", name:"${title}"} end tell `; }, schema: { type: "object", properties: { title: { type: "string", description: "Title of the note" }, html: { type: "string", description: "Raw HTML content for the note" } }, required: ["title", "html"] } }, { name: "list", description: "List all notes or notes in a specific folder", script: (args) => { const { folder = "" } = args; if (folder) { return ` tell application "Notes" set folderList to folders whose name is "${folder}" if length of folderList > 0 then set targetFolder to item 1 of folderList set noteNames to name of notes of targetFolder return noteNames as string else return "Folder not found: ${folder}" end if end tell `; } else { return ` tell application "Notes" set noteNames to name of notes return noteNames as string end tell `; } }, schema: { type: "object", properties: { folder: { type: "string", description: "Optional folder name to list notes from" } } } }, { name: "get", description: "Get a specific note by title", script: (args) => { const { title, folder = "" } = args; if (folder) { return ` tell application "Notes" set folderList to folders whose name is "${folder}" if length of folderList > 0 then set targetFolder to item 1 of folderList set matchingNotes to notes of targetFolder whose name is "${title}" if length of matchingNotes > 0 then set n to item 1 of matchingNotes set noteTitle to name of n set noteBody to body of n set noteCreationDate to creation date of n set noteModDate to modification date of n set jsonResult to "{\\"title\\": \\"" set jsonResult to jsonResult & noteTitle & "\\"" set jsonResult to jsonResult & ", \\"body\\": \\"" & noteBody & "\\"" set jsonResult to jsonResult & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" set jsonResult to jsonResult & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" return jsonResult else return "Note not found: ${title}" end if else return "Folder not found: ${folder}" end if end tell `; } else { return ` tell application "Notes" set matchingNotes to notes whose name is "${title}" if length of matchingNotes > 0 then set n to item 1 of matchingNotes set noteTitle to name of n set noteBody to body of n set noteCreationDate to creation date of n set noteModDate to modification date of n set jsonResult to "{\\"title\\": \\"" set jsonResult to jsonResult & noteTitle & "\\"" set jsonResult to jsonResult & ", \\"body\\": \\"" & noteBody & "\\"" set jsonResult to jsonResult & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" set jsonResult to jsonResult & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" return jsonResult else return "Note not found: ${title}" end if end tell `; } }, schema: { type: "object", properties: { title: { type: "string", description: "Title of the note to retrieve" }, folder: { type: "string", description: "Optional folder name to search in" } }, required: ["title"] } }, { name: "search", description: "Search for notes containing specific text", script: (args) => { const { query, folder = "", limit = 5, includeBody = true } = args; if (folder) { return ` tell application "Notes" set folderList to folders whose name is "${folder}" if length of folderList > 0 then set targetFolder to item 1 of folderList set matchingNotes to {} set allNotes to notes of targetFolder repeat with n in allNotes if name of n contains "${query}" or body of n contains "${query}" then set end of matchingNotes to n end if end repeat set resultCount to length of matchingNotes if resultCount > ${limit} then set resultCount to ${limit} set jsonResult to "[" repeat with i from 1 to resultCount set n to item i of matchingNotes set noteTitle to name of n set noteCreationDate to creation date of n set noteModDate to modification date of n ${includeBody ? 'set noteBody to body of n' : ''} set noteJson to "{\\"title\\": \\"" set noteJson to noteJson & noteTitle & "\\"" ${includeBody ? 'set noteJson to noteJson & ", \\"body\\": \\"" & noteBody & "\\""' : ''} set noteJson to noteJson & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" set noteJson to noteJson & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" set jsonResult to jsonResult & noteJson if i < resultCount then set jsonResult to jsonResult & ", " end repeat set jsonResult to jsonResult & "]" return jsonResult else return "Folder not found: ${folder}" end if end tell `; } else { return ` tell application "Notes" set matchingNotes to {} set allNotes to notes repeat with n in allNotes if name of n contains "${query}" or body of n contains "${query}" then set end of matchingNotes to n end if end repeat set resultCount to length of matchingNotes if resultCount > ${limit} then set resultCount to ${limit} set jsonResult to "[" repeat with i from 1 to resultCount set n to item i of matchingNotes set noteTitle to name of n set noteCreationDate to creation date of n set noteModDate to modification date of n ${includeBody ? 'set noteBody to body of n' : ''} set noteJson to "{\\"title\\": \\"" set noteJson to noteJson & noteTitle & "\\"" ${includeBody ? 'set noteJson to noteJson & ", \\"body\\": \\"" & noteBody & "\\""' : ''} set noteJson to noteJson & ", \\"creationDate\\": \\"" & noteCreationDate & "\\"" set noteJson to noteJson & ", \\"modificationDate\\": \\"" & noteModDate & "\\"}" set jsonResult to jsonResult & noteJson if i < resultCount then set jsonResult to jsonResult & ", " end repeat set jsonResult to jsonResult & "]" return jsonResult end tell `; } }, schema: { type: "object", properties: { query: { type: "string", description: "Text to search for in notes (title and body)" }, folder: { type: "string", description: "Optional folder name to search in" }, limit: { type: "number", description: "Maximum number of results to return (default: 5)" }, includeBody: { type: "boolean", description: "Whether to include note body in results (default: true)" } }, required: ["query"] } } ] }; ``` -------------------------------------------------------------------------------- /src/categories/mail.ts: -------------------------------------------------------------------------------- ```typescript import { ScriptCategory } from "../types/index.js"; /** * Mail-related scripts. * * create_email: Create a new email in Mail.app with specified recipient, subject, and body * * list_emails: List emails from a specified mailbox in Mail.app * * get_email: Get a specific email by ID or search criteria from Mail.app */ export const mailCategory: ScriptCategory = { name: "mail", description: "Mail operations", scripts: [ { name: "create_email", description: "Create a new email in Mail.app", schema: { type: "object", properties: { recipient: { type: "string", description: "Email recipient", }, subject: { type: "string", description: "Email subject", }, body: { type: "string", description: "Email body", }, }, required: ["recipient", "subject", "body"], }, script: (args) => ` set recipient to "${args.recipient}" set subject to "${args.subject}" set body to "${args.body}" -- URL encode subject and body set encodedSubject to my urlEncode(subject) set encodedBody to my urlEncode(body) -- Construct the mailto URL set mailtoURL to "mailto:" & recipient & "?subject=" & encodedSubject & "&body=" & encodedBody -- Use Apple Mail's 'mailto' command to create the email tell application "Mail" mailto mailtoURL activate end tell -- Handler to URL-encode text on urlEncode(theText) set theEncodedText to "" set theChars to every character of theText repeat with aChar in theChars set charCode to ASCII number aChar if charCode = 32 then set theEncodedText to theEncodedText & "%20" -- Space 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 -- Allowed characters: A-Z, a-z, 0-9, -, ., _, ~ set theEncodedText to theEncodedText & aChar else -- Convert to %HH format set hexCode to do shell script "printf '%02X' " & charCode set theEncodedText to theEncodedText & "%" & hexCode end if end repeat return theEncodedText end urlEncode `, }, { name: "list_emails", description: "List emails from a specified mailbox in Mail.app", schema: { type: "object", properties: { mailbox: { type: "string", description: "Name of the mailbox to list emails from (e.g., 'Inbox', 'Sent')", default: "Inbox" }, account: { type: "string", description: "Name of the account to search in (e.g., 'iCloud', 'Gmail', 'Exchange'). If not specified, searches all accounts with preference for iCloud.", default: "iCloud" }, count: { type: "number", description: "Maximum number of emails to retrieve", default: 10 }, unreadOnly: { type: "boolean", description: "Only show unread emails if true" } } }, script: (args) => ` set mailboxName to "${args.mailbox || 'Inbox'}" set accountName to "${args.account || 'iCloud'}" set messageCount to ${args.count || 10} set showUnreadOnly to ${args.unreadOnly ? 'true' : 'false'} set searchAllAccounts to ${!args.account ? 'true' : 'false'} tell application "Mail" -- Get all messages if no specific mailbox is found set foundMailbox to false set emailMessages to {} set targetAccount to missing value -- First try to find the specified account if not searchAllAccounts then try set allAccounts to every account repeat with acct in allAccounts if name of acct is accountName then set targetAccount to acct exit repeat end if end repeat end try -- If account not found, set to search all accounts if targetAccount is missing value then set searchAllAccounts to true end if end if -- If specific account is found, search in that account if not searchAllAccounts and targetAccount is not missing value then try set acctMailboxes to every mailbox of targetAccount repeat with m in acctMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat -- If mailbox not found in specified account, try to get inbox if not foundMailbox then try set inboxMailbox to inbox of targetAccount set targetMailbox to inboxMailbox set foundMailbox to true if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if end try end if end try else -- Search all accounts, with preference for iCloud set iCloudAccount to missing value set allAccounts to every account -- First look for iCloud account repeat with acct in allAccounts if name of acct is "iCloud" then set iCloudAccount to acct exit repeat end if end repeat -- Try to find the mailbox directly try set allMailboxes to every mailbox repeat with m in allMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat end try -- If not found directly, try to find it in each account (prioritize iCloud) if not foundMailbox and iCloudAccount is not missing value then try set acctMailboxes to every mailbox of iCloudAccount repeat with m in acctMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat end try end if -- If still not found in iCloud, check other accounts if not foundMailbox then repeat with acct in allAccounts if acct is not iCloudAccount then try set acctMailboxes to every mailbox of acct repeat with m in acctMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat if foundMailbox then exit repeat end try end if end repeat end if end if -- If still not found, get messages from all inboxes if not foundMailbox then set emailMessages to {} set allAccounts to every account set accountsChecked to 0 -- First check iCloud if available repeat with acct in allAccounts if name of acct is "iCloud" then try -- Try to get the inbox for iCloud set inboxMailbox to inbox of acct -- Add messages from this inbox if showUnreadOnly then set acctMessages to (messages of inboxMailbox whose read status is false) else set acctMessages to (messages of inboxMailbox) end if set emailMessages to emailMessages & acctMessages set accountsChecked to accountsChecked + 1 end try exit repeat end if end repeat -- Then check other accounts if needed if accountsChecked is 0 then repeat with acct in allAccounts try -- Try to get the inbox for this account set inboxMailbox to inbox of acct -- Add messages from this inbox if showUnreadOnly then set acctMessages to (messages of inboxMailbox whose read status is false) else set acctMessages to (messages of inboxMailbox) end if set emailMessages to emailMessages & acctMessages end try end repeat end if -- Sort combined messages by date (newest first) set emailMessages to my sortMessagesByDate(emailMessages) set mailboxName to "All Inboxes" end if -- Limit the number of messages if (count of emailMessages) > messageCount then set emailMessages to items 1 thru messageCount of emailMessages end if -- Format the results set accountInfo to "" if not searchAllAccounts and targetAccount is not missing value then set accountInfo to " (" & accountName & ")" end if set emailList to "Recent emails in " & mailboxName & accountInfo & ":" & return & return if (count of emailMessages) is 0 then set emailList to emailList & "No messages found." else repeat with theMessage in emailMessages try set msgSubject to subject of theMessage set msgSender to sender of theMessage set msgDate to date received of theMessage set msgRead to read status of theMessage -- Try to get account name for this message set msgAccount to "" try set msgMailbox to mailbox of theMessage set msgAcct to account of msgMailbox set msgAccount to " [" & name of msgAcct & "]" end try set emailList to emailList & "From: " & msgSender & return set emailList to emailList & "Subject: " & msgSubject & return set emailList to emailList & "Date: " & msgDate & msgAccount & return set emailList to emailList & "Read: " & msgRead & return & return on error errMsg set emailList to emailList & "Error processing message: " & errMsg & return & return end try end repeat end if return emailList end tell -- Helper function to sort messages by date on sortMessagesByDate(messageList) tell application "Mail" set sortedMessages to {} -- Simple bubble sort by date received (newest first) repeat with i from 1 to count of messageList set currentMsg to item i of messageList set currentDate to date received of currentMsg set inserted to false if (count of sortedMessages) is 0 then set sortedMessages to {currentMsg} else repeat with j from 1 to count of sortedMessages set compareMsg to item j of sortedMessages set compareDate to date received of compareMsg if currentDate > compareDate then if j is 1 then set sortedMessages to {currentMsg} & sortedMessages else set sortedMessages to (items 1 thru (j - 1) of sortedMessages) & currentMsg & (items j thru (count of sortedMessages) of sortedMessages) end if set inserted to true exit repeat end if end repeat if not inserted then set sortedMessages to sortedMessages & {currentMsg} end if end if end repeat return sortedMessages end tell end sortMessagesByDate `, }, { name: "get_email", description: "Get a specific email by search criteria from Mail.app", schema: { type: "object", properties: { mailbox: { type: "string", description: "Name of the mailbox to search in (e.g., 'Inbox', 'Sent')", default: "Inbox" }, account: { type: "string", description: "Name of the account to search in (e.g., 'iCloud', 'Gmail', 'Exchange'). If not specified, searches all accounts with preference for iCloud.", default: "iCloud" }, subject: { type: "string", description: "Subject text to search for (partial match)" }, sender: { type: "string", description: "Sender email or name to search for (partial match)" }, dateReceived: { type: "string", description: "Date received to search for (format: YYYY-MM-DD)" }, unreadOnly: { type: "boolean", description: "Only search unread emails if true" }, includeBody: { type: "boolean", description: "Include email body in the result if true", default: false } }, required: [] }, script: (args) => ` set mailboxName to "${args.mailbox || 'Inbox'}" set accountName to "${args.account || 'iCloud'}" set searchSubject to "${args.subject || ''}" set searchSender to "${args.sender || ''}" set searchDate to "${args.dateReceived || ''}" set showUnreadOnly to ${args.unreadOnly ? 'true' : 'false'} set includeBody to ${args.includeBody ? 'true' : 'false'} set searchAllAccounts to ${!args.account ? 'true' : 'false'} tell application "Mail" -- Get all messages if no specific mailbox is found set foundMailbox to false set emailMessages to {} set targetAccount to missing value -- First try to find the specified account if not searchAllAccounts then try set allAccounts to every account repeat with acct in allAccounts if name of acct is accountName then set targetAccount to acct exit repeat end if end repeat end try -- If account not found, set to search all accounts if targetAccount is missing value then set searchAllAccounts to true end if end if -- If specific account is found, search in that account if not searchAllAccounts and targetAccount is not missing value then try set acctMailboxes to every mailbox of targetAccount repeat with m in acctMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat end try else -- Search all accounts, with preference for iCloud set iCloudAccount to missing value set allAccounts to every account -- First look for iCloud account repeat with acct in allAccounts if name of acct is "iCloud" then set iCloudAccount to acct exit repeat end if end repeat -- Try to find the mailbox directly try set allMailboxes to every mailbox repeat with m in allMailboxes if name of m is mailboxName then set targetMailbox to m set foundMailbox to true -- Get messages from the found mailbox if showUnreadOnly then set emailMessages to (messages of targetMailbox whose read status is false) else set emailMessages to (messages of targetMailbox) end if exit repeat end if end repeat end try end if -- Filter messages based on search criteria set filteredMessages to {} repeat with theMessage in emailMessages try set matchesSubject to true set matchesSender to true set matchesDate to true -- Check subject if specified if searchSubject is not "" then set msgSubject to subject of theMessage if msgSubject does not contain searchSubject then set matchesSubject to false end if end if -- Check sender if specified if searchSender is not "" then set msgSender to sender of theMessage if msgSender does not contain searchSender then set matchesSender to false end if end if -- Check date if specified if searchDate is not "" then set msgDate to date received of theMessage set msgDateString to (year of msgDate as string) & "-" & my padNumber(month of msgDate as integer) & "-" & my padNumber(day of msgDate as integer) if msgDateString is not searchDate then set matchesDate to false end if end if -- Add to filtered list if all criteria match if matchesSubject and matchesSender and matchesDate then set end of filteredMessages to theMessage end if end try end repeat -- Format the results set emailList to "Search results:" & return & return if (count of filteredMessages) is 0 then set emailList to emailList & "No matching emails found." else repeat with theMessage in filteredMessages try set msgSubject to subject of theMessage set msgSender to sender of theMessage set msgDate to date received of theMessage set msgRead to read status of theMessage -- Try to get account name for this message set msgAccount to "" try set msgMailbox to mailbox of theMessage set msgAcct to account of msgMailbox set msgAccount to " [" & name of msgAcct & "]" end try set emailList to emailList & "From: " & msgSender & return set emailList to emailList & "Subject: " & msgSubject & return set emailList to emailList & "Date: " & msgDate & msgAccount & return set emailList to emailList & "Read: " & msgRead & return -- Include body if requested if includeBody then set msgContent to content of theMessage set emailList to emailList & "Content: " & return & msgContent & return end if set emailList to emailList & return on error errMsg set emailList to emailList & "Error processing message: " & errMsg & return & return end try end repeat end if return emailList end tell -- Helper function to pad numbers with leading zero if needed on padNumber(num) if num < 10 then return "0" & num else return num as string end if end padNumber `, }, ], }; ```