#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const server = new Server(
{
name: 'mcp-mac',
version: '0.0.1',
},
{
capabilities: {
tools: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'discover_apps',
description: 'Discover AppleScript capabilities of a macOS application',
inputSchema: {
type: 'object',
properties: {
app_name: {
type: 'string',
description: 'Name of the macOS application to discover',
},
method: {
type: 'string',
description: 'Discovery method: basic, dictionary, properties, system_events, comprehensive',
},
destination: {
type: 'string',
description: 'Directory path to save discovery results',
},
},
required: ['app_name', 'method', 'destination'],
},
},
{
name: 'finder_get_selection',
description: 'Get currently selected files/folders in Finder',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'finder_get_current_folder',
description: 'Get path of currently viewed folder in frontmost Finder window',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'mail_get_accounts',
description: 'Get list of all Mail account names',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'mail_get_inbox_count',
description: 'Get unread message count in inbox',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'mail_get_total_inbox_count',
description: 'Get total message count in inbox',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'contacts_search_people',
description: 'Search for people by name in Contacts',
inputSchema: {
type: 'object',
properties: {
search_term: {
type: 'string',
description: 'Name or part of name to search for',
},
},
required: ['search_term'],
},
},
{
name: 'contacts_get_person_info',
description: 'Get detailed information for a specific person',
inputSchema: {
type: 'object',
properties: {
person_name: {
type: 'string',
description: 'Full name of the person to get info for',
},
},
required: ['person_name'],
},
},
{
name: 'reminders_get_lists',
description: 'Get all reminder lists with reminder counts',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'reminders_get_incomplete_reminders',
description: 'Get incomplete reminders across all lists',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Maximum number of reminders to return (default: 10)',
},
},
},
},
{
name: 'notes_get_folders',
description: 'Get all note folders with note counts',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'notes_get_recent_notes',
description: 'Get recently modified notes',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Maximum number of notes to return (default: 10)',
},
},
},
},
{
name: 'notes_search_notes',
description: 'Search notes by title or content',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query for note title or content',
},
},
required: ['query'],
},
},
{
name: 'notes_create_note',
description: 'Create new note with title and content',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Note title',
},
content: {
type: 'string',
description: 'Note content/body',
},
folder: {
type: 'string',
description: 'Target folder name (optional, defaults to "Notes")',
},
},
required: ['title', 'content'],
},
},
{
name: 'textedit_get_documents',
description: 'Get list of open TextEdit documents',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'textedit_create_document',
description: 'Create new TextEdit document with optional content',
inputSchema: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'Optional initial content for the document',
},
},
},
},
{
name: 'calendar_get_calendars',
description: 'Get all calendars with event counts',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'calendar_create_event',
description: 'Create new calendar event',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Event title/summary',
},
start_datetime: {
type: 'string',
description: 'Start date and time (YYYY-MM-DD HH:MM format)',
},
end_datetime: {
type: 'string',
description: 'End date and time (YYYY-MM-DD HH:MM format)',
},
calendar: {
type: 'string',
description: 'Target calendar name (optional, defaults to "Calendar")',
},
notes: {
type: 'string',
description: 'Event notes/description (optional)',
},
},
required: ['title', 'start_datetime', 'end_datetime'],
},
},
{
name: 'calendar_get_today_events',
description: 'Get today\'s events across all calendars',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'calendar_get_upcoming_events',
description: 'Get upcoming events in date range',
inputSchema: {
type: 'object',
properties: {
days: {
type: 'number',
description: 'Number of days ahead to look (default: 7)',
},
},
},
},
{
name: 'workflow_contact_to_textedit',
description: 'Get contact information and create formatted TextEdit document',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Person name to look up',
},
title: {
type: 'string',
description: 'Document title (optional)',
},
},
required: ['name'],
},
},
{
name: 'mail_create_message',
description: 'Create new email message with recipients, subject, and body',
inputSchema: {
type: 'object',
properties: {
to: {
type: 'string',
description: 'Recipient email address',
},
subject: {
type: 'string',
description: 'Email subject',
},
body: {
type: 'string',
description: 'Email body content',
},
cc: {
type: 'string',
description: 'CC recipient email address (optional)',
},
},
required: ['to', 'subject', 'body'],
},
},
{
name: 'mail_send_message',
description: 'Send the most recently created message',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'reminders_create_reminder',
description: 'Create new reminder with title, optional due date and list',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Reminder title',
},
due_date: {
type: 'string',
description: 'Due date in format YYYY-MM-DD (optional)',
},
list: {
type: 'string',
description: 'Target reminder list name (optional, defaults to "Reminders")',
},
notes: {
type: 'string',
description: 'Reminder notes/body (optional)',
},
},
required: ['title'],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'discover_apps':
const appName = (args?.app_name as string) || '';
const method = (args?.method as string) || '';
const destination = (args?.destination as string) || '';
// Validate required parameters
if (!appName || !method || !destination) {
return {
content: [
{
type: 'text',
text: 'Error: app_name, method, and destination are all required',
},
],
};
}
// Validate method parameter
const validMethods = ['basic', 'dictionary', 'properties', 'system_events', 'comprehensive'];
if (!validMethods.includes(method)) {
return {
content: [
{
type: 'text',
text: `Error: method must be one of: ${validMethods.join(', ')}`,
},
],
};
}
// Check if destination directory exists
try {
const fs = await import('fs');
const path = await import('path');
if (!fs.existsSync(destination)) {
return {
content: [
{
type: 'text',
text: `Error: destination directory does not exist: ${destination}`,
},
],
};
}
if (!fs.statSync(destination).isDirectory()) {
return {
content: [
{
type: 'text',
text: `Error: destination is not a directory: ${destination}`,
},
],
};
}
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error checking destination directory: ${error.message}`,
},
],
};
}
// Execute discovery based on method
try {
let results = '';
const fs = await import('fs');
const path = await import('path');
if (method === 'basic') {
// Basic availability test
const appleScript_name = `on run argv
set appName to item 1 of argv
tell application appName to get name
end run`;
const nameCommand = `osascript -e '${appleScript_name}' -- "${appName}"`;
const { stdout: nameOutput, stderr: nameError } = await execAsync(nameCommand);
results = `Discovery for ${appName} (Basic Method):\n`;
results += `Date: ${new Date().toISOString()}\n`;
results += `Command: ${nameCommand}\n`;
results += `Output: ${nameOutput.trim()}\n`;
results += `Error: ${nameError.trim() || 'none'}\n\n`;
// App-specific state verification tests
if (appName.toLowerCase() === 'finder') {
const selectionCommand = `osascript -e 'tell application "Finder" to get selection as alias list'`;
try {
const { stdout: selOutput, stderr: selError } = await execAsync(selectionCommand);
results += `State Test - Selection:\n`;
results += `Command: ${selectionCommand}\n`;
results += `Output: ${selOutput.trim()}\n`;
results += `Error: ${selError.trim() || 'none'}\n`;
results += `Verification: Check if Finder selection matches output\n\n`;
} catch (selErr: any) {
results += `State Test - Selection:\n`;
results += `Command: ${selectionCommand}\n`;
results += `Error: ${selErr.message}\n\n`;
}
}
if (appName.toLowerCase() === 'contacts') {
const countCommand = `osascript -e 'tell application "Contacts" to get count of people'`;
try {
const { stdout: countOutput, stderr: countError } = await execAsync(countCommand);
results += `State Test - Count:\n`;
results += `Command: ${countCommand}\n`;
results += `Output: ${countOutput.trim()}\n`;
results += `Error: ${countError.trim() || 'none'}\n`;
results += `Verification: Check if Contacts app shows same count\n\n`;
} catch (countErr: any) {
results += `State Test - Count:\n`;
results += `Command: ${countCommand}\n`;
results += `Error: ${countErr.message}\n\n`;
}
}
if (appName.toLowerCase() === 'mail') {
const accountsCommand = `osascript -e 'tell application "Mail" to get name of every account'`;
try {
const { stdout: accountsOutput, stderr: accountsError } = await execAsync(accountsCommand);
results += `State Test - Accounts:\n`;
results += `Command: ${accountsCommand}\n`;
results += `Output: ${accountsOutput.trim()}\n`;
results += `Error: ${accountsError.trim() || 'none'}\n`;
results += `Verification: Check if Mail app shows same accounts\n\n`;
} catch (accountsErr: any) {
results += `State Test - Accounts:\n`;
results += `Command: ${accountsCommand}\n`;
results += `Error: ${accountsErr.message}\n\n`;
}
}
} else {
// Placeholder for other methods
results = `Discovery method '${method}' not yet implemented for ${appName}`;
}
// Write results to file
const filename = `${appName.toLowerCase()}_${method}.txt`;
const filepath = path.join(destination, filename);
// Get preview of file content (first line)
const preview = results.split('\n')[0];
const toolVersion = new Date().toISOString();
let status = 'SUCCESS';
if (method !== 'basic' && results.includes('not yet implemented')) {
status = 'PARTIAL';
}
// Append tool response metadata to file for testing verification
const toolResponse = `\n---\nTOOL RESPONSE:\nSTATUS: ${status}\nMETHOD: ${method}\nAPP: ${appName}\nTOOL_VERSION: ${toolVersion}\nFILE: ${filepath}\nPREVIEW: "${preview}"\n${status === 'SUCCESS' ? 'Discovery completed successfully.' : 'Method not yet implemented - placeholder saved.'}`;
const fullContent = results + toolResponse;
fs.writeFileSync(filepath, fullContent);
return {
content: [
{
type: 'text',
text: `STATUS: ${status}
METHOD: ${method}
APP: ${appName}
TOOL_VERSION: ${toolVersion}
FILE: ${filepath}
PREVIEW: "${preview}"
${status === 'SUCCESS' ? 'Discovery completed successfully.' : 'Method not yet implemented - placeholder saved.'}`,
},
],
};
} catch (error: any) {
const toolVersion = new Date().toISOString();
return {
content: [
{
type: 'text',
text: `STATUS: ERROR
METHOD: ${method}
APP: ${appName}
TOOL_VERSION: ${toolVersion}
ERROR: ${error.message}`,
},
],
};
}
try {
// Basic availability test
const nameCommand = `osascript -e 'tell application "${appName}" to get name'`;
const { stdout: nameOutput, stderr: nameError } = await execAsync(nameCommand);
let results = `Discovery for ${appName}:\n`;
results += `Basic Test - Command: ${nameCommand}\n`;
results += `Output: ${nameOutput.trim()}\n`;
results += `Error: ${nameError.trim() || 'none'}\n\n`;
// State verification tests based on app
if (appName.toLowerCase() === 'finder') {
const selectionCommand = `osascript -e 'tell application "Finder" to get selection as alias list'`;
try {
const { stdout: selOutput, stderr: selError } = await execAsync(selectionCommand);
results += `State Test - Selection Command: ${selectionCommand}\n`;
results += `Selection Output: ${selOutput.trim()}\n`;
results += `Selection Error: ${selError.trim() || 'none'}\n`;
results += `Verification: Manually check if Finder selection matches output\n\n`;
} catch (selErr: any) {
results += `State Test - Selection Command: ${selectionCommand}\n`;
results += `Selection Error: ${selErr.message}\n\n`;
}
const folderCommand = `osascript -e 'tell application "Finder" to get POSIX path of (target of front window as alias)'`;
try {
const { stdout: folderOutput, stderr: folderError } = await execAsync(folderCommand);
results += `State Test - Folder Command: ${folderCommand}\n`;
results += `Folder Output: ${folderOutput.trim()}\n`;
results += `Folder Error: ${folderError.trim() || 'none'}\n`;
results += `Verification: Manually check if current Finder folder matches output\n`;
} catch (folderErr: any) {
results += `State Test - Folder Command: ${folderCommand}\n`;
results += `Folder Error: ${folderErr.message}\n`;
}
}
if (appName.toLowerCase() === 'contacts') {
const countCommand = `osascript -e 'tell application "Contacts" to get count of people'`;
try {
const { stdout: countOutput, stderr: countError } = await execAsync(countCommand);
results += `State Test - Count Command: ${countCommand}\n`;
results += `Count Output: ${countOutput.trim()}\n`;
results += `Count Error: ${countError.trim() || 'none'}\n`;
results += `Verification: Manually check if Contacts app shows same number of people\n`;
} catch (countErr: any) {
results += `State Test - Count Command: ${countCommand}\n`;
results += `Count Error: ${countErr.message}\n`;
}
}
if (appName.toLowerCase() === 'mail') {
const accountsCommand = `osascript -e 'tell application "Mail" to get name of every account'`;
try {
const { stdout: accountsOutput, stderr: accountsError } = await execAsync(accountsCommand);
results += `State Test - Accounts Command: ${accountsCommand}\n`;
results += `Accounts Output: ${accountsOutput.trim()}\n`;
results += `Accounts Error: ${accountsError.trim() || 'none'}\n`;
results += `Verification: Manually check if Mail app shows same account names\n`;
} catch (accountsErr: any) {
results += `State Test - Accounts Command: ${accountsCommand}\n`;
results += `Accounts Error: ${accountsErr.message}\n`;
}
}
return {
content: [
{
type: 'text',
text: results,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Discovery for ${appName}:\nBasic Command: osascript -e 'on run argv...' -- "${appName}"\nError: ${error.message}`,
},
],
};
}
case 'finder_get_selection':
try {
const command = `osascript -e 'tell application "Finder" to get selection as alias list'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting Finder selection: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No files selected in Finder',
},
],
};
}
// Process AppleScript aliases to user-friendly names
const aliases = output.split(', ');
const friendlyNames = aliases.map(alias => {
// Extract filename from alias path
const pathParts = alias.replace('alias ', '').split(':');
// Check if it's a directory (ends with colon, creates empty last element)
const isDirectory = alias.endsWith(':');
let filename;
if (isDirectory) {
// For directories: take second-to-last element (last is empty from trailing colon)
filename = pathParts[pathParts.length - 2];
} else {
// For files: take last element
filename = pathParts[pathParts.length - 1];
}
// Remove .localized suffix if present
if (filename && filename.endsWith('.localized')) {
filename = filename.replace('.localized', '');
}
return isDirectory ? `${filename}/` : filename;
});
return {
content: [
{
type: 'text',
text: `Selected items (${friendlyNames.length}):\n${friendlyNames.join('\n')}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing Finder selection command: ${error.message}`,
},
],
};
}
case 'finder_get_current_folder':
try {
const command = `osascript -e 'tell application "Finder" to get POSIX path of (target of front window as alias)'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting current folder: ${stderr.trim()}`,
},
],
};
}
const folderPath = stdout.trim();
return {
content: [
{
type: 'text',
text: `Current folder: ${folderPath}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing current folder command: ${error.message}`,
},
],
};
}
case 'mail_get_accounts':
try {
const command = `osascript -e 'tell application "Mail" to get name of every account'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting Mail accounts: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No Mail accounts found',
},
],
};
}
// Parse comma-separated account names
const accounts = output.split(', ').map(name => name.trim());
return {
content: [
{
type: 'text',
text: `Mail accounts (${accounts.length}):\n${accounts.join('\n')}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing Mail accounts command: ${error.message}`,
},
],
};
}
case 'mail_get_inbox_count':
try {
const command = `osascript -e 'tell application "Mail" to get unread count of inbox'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting inbox count: ${stderr.trim()}`,
},
],
};
}
const unreadCount = parseInt(stdout.trim());
return {
content: [
{
type: 'text',
text: `Unread messages in inbox: ${unreadCount}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing inbox count command: ${error.message}`,
},
],
};
}
case 'mail_get_total_inbox_count':
try {
const command = `osascript -e 'tell application "Mail" to get count of messages of inbox'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting total inbox count: ${stderr.trim()}`,
},
],
};
}
const totalCount = parseInt(stdout.trim());
return {
content: [
{
type: 'text',
text: `Total messages in inbox: ${totalCount}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing total inbox count command: ${error.message}`,
},
],
};
}
case 'contacts_search_people':
try {
const searchTerm = (args?.search_term as string) || '';
if (!searchTerm) {
return {
content: [
{
type: 'text',
text: 'Error: search_term is required',
},
],
};
}
const command = `osascript -e 'on run argv
set searchTerm to item 1 of argv
tell application "Contacts" to get name of every person whose name contains searchTerm
end run' -- "${searchTerm}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error searching contacts: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: `No contacts found matching "${searchTerm}"`,
},
],
};
}
// Parse comma-separated names
const names = output.split(', ').map(name => name.trim());
return {
content: [
{
type: 'text',
text: `Found ${names.length} contact(s) matching "${searchTerm}":\n${names.join('\n')}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing contacts search command: ${error.message}`,
},
],
};
}
case 'contacts_get_person_info':
try {
const personName = (args?.person_name as string) || '';
if (!personName) {
return {
content: [
{
type: 'text',
text: 'Error: person_name is required',
},
],
};
}
const command = `osascript -e 'on run argv
set personName to item 1 of argv
tell application "Contacts"
set thePerson to first person whose name is personName
set emailList to {}
repeat with anEmail in emails of thePerson
set end of emailList to (value of anEmail & " (" & label of anEmail & ")")
end repeat
set phoneList to {}
repeat with aPhone in phones of thePerson
set end of phoneList to (value of aPhone & " (" & label of aPhone & ")")
end repeat
return "Name: " & name of thePerson & "\\nCompany: " & organization of thePerson & "\\nJob Title: " & job title of thePerson & "\\nEmails:\\n" & (emailList as string) & "\\nPhones:\\n" & (phoneList as string)
end tell
end run' -- "${personName}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting person info: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: `No contact found with name "${personName}"`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `Contact info for "${personName}":\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing person info command: ${error.message}`,
},
],
};
}
case 'reminders_get_lists':
try {
const command = `osascript -e 'tell application "Reminders"
set listInfo to {}
repeat with aList in lists
set listName to name of aList
set reminderCount to count of reminders in aList
set end of listInfo to (listName & ": " & reminderCount & " reminders")
end repeat
return listInfo as string
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting reminder lists: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No reminder lists found',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Reminder Lists:\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing reminders lists command: ${error.message}`,
},
],
};
}
case 'reminders_get_incomplete_reminders':
try {
const limit = (args?.limit as number) || 10;
const command = `osascript -e 'on run argv
set numLimit to (item 1 of argv) as number
tell application "Reminders"
set incompleteReminders to {}
set reminderCount to 0
repeat with aList in lists
set listName to name of aList
repeat with aReminder in reminders of aList
if not completed of aReminder and reminderCount < numLimit then
set reminderName to name of aReminder
set reminderBody to body of aReminder
if reminderBody is missing value then set reminderBody to ""
set end of incompleteReminders to (reminderName & " (List: " & listName & ")")
set reminderCount to reminderCount + 1
end if
end repeat
end repeat
return incompleteReminders as string
end tell
end run' -- ${limit}`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting incomplete reminders: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No incomplete reminders found',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Incomplete Reminders (limit: ${limit}):\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing incomplete reminders command: ${error.message}`,
},
],
};
}
case 'notes_get_folders':
try {
const command = `osascript -e 'tell application "Notes"
set folderInfo to {}
repeat with aFolder in folders
set folderName to name of aFolder
set noteCount to count of notes in aFolder
set end of folderInfo to (folderName & ": " & noteCount & " notes")
end repeat
return folderInfo as string
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting note folders: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No note folders found',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Note Folders:\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing notes folders command: ${error.message}`,
},
],
};
}
case 'notes_get_recent_notes':
try {
const limit = (args?.limit as number) || 10;
const command = `osascript -e 'on run argv
set numLimit to (item 1 of argv) as number
tell application "Notes"
set recentNotes to {}
set noteCount to 0
repeat with aNote in notes
if noteCount < numLimit then
set noteName to name of aNote
set modDate to modification date of aNote
set end of recentNotes to (noteName & " (Modified: " & (modDate as string) & ")")
set noteCount to noteCount + 1
end if
end repeat
return recentNotes as string
end tell
end run' -- ${limit}`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting recent notes: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No notes found',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Recent Notes (limit: ${limit}):\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing recent notes command: ${error.message}`,
},
],
};
}
case 'notes_search_notes':
try {
const query = (args?.query as string) || '';
if (!query) {
return {
content: [
{
type: 'text',
text: 'Error: query is required',
},
],
};
}
const command = `osascript -e 'on run argv
set queryString to item 1 of argv
tell application "Notes"
set matchingNotes to {}
repeat with aNote in notes
set noteName to name of aNote
if noteName contains queryString then
set modDate to modification date of aNote
set end of matchingNotes to (noteName & " (Modified: " & (modDate as string) & ")")
end if
end repeat
return matchingNotes as string
end tell
end run' -- "${query}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error searching notes: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: `No notes found matching "${query}"`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `Notes matching "${query}":\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing notes search command: ${error.message}`,
},
],
};
}
case 'notes_create_note':
try {
const title = (args?.title as string) || '';
const content = (args?.content as string) || '';
const folder = (args?.folder as string) || 'Notes';
if (!title || !content) {
return {
content: [
{
type: 'text',
text: 'Error: title and content are required',
},
],
};
}
const command = `osascript -e 'on run argv
set noteTitle to item 1 of argv
set noteContent to item 2 of argv
set targetFolderName to item 3 of argv
tell application "Notes"
set targetFolder to folder targetFolderName
set newNote to make new note in targetFolder with properties {name:noteTitle, body:noteContent}
return "Note created: " & name of newNote & " in folder: " & name of targetFolder
end tell
end run' -- "${title}" "${content}" "${folder}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error creating note: ${stderr.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: stdout.trim(),
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing note creation command: ${error.message}`,
},
],
};
}
case 'textedit_get_documents':
try {
const command = `osascript -e 'tell application "TextEdit"
set docInfo to {}
repeat with aDoc in documents
set docName to name of aDoc
set docModified to modified of aDoc
set modifiedText to ""
if docModified then set modifiedText to " (modified)"
set end of docInfo to (docName & modifiedText)
end repeat
return docInfo as string
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting TextEdit documents: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No TextEdit documents are currently open',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Open TextEdit Documents:\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing TextEdit documents command: ${error.message}`,
},
],
};
}
case 'textedit_create_document':
try {
const content = (args?.content as string) || '';
const command = `osascript -e 'on run argv
set docContent to item 1 of argv
tell application "TextEdit"
set newDoc to make new document
if docContent is not "" then
set text of newDoc to docContent
end if
set docName to name of newDoc
return "Created document: " & docName
end tell
end run' -- "${content}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error creating TextEdit document: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'Document created but name could not be retrieved',
},
],
};
}
return {
content: [
{
type: 'text',
text: output,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing TextEdit create document command: ${error.message}`,
},
],
};
}
case 'calendar_get_calendars':
try {
const command = `osascript -e 'tell application "Calendar"
set calendarInfo to {}
repeat with aCal in calendars
set calName to title of aCal
set eventCount to count of events in aCal
set end of calendarInfo to (calName & ": " & eventCount & " events")
end repeat
return calendarInfo as string
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting calendars: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No calendars found',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Calendars:\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing calendars command: ${error.message}`,
},
],
};
}
case 'calendar_get_today_events':
try {
const command = `osascript -e 'tell application "Calendar"
set todayEvents to {}
set todayStart to (current date)
set time of todayStart to 0
set todayEnd to todayStart + (24 * 60 * 60) - 1
repeat with aCal in calendars
set calName to title of aCal
repeat with anEvent in events of aCal
set eventStart to start date of anEvent
if eventStart ≥ todayStart and eventStart ≤ todayEnd then
set eventSummary to summary of anEvent
set eventTime to (eventStart as string)
set end of todayEvents to (eventSummary & " (" & calName & ") - " & eventTime)
end if
end repeat
end repeat
return todayEvents as string
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting today's events: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: 'No events scheduled for today',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Today's Events:\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing today's events command: ${error.message}`,
},
],
};
}
case 'calendar_get_upcoming_events':
try {
const days = (args?.days as number) || 7;
const command = `osascript -e 'on run argv
set numDays to (item 1 of argv) as number
tell application "Calendar"
set upcomingEvents to {}
set startDate to (current date)
set endDate to startDate + (numDays * 24 * 60 * 60)
repeat with aCal in calendars
set calName to title of aCal
repeat with anEvent in events of aCal
set eventStart to start date of anEvent
if eventStart ≥ startDate and eventStart ≤ endDate then
set eventSummary to summary of anEvent
set eventTime to (eventStart as string)
set end of upcomingEvents to (eventSummary & " (" & calName & ") - " & eventTime)
end if
end repeat
end repeat
return upcomingEvents as string
end tell
end run' -- ${days}`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting upcoming events: ${stderr.trim()}`,
},
],
};
}
const output = stdout.trim();
if (!output || output === '') {
return {
content: [
{
type: 'text',
text: `No events scheduled in the next ${days} days`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `Upcoming Events (next ${days} days):\n${output}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing upcoming events command: ${error.message}`,
},
],
};
}
case 'calendar_create_event':
try {
const title = (args?.title as string) || '';
const startDateTime = (args?.start_datetime as string) || '';
const endDateTime = (args?.end_datetime as string) || '';
const calendar = (args?.calendar as string) || 'Calendar';
const notes = (args?.notes as string) || '';
if (!title || !startDateTime || !endDateTime) {
return {
content: [
{
type: 'text',
text: 'Error: title, start_datetime, and end_datetime are required',
},
],
};
}
// Helper to parse "YYYY-MM-DD HH:MM" and return components
const parseDate = (dateStr: string) => {
const parts = dateStr.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/);
if (!parts) return null;
return {
year: parseInt(parts[1]),
month: parseInt(parts[2]),
day: parseInt(parts[3]),
hours: parseInt(parts[4]),
minutes: parseInt(parts[5])
};
};
const start = parseDate(startDateTime);
const end = parseDate(endDateTime);
if (!start || !end) {
return {
content: [
{
type: 'text',
text: 'Error: Dates must be in format YYYY-MM-DD HH:MM',
},
],
};
}
const command = `osascript -e 'on run argv
set calendarName to item 1 of argv
set eventTitle to item 2 of argv
set eventNotes to item 3 of argv
-- Parse Start Date Components
set startYear to (item 4 of argv) as integer
set startMonth to (item 5 of argv) as integer
set startDay to (item 6 of argv) as integer
set startHour to (item 7 of argv) as integer
set startMinute to (item 8 of argv) as integer
-- Parse End Date Components
set endYear to (item 9 of argv) as integer
set endMonth to (item 10 of argv) as integer
set endDay to (item 11 of argv) as integer
set endHour to (item 12 of argv) as integer
set endMinute to (item 13 of argv) as integer
tell application "Calendar"
set targetCalendar to calendar calendarName
-- Construct Start Date
set startDate to current date
set year of startDate to startYear
set month of startDate to startMonth
set day of startDate to startDay
set time of startDate to (startHour * 3600 + startMinute * 60)
-- Construct End Date
set endDate to current date
set year of endDate to endYear
set month of endDate to endMonth
set day of endDate to endDay
set time of endDate to (endHour * 3600 + endMinute * 60)
set newEvent to make new event in targetCalendar with properties {summary:eventTitle, start date:startDate, end date:endDate}
if eventNotes is not "" then
set description of newEvent to eventNotes
end if
return "Event created: " & summary of newEvent & " in calendar: " & title of targetCalendar
end tell
end run' -- "${calendar}" "${title}" "${notes}" ${start.year} ${start.month} ${start.day} ${start.hours} ${start.minutes} ${end.year} ${end.month} ${end.day} ${end.hours} ${end.minutes}`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error creating event: ${stderr.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: stdout.trim(),
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing event creation command: ${error.message}`,
},
],
};
}
case 'workflow_contact_to_textedit':
try {
const personName = (args?.name as string) || '';
const docTitle = (args?.title as string) || `Contact: ${personName}`;
if (!personName) {
return {
content: [
{
type: 'text',
text: 'Error: name is required',
},
],
};
}
// Step 1: Get contact information
const contactCommand = `osascript -e 'on run argv
set personNameToFind to item 1 of argv
tell application "Contacts"
set thePerson to first person whose name is personNameToFind
set emailList to {}
repeat with anEmail in emails of thePerson
set end of emailList to (value of anEmail & " (" & label of anEmail & ")")
end repeat
set phoneList to {}
repeat with aPhone in phones of thePerson
set end of phoneList to (value of aPhone & " (" & label of aPhone & ")")
end repeat
return "Name: " & name of thePerson & "\\nCompany: " & organization of thePerson & "\\nJob Title: " & job title of thePerson & "\\nEmails:\\n" & (emailList as string) & "\\nPhones:\\n" & (phoneList as string)
end tell
end run' -- "${personName}"`;
const { stdout: contactInfo, stderr: contactError } = await execAsync(contactCommand);
if (contactError.trim()) {
return {
content: [
{
type: 'text',
text: `Error getting contact info: ${contactError.trim()}`,
},
],
};
}
if (!contactInfo.trim()) {
return {
content: [
{
type: 'text',
text: `No contact found with name "${personName}"`,
},
],
};
}
// Step 2: Format data for document
const formattedContent = `${docTitle}
==================
${contactInfo.trim()}
Generated: ${new Date().toLocaleString()}`;
// Step 3: Create TextEdit document
const docCommand = `osascript -e 'on run argv
set docContent to item 1 of argv
tell application "TextEdit"
set newDoc to make new document
set text of newDoc to docContent
set docName to name of newDoc
return "Created document: " & docName
end tell
end run' -- "${formattedContent}"`;
const { stdout: docResult, stderr: docError } = await execAsync(docCommand);
if (docError.trim()) {
return {
content: [
{
type: 'text',
text: `Error creating document: ${docError.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `Workflow completed: ${docResult.trim()}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing workflow: ${error.message}`,
},
],
};
}
case 'mail_create_message':
try {
const to = (args?.to as string) || '';
const subject = (args?.subject as string) || '';
const body = (args?.body as string) || '';
const cc = (args?.cc as string) || '';
if (!to || !subject || !body) {
return {
content: [
{
type: 'text',
text: 'Error: to, subject, and body are required',
},
],
};
}
const command = `osascript -e 'on run argv
set toAddress to item 1 of argv
set mailSubject to item 2 of argv
set mailBody to item 3 of argv
set ccAddress to item 4 of argv
tell application "Mail"
set newMessage to make new outgoing message
set subject of newMessage to mailSubject
set content of newMessage to mailBody
make new to recipient at end of to recipients of newMessage with properties {address:toAddress}
if ccAddress is not "" then
make new cc recipient at end of cc recipients of newMessage with properties {address:ccAddress}
end if
return "Message created - Subject: " & subject of newMessage & ", To: " & toAddress
end tell
end run' -- "${to}" "${subject}" "${body}" "${cc}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error creating message: ${stderr.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: stdout.trim(),
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing mail create command: ${error.message}`,
},
],
};
}
case 'mail_send_message':
try {
const command = `osascript -e 'tell application "Mail"
set frontMessage to item 1 of (get outgoing messages)
send frontMessage
return "Message sent successfully"
end tell'`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error sending message: ${stderr.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: stdout.trim(),
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing mail send command: ${error.message}`,
},
],
};
}
case 'reminders_create_reminder':
try {
const title = (args?.title as string) || '';
const dueDate = (args?.due_date as string) || '';
const listName = (args?.list as string) || 'Reminders';
const notes = (args?.notes as string) || '';
if (!title) {
return {
content: [
{
type: 'text',
text: 'Error: title is required',
},
],
};
}
const command = `osascript -e 'on run {listName, reminderTitle, reminderNotes, dueDateStr}
-- Parse date OUTSIDE the tell block
set y to 0
set m to 0
set d to 0
set hasDate to false
if dueDateStr is not "" then
set oldDelims to AppleScript'"'"'s text item delimiters
set AppleScript'"'"'s text item delimiters to "-"
set dateParts to text items of dueDateStr
set AppleScript'"'"'s text item delimiters to oldDelims
if (count of dateParts) is 3 then
set y to item 1 of dateParts as integer
set m to item 2 of dateParts as integer
set d to item 3 of dateParts as integer
set hasDate to true
end if
end if
tell application "Reminders"
set targetList to list listName
set newReminder to make new reminder in targetList
set name of newReminder to reminderTitle
if reminderNotes is not "" then
set body of newReminder to reminderNotes
end if
if hasDate then
set myDate to current date
set year of myDate to y
set month of myDate to m
set day of myDate to d
set time of myDate to 0
set due date of newReminder to myDate
end if
return "Reminder created: " & name of newReminder & " in list: " & name of targetList
end tell
end run' -- "${listName}" "${title}" "${notes}" "${dueDate}"`;
const { stdout, stderr } = await execAsync(command);
if (stderr.trim()) {
return {
content: [
{
type: 'text',
text: `Error executing reminders create command: ${stderr.trim()}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: stdout.trim(),
},
],
};
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error executing reminder creation command: ${error.message}`,
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});