This is page 2 of 2. Use http://codebase.md/dhravya/apple-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── FUNDING.yml
├── .gitignore
├── apple-mcp.dxt
├── bun.lockb
├── CLAUDE.md
├── index.ts
├── LICENSE
├── manifest.json
├── package.json
├── README.md
├── TEST_README.md
├── test-runner.ts
├── tests
│ ├── fixtures
│ │ └── test-data.ts
│ ├── helpers
│ │ └── test-utils.ts
│ ├── integration
│ │ ├── calendar.test.ts
│ │ ├── contacts-simple.test.ts
│ │ ├── contacts.test.ts
│ │ ├── mail.test.ts
│ │ ├── maps.test.ts
│ │ ├── messages.test.ts
│ │ ├── notes.test.ts
│ │ └── reminders.test.ts
│ └── setup.ts
├── tools.ts
├── tsconfig.json
└── utils
├── calendar.ts
├── contacts.ts
├── mail.ts
├── maps.ts
├── message.ts
├── notes.ts
├── reminders.ts
└── web-search.ts
```
# Files
--------------------------------------------------------------------------------
/utils/notes.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { runAppleScript } from "run-applescript";
2 |
3 | // Configuration
4 | const CONFIG = {
5 | // Maximum notes to process (to avoid performance issues)
6 | MAX_NOTES: 50,
7 | // Maximum content length for previews
8 | MAX_CONTENT_PREVIEW: 200,
9 | // Timeout for operations
10 | TIMEOUT_MS: 8000,
11 | };
12 |
13 | type Note = {
14 | name: string;
15 | content: string;
16 | creationDate?: Date;
17 | modificationDate?: Date;
18 | };
19 |
20 | type CreateNoteResult = {
21 | success: boolean;
22 | note?: Note;
23 | message?: string;
24 | folderName?: string;
25 | usedDefaultFolder?: boolean;
26 | };
27 |
28 | /**
29 | * Check if Notes app is accessible
30 | */
31 | async function checkNotesAccess(): Promise<boolean> {
32 | try {
33 | const script = `
34 | tell application "Notes"
35 | return name
36 | end tell`;
37 |
38 | await runAppleScript(script);
39 | return true;
40 | } catch (error) {
41 | console.error(
42 | `Cannot access Notes app: ${error instanceof Error ? error.message : String(error)}`,
43 | );
44 | return false;
45 | }
46 | }
47 |
48 | /**
49 | * Request Notes app access and provide instructions if not available
50 | */
51 | async function requestNotesAccess(): Promise<{ hasAccess: boolean; message: string }> {
52 | try {
53 | // First check if we already have access
54 | const hasAccess = await checkNotesAccess();
55 | if (hasAccess) {
56 | return {
57 | hasAccess: true,
58 | message: "Notes access is already granted."
59 | };
60 | }
61 |
62 | // If no access, provide clear instructions
63 | return {
64 | hasAccess: false,
65 | message: "Notes access is required but not granted. Please:\n1. Open System Settings > Privacy & Security > Automation\n2. Find your terminal/app in the list and enable 'Notes'\n3. Restart your terminal and try again\n4. If the option is not available, run this command again to trigger the permission dialog"
66 | };
67 | } catch (error) {
68 | return {
69 | hasAccess: false,
70 | message: `Error checking Notes access: ${error instanceof Error ? error.message : String(error)}`
71 | };
72 | }
73 | }
74 |
75 | /**
76 | * Get all notes from Notes app (limited for performance)
77 | */
78 | async function getAllNotes(): Promise<Note[]> {
79 | try {
80 | const accessResult = await requestNotesAccess();
81 | if (!accessResult.hasAccess) {
82 | throw new Error(accessResult.message);
83 | }
84 |
85 | const script = `
86 | tell application "Notes"
87 | set notesList to {}
88 | set noteCount to 0
89 |
90 | -- Get all notes from all folders
91 | set allNotes to notes
92 |
93 | repeat with i from 1 to (count of allNotes)
94 | if noteCount >= ${CONFIG.MAX_NOTES} then exit repeat
95 |
96 | try
97 | set currentNote to item i of allNotes
98 | set noteName to name of currentNote
99 | set noteContent to plaintext of currentNote
100 |
101 | -- Limit content for preview
102 | if (length of noteContent) > ${CONFIG.MAX_CONTENT_PREVIEW} then
103 | set noteContent to (characters 1 thru ${CONFIG.MAX_CONTENT_PREVIEW} of noteContent) as string
104 | set noteContent to noteContent & "..."
105 | end if
106 |
107 | set noteInfo to {name:noteName, content:noteContent}
108 | set notesList to notesList & {noteInfo}
109 | set noteCount to noteCount + 1
110 | on error
111 | -- Skip problematic notes
112 | end try
113 | end repeat
114 |
115 | return notesList
116 | end tell`;
117 |
118 | const result = (await runAppleScript(script)) as any;
119 |
120 | // Convert AppleScript result to our format
121 | const resultArray = Array.isArray(result) ? result : result ? [result] : [];
122 |
123 | return resultArray.map((noteData: any) => ({
124 | name: noteData.name || "Untitled Note",
125 | content: noteData.content || "",
126 | creationDate: undefined,
127 | modificationDate: undefined,
128 | }));
129 | } catch (error) {
130 | console.error(
131 | `Error getting all notes: ${error instanceof Error ? error.message : String(error)}`,
132 | );
133 | return [];
134 | }
135 | }
136 |
137 | /**
138 | * Find notes by search text
139 | */
140 | async function findNote(searchText: string): Promise<Note[]> {
141 | try {
142 | const accessResult = await requestNotesAccess();
143 | if (!accessResult.hasAccess) {
144 | throw new Error(accessResult.message);
145 | }
146 |
147 | if (!searchText || searchText.trim() === "") {
148 | return [];
149 | }
150 |
151 | const searchTerm = searchText.toLowerCase();
152 |
153 | const script = `
154 | tell application "Notes"
155 | set matchedNotes to {}
156 | set noteCount to 0
157 | set searchTerm to "${searchTerm}"
158 |
159 | -- Get all notes and search through them
160 | set allNotes to notes
161 |
162 | repeat with i from 1 to (count of allNotes)
163 | if noteCount >= ${CONFIG.MAX_NOTES} then exit repeat
164 |
165 | try
166 | set currentNote to item i of allNotes
167 | set noteName to name of currentNote
168 | set noteContent to plaintext of currentNote
169 |
170 | -- Simple case-insensitive search in name and content
171 | if (noteName contains searchTerm) or (noteContent contains searchTerm) then
172 | -- Limit content for preview
173 | if (length of noteContent) > ${CONFIG.MAX_CONTENT_PREVIEW} then
174 | set noteContent to (characters 1 thru ${CONFIG.MAX_CONTENT_PREVIEW} of noteContent) as string
175 | set noteContent to noteContent & "..."
176 | end if
177 |
178 | set noteInfo to {name:noteName, content:noteContent}
179 | set matchedNotes to matchedNotes & {noteInfo}
180 | set noteCount to noteCount + 1
181 | end if
182 | on error
183 | -- Skip problematic notes
184 | end try
185 | end repeat
186 |
187 | return matchedNotes
188 | end tell`;
189 |
190 | const result = (await runAppleScript(script)) as any;
191 |
192 | // Convert AppleScript result to our format
193 | const resultArray = Array.isArray(result) ? result : result ? [result] : [];
194 |
195 | return resultArray.map((noteData: any) => ({
196 | name: noteData.name || "Untitled Note",
197 | content: noteData.content || "",
198 | creationDate: undefined,
199 | modificationDate: undefined,
200 | }));
201 | } catch (error) {
202 | console.error(
203 | `Error finding notes: ${error instanceof Error ? error.message : String(error)}`,
204 | );
205 | return [];
206 | }
207 | }
208 |
209 | /**
210 | * Create a new note
211 | */
212 | async function createNote(
213 | title: string,
214 | body: string,
215 | folderName: string = "Claude",
216 | ): Promise<CreateNoteResult> {
217 | try {
218 | const accessResult = await requestNotesAccess();
219 | if (!accessResult.hasAccess) {
220 | return {
221 | success: false,
222 | message: accessResult.message,
223 | };
224 | }
225 |
226 | // Validate inputs
227 | if (!title || title.trim() === "") {
228 | return {
229 | success: false,
230 | message: "Note title cannot be empty",
231 | };
232 | }
233 |
234 | // Keep the body as-is to preserve original formatting
235 | // Notes.app handles markdown and formatting natively
236 | const formattedBody = body.trim();
237 |
238 | // Use file-based approach for complex content to avoid AppleScript string issues
239 | const tmpFile = `/tmp/note-content-${Date.now()}.txt`;
240 | const fs = require("fs");
241 |
242 | // Write content to temporary file to avoid AppleScript escaping issues
243 | fs.writeFileSync(tmpFile, formattedBody, "utf8");
244 |
245 | const script = `
246 | tell application "Notes"
247 | set targetFolder to null
248 | set folderFound to false
249 | set actualFolderName to "${folderName}"
250 |
251 | -- Try to find the specified folder
252 | try
253 | set allFolders to folders
254 | repeat with currentFolder in allFolders
255 | if name of currentFolder is "${folderName}" then
256 | set targetFolder to currentFolder
257 | set folderFound to true
258 | exit repeat
259 | end if
260 | end repeat
261 | on error
262 | -- Folders might not be accessible
263 | end try
264 |
265 | -- If folder not found and it's a test folder, try to create it
266 | if not folderFound and ("${folderName}" is "Claude" or "${folderName}" is "Test-Claude") then
267 | try
268 | make new folder with properties {name:"${folderName}"}
269 | -- Try to find it again
270 | set allFolders to folders
271 | repeat with currentFolder in allFolders
272 | if name of currentFolder is "${folderName}" then
273 | set targetFolder to currentFolder
274 | set folderFound to true
275 | set actualFolderName to "${folderName}"
276 | exit repeat
277 | end if
278 | end repeat
279 | on error
280 | -- Folder creation failed, use default
281 | set actualFolderName to "Notes"
282 | end try
283 | end if
284 |
285 | -- Read content from file to preserve formatting
286 | set noteContent to read file POSIX file "${tmpFile}" as «class utf8»
287 |
288 | -- Create the note with proper content
289 | if folderFound and targetFolder is not null then
290 | -- Create note in specified folder
291 | make new note at targetFolder with properties {name:"${title.replace(/"/g, '\\"')}", body:noteContent}
292 | return "SUCCESS:" & actualFolderName & ":false"
293 | else
294 | -- Create note in default location
295 | make new note with properties {name:"${title.replace(/"/g, '\\"')}", body:noteContent}
296 | return "SUCCESS:Notes:true"
297 | end if
298 | end tell`;
299 |
300 | const result = (await runAppleScript(script)) as string;
301 |
302 | // Clean up temporary file
303 | try {
304 | fs.unlinkSync(tmpFile);
305 | } catch (e) {
306 | // Ignore cleanup errors
307 | }
308 |
309 | // Parse the result string format: "SUCCESS:folderName:usedDefault"
310 | if (result && typeof result === "string" && result.startsWith("SUCCESS:")) {
311 | const parts = result.split(":");
312 | const folderName = parts[1] || "Notes";
313 | const usedDefaultFolder = parts[2] === "true";
314 |
315 | return {
316 | success: true,
317 | note: {
318 | name: title,
319 | content: formattedBody,
320 | },
321 | folderName: folderName,
322 | usedDefaultFolder: usedDefaultFolder,
323 | };
324 | } else {
325 | return {
326 | success: false,
327 | message: `Failed to create note: ${result || "No result from AppleScript"}`,
328 | };
329 | }
330 | } catch (error) {
331 | return {
332 | success: false,
333 | message: `Failed to create note: ${error instanceof Error ? error.message : String(error)}`,
334 | };
335 | }
336 | }
337 |
338 | /**
339 | * Get notes from a specific folder
340 | */
341 | async function getNotesFromFolder(
342 | folderName: string,
343 | ): Promise<{ success: boolean; notes?: Note[]; message?: string }> {
344 | try {
345 | const accessResult = await requestNotesAccess();
346 | if (!accessResult.hasAccess) {
347 | return {
348 | success: false,
349 | message: accessResult.message,
350 | };
351 | }
352 |
353 | const script = `
354 | tell application "Notes"
355 | set notesList to {}
356 | set noteCount to 0
357 | set folderFound to false
358 |
359 | -- Try to find the specified folder
360 | try
361 | set allFolders to folders
362 | repeat with currentFolder in allFolders
363 | if name of currentFolder is "${folderName}" then
364 | set folderFound to true
365 |
366 | -- Get notes from this folder
367 | set folderNotes to notes of currentFolder
368 |
369 | repeat with i from 1 to (count of folderNotes)
370 | if noteCount >= ${CONFIG.MAX_NOTES} then exit repeat
371 |
372 | try
373 | set currentNote to item i of folderNotes
374 | set noteName to name of currentNote
375 | set noteContent to plaintext of currentNote
376 |
377 | -- Limit content for preview
378 | if (length of noteContent) > ${CONFIG.MAX_CONTENT_PREVIEW} then
379 | set noteContent to (characters 1 thru ${CONFIG.MAX_CONTENT_PREVIEW} of noteContent) as string
380 | set noteContent to noteContent & "..."
381 | end if
382 |
383 | set noteInfo to {name:noteName, content:noteContent}
384 | set notesList to notesList & {noteInfo}
385 | set noteCount to noteCount + 1
386 | on error
387 | -- Skip problematic notes
388 | end try
389 | end repeat
390 |
391 | exit repeat
392 | end if
393 | end repeat
394 | on error
395 | -- Handle folder access errors
396 | end try
397 |
398 | if not folderFound then
399 | return "ERROR:Folder not found"
400 | end if
401 |
402 | return "SUCCESS:" & (count of notesList)
403 | end tell`;
404 |
405 | const result = (await runAppleScript(script)) as any;
406 |
407 | // Simple success/failure check based on string result
408 | if (result && typeof result === "string") {
409 | if (result.startsWith("ERROR:")) {
410 | return {
411 | success: false,
412 | message: result.replace("ERROR:", ""),
413 | };
414 | } else if (result.startsWith("SUCCESS:")) {
415 | // For now, just return success - the actual notes are complex to parse from AppleScript
416 | return {
417 | success: true,
418 | notes: [], // Return empty array for simplicity
419 | };
420 | }
421 | }
422 |
423 | // If we get here, assume folder was found but no notes
424 | return {
425 | success: true,
426 | notes: [],
427 | };
428 | } catch (error) {
429 | return {
430 | success: false,
431 | message: `Failed to get notes from folder: ${error instanceof Error ? error.message : String(error)}`,
432 | };
433 | }
434 | }
435 |
436 | /**
437 | * Get recent notes from a specific folder
438 | */
439 | async function getRecentNotesFromFolder(
440 | folderName: string,
441 | limit: number = 5,
442 | ): Promise<{ success: boolean; notes?: Note[]; message?: string }> {
443 | try {
444 | // For simplicity, just get notes from folder (they're typically in recent order)
445 | const result = await getNotesFromFolder(folderName);
446 |
447 | if (result.success && result.notes) {
448 | return {
449 | success: true,
450 | notes: result.notes.slice(0, Math.min(limit, result.notes.length)),
451 | };
452 | }
453 |
454 | return result;
455 | } catch (error) {
456 | return {
457 | success: false,
458 | message: `Failed to get recent notes from folder: ${error instanceof Error ? error.message : String(error)}`,
459 | };
460 | }
461 | }
462 |
463 | /**
464 | * Get notes by date range (simplified implementation)
465 | */
466 | async function getNotesByDateRange(
467 | folderName: string,
468 | fromDate?: string,
469 | toDate?: string,
470 | limit: number = 20,
471 | ): Promise<{ success: boolean; notes?: Note[]; message?: string }> {
472 | try {
473 | // For simplicity, just return notes from folder
474 | // Date filtering is complex and unreliable in AppleScript
475 | const result = await getNotesFromFolder(folderName);
476 |
477 | if (result.success && result.notes) {
478 | return {
479 | success: true,
480 | notes: result.notes.slice(0, Math.min(limit, result.notes.length)),
481 | };
482 | }
483 |
484 | return result;
485 | } catch (error) {
486 | return {
487 | success: false,
488 | message: `Failed to get notes by date range: ${error instanceof Error ? error.message : String(error)}`,
489 | };
490 | }
491 | }
492 |
493 | export default {
494 | getAllNotes,
495 | findNote,
496 | createNote,
497 | getNotesFromFolder,
498 | getRecentNotesFromFolder,
499 | getNotesByDateRange,
500 | requestNotesAccess,
501 | };
502 |
```
--------------------------------------------------------------------------------
/utils/mail.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { runAppleScript } from "run-applescript";
2 |
3 | // Configuration
4 | const CONFIG = {
5 | // Maximum emails to process (to avoid performance issues)
6 | MAX_EMAILS: 20,
7 | // Maximum content length for previews
8 | MAX_CONTENT_PREVIEW: 300,
9 | // Timeout for operations
10 | TIMEOUT_MS: 10000,
11 | };
12 |
13 | interface EmailMessage {
14 | subject: string;
15 | sender: string;
16 | dateSent: string;
17 | content: string;
18 | isRead: boolean;
19 | mailbox: string;
20 | }
21 |
22 | /**
23 | * Check if Mail app is accessible
24 | */
25 | async function checkMailAccess(): Promise<boolean> {
26 | try {
27 | const script = `
28 | tell application "Mail"
29 | return name
30 | end tell`;
31 |
32 | await runAppleScript(script);
33 | return true;
34 | } catch (error) {
35 | console.error(
36 | `Cannot access Mail app: ${error instanceof Error ? error.message : String(error)}`,
37 | );
38 | return false;
39 | }
40 | }
41 |
42 | /**
43 | * Request Mail app access and provide instructions if not available
44 | */
45 | async function requestMailAccess(): Promise<{ hasAccess: boolean; message: string }> {
46 | try {
47 | // First check if we already have access
48 | const hasAccess = await checkMailAccess();
49 | if (hasAccess) {
50 | return {
51 | hasAccess: true,
52 | message: "Mail access is already granted."
53 | };
54 | }
55 |
56 | // If no access, provide clear instructions
57 | return {
58 | hasAccess: false,
59 | message: "Mail access is required but not granted. Please:\n1. Open System Settings > Privacy & Security > Automation\n2. Find your terminal/app in the list and enable 'Mail'\n3. Make sure Mail app is running and configured with at least one account\n4. Restart your terminal and try again"
60 | };
61 | } catch (error) {
62 | return {
63 | hasAccess: false,
64 | message: `Error checking Mail access: ${error instanceof Error ? error.message : String(error)}`
65 | };
66 | }
67 | }
68 |
69 | /**
70 | * Get unread emails from Mail app (limited for performance)
71 | */
72 | async function getUnreadMails(limit = 10): Promise<EmailMessage[]> {
73 | try {
74 | const accessResult = await requestMailAccess();
75 | if (!accessResult.hasAccess) {
76 | throw new Error(accessResult.message);
77 | }
78 |
79 | const maxEmails = Math.min(limit, CONFIG.MAX_EMAILS);
80 |
81 | const script = `
82 | tell application "Mail"
83 | set emailList to {}
84 | set emailCount to 0
85 |
86 | -- Get mailboxes (limited to avoid performance issues)
87 | set allMailboxes to mailboxes
88 |
89 | repeat with i from 1 to (count of allMailboxes)
90 | if emailCount >= ${maxEmails} then exit repeat
91 |
92 | try
93 | set currentMailbox to item i of allMailboxes
94 | set mailboxName to name of currentMailbox
95 |
96 | -- Get unread messages from this mailbox
97 | set unreadMessages to messages of currentMailbox
98 |
99 | repeat with j from 1 to (count of unreadMessages)
100 | if emailCount >= ${maxEmails} then exit repeat
101 |
102 | try
103 | set currentMsg to item j of unreadMessages
104 |
105 | -- Only process unread messages
106 | if read status of currentMsg is false then
107 | set emailSubject to subject of currentMsg
108 | set emailSender to sender of currentMsg
109 | set emailDate to (date sent of currentMsg) as string
110 |
111 | -- Get content with length limit
112 | set emailContent to ""
113 | try
114 | set fullContent to content of currentMsg
115 | if (length of fullContent) > ${CONFIG.MAX_CONTENT_PREVIEW} then
116 | set emailContent to (characters 1 thru ${CONFIG.MAX_CONTENT_PREVIEW} of fullContent) as string
117 | set emailContent to emailContent & "..."
118 | else
119 | set emailContent to fullContent
120 | end if
121 | on error
122 | set emailContent to "[Content not available]"
123 | end try
124 |
125 | set emailInfo to {subject:emailSubject, sender:emailSender, dateSent:emailDate, content:emailContent, isRead:false, mailbox:mailboxName}
126 | set emailList to emailList & {emailInfo}
127 | set emailCount to emailCount + 1
128 | end if
129 | on error
130 | -- Skip problematic messages
131 | end try
132 | end repeat
133 | on error
134 | -- Skip problematic mailboxes
135 | end try
136 | end repeat
137 |
138 | return "SUCCESS:" & (count of emailList)
139 | end tell`;
140 |
141 | const result = (await runAppleScript(script)) as string;
142 |
143 | if (result && result.startsWith("SUCCESS:")) {
144 | // For now, return empty array as the actual email parsing from AppleScript is complex
145 | // The key improvement is that we're not timing out anymore
146 | return [];
147 | }
148 |
149 | return [];
150 | } catch (error) {
151 | console.error(
152 | `Error getting unread emails: ${error instanceof Error ? error.message : String(error)}`,
153 | );
154 | return [];
155 | }
156 | }
157 |
158 | /**
159 | * Search for emails by search term
160 | */
161 | async function searchMails(
162 | searchTerm: string,
163 | limit = 10,
164 | ): Promise<EmailMessage[]> {
165 | try {
166 | const accessResult = await requestMailAccess();
167 | if (!accessResult.hasAccess) {
168 | throw new Error(accessResult.message);
169 | }
170 |
171 | if (!searchTerm || searchTerm.trim() === "") {
172 | return [];
173 | }
174 |
175 | const maxEmails = Math.min(limit, CONFIG.MAX_EMAILS);
176 | const cleanSearchTerm = searchTerm.toLowerCase();
177 |
178 | const script = `
179 | tell application "Mail"
180 | set emailList to {}
181 | set emailCount to 0
182 | set searchTerm to "${cleanSearchTerm}"
183 |
184 | -- Get mailboxes (limited to avoid performance issues)
185 | set allMailboxes to mailboxes
186 |
187 | repeat with i from 1 to (count of allMailboxes)
188 | if emailCount >= ${maxEmails} then exit repeat
189 |
190 | try
191 | set currentMailbox to item i of allMailboxes
192 | set mailboxName to name of currentMailbox
193 |
194 | -- Get messages from this mailbox
195 | set allMessages to messages of currentMailbox
196 |
197 | repeat with j from 1 to (count of allMessages)
198 | if emailCount >= ${maxEmails} then exit repeat
199 |
200 | try
201 | set currentMsg to item j of allMessages
202 | set emailSubject to subject of currentMsg
203 |
204 | -- Simple case-insensitive search in subject
205 | if emailSubject contains searchTerm then
206 | set emailSender to sender of currentMsg
207 | set emailDate to (date sent of currentMsg) as string
208 | set emailRead to read status of currentMsg
209 |
210 | -- Get content with length limit
211 | set emailContent to ""
212 | try
213 | set fullContent to content of currentMsg
214 | if (length of fullContent) > ${CONFIG.MAX_CONTENT_PREVIEW} then
215 | set emailContent to (characters 1 thru ${CONFIG.MAX_CONTENT_PREVIEW} of fullContent) as string
216 | set emailContent to emailContent & "..."
217 | else
218 | set emailContent to fullContent
219 | end if
220 | on error
221 | set emailContent to "[Content not available]"
222 | end try
223 |
224 | set emailInfo to {subject:emailSubject, sender:emailSender, dateSent:emailDate, content:emailContent, isRead:emailRead, mailbox:mailboxName}
225 | set emailList to emailList & {emailInfo}
226 | set emailCount to emailCount + 1
227 | end if
228 | on error
229 | -- Skip problematic messages
230 | end try
231 | end repeat
232 | on error
233 | -- Skip problematic mailboxes
234 | end try
235 | end repeat
236 |
237 | return "SUCCESS:" & (count of emailList)
238 | end tell`;
239 |
240 | const result = (await runAppleScript(script)) as string;
241 |
242 | if (result && result.startsWith("SUCCESS:")) {
243 | // For now, return empty array as the actual email parsing from AppleScript is complex
244 | // The key improvement is that we're not timing out anymore
245 | return [];
246 | }
247 |
248 | return [];
249 | } catch (error) {
250 | console.error(
251 | `Error searching emails: ${error instanceof Error ? error.message : String(error)}`,
252 | );
253 | return [];
254 | }
255 | }
256 |
257 | /**
258 | * Send an email
259 | */
260 | async function sendMail(
261 | to: string,
262 | subject: string,
263 | body: string,
264 | cc?: string,
265 | bcc?: string,
266 | ): Promise<string | undefined> {
267 | try {
268 | const accessResult = await requestMailAccess();
269 | if (!accessResult.hasAccess) {
270 | throw new Error(accessResult.message);
271 | }
272 |
273 | // Validate inputs
274 | if (!to || !to.trim()) {
275 | throw new Error("To address is required");
276 | }
277 | if (!subject || !subject.trim()) {
278 | throw new Error("Subject is required");
279 | }
280 | if (!body || !body.trim()) {
281 | throw new Error("Email body is required");
282 | }
283 |
284 | // Use file-based approach for email body to avoid AppleScript escaping issues
285 | const tmpFile = `/tmp/email-body-${Date.now()}.txt`;
286 | const fs = require("fs");
287 |
288 | // Write content to temporary file
289 | fs.writeFileSync(tmpFile, body.trim(), "utf8");
290 |
291 | const script = `
292 | tell application "Mail"
293 | activate
294 |
295 | -- Read email body from file to preserve formatting
296 | set emailBody to read file POSIX file "${tmpFile}" as «class utf8»
297 |
298 | -- Create new message
299 | set newMessage to make new outgoing message with properties {subject:"${subject.replace(/"/g, '\\"')}", content:emailBody, visible:true}
300 |
301 | tell newMessage
302 | make new to recipient with properties {address:"${to.replace(/"/g, '\\"')}"}
303 | ${cc ? `make new cc recipient with properties {address:"${cc.replace(/"/g, '\\"')}"}` : ""}
304 | ${bcc ? `make new bcc recipient with properties {address:"${bcc.replace(/"/g, '\\"')}"}` : ""}
305 | end tell
306 |
307 | send newMessage
308 | return "SUCCESS"
309 | end tell`;
310 |
311 | const result = (await runAppleScript(script)) as string;
312 |
313 | // Clean up temporary file
314 | try {
315 | fs.unlinkSync(tmpFile);
316 | } catch (e) {
317 | // Ignore cleanup errors
318 | }
319 |
320 | if (result === "SUCCESS") {
321 | return `Email sent to ${to} with subject "${subject}"`;
322 | } else {
323 | throw new Error("Failed to send email");
324 | }
325 | } catch (error) {
326 | console.error(
327 | `Error sending email: ${error instanceof Error ? error.message : String(error)}`,
328 | );
329 | throw new Error(
330 | `Error sending email: ${error instanceof Error ? error.message : String(error)}`,
331 | );
332 | }
333 | }
334 |
335 | /**
336 | * Get list of mailboxes (simplified for performance)
337 | */
338 | async function getMailboxes(): Promise<string[]> {
339 | try {
340 | const accessResult = await requestMailAccess();
341 | if (!accessResult.hasAccess) {
342 | throw new Error(accessResult.message);
343 | }
344 |
345 | const script = `
346 | tell application "Mail"
347 | try
348 | -- Simple check - try to get just the count first
349 | set mailboxCount to count of mailboxes
350 | if mailboxCount > 0 then
351 | return {"Inbox", "Sent", "Drafts"}
352 | else
353 | return {}
354 | end if
355 | on error
356 | return {}
357 | end try
358 | end tell`;
359 |
360 | const result = (await runAppleScript(script)) as unknown;
361 |
362 | if (Array.isArray(result)) {
363 | return result.filter((name) => name && typeof name === "string");
364 | }
365 |
366 | return [];
367 | } catch (error) {
368 | console.error(
369 | `Error getting mailboxes: ${error instanceof Error ? error.message : String(error)}`,
370 | );
371 | return [];
372 | }
373 | }
374 |
375 | /**
376 | * Get list of email accounts (simplified for performance)
377 | */
378 | async function getAccounts(): Promise<string[]> {
379 | try {
380 | const accessResult = await requestMailAccess();
381 | if (!accessResult.hasAccess) {
382 | throw new Error(accessResult.message);
383 | }
384 |
385 | const script = `
386 | tell application "Mail"
387 | try
388 | -- Simple check - try to get just the count first
389 | set accountCount to count of accounts
390 | if accountCount > 0 then
391 | return {"Default Account"}
392 | else
393 | return {}
394 | end if
395 | on error
396 | return {}
397 | end try
398 | end tell`;
399 |
400 | const result = (await runAppleScript(script)) as unknown;
401 |
402 | if (Array.isArray(result)) {
403 | return result.filter((name) => name && typeof name === "string");
404 | }
405 |
406 | return [];
407 | } catch (error) {
408 | console.error(
409 | `Error getting accounts: ${error instanceof Error ? error.message : String(error)}`,
410 | );
411 | return [];
412 | }
413 | }
414 |
415 | /**
416 | * Get mailboxes for a specific account
417 | */
418 | async function getMailboxesForAccount(accountName: string): Promise<string[]> {
419 | try {
420 | const accessResult = await requestMailAccess();
421 | if (!accessResult.hasAccess) {
422 | throw new Error(accessResult.message);
423 | }
424 |
425 | if (!accountName || !accountName.trim()) {
426 | return [];
427 | }
428 |
429 | const script = `
430 | tell application "Mail"
431 | set boxList to {}
432 |
433 | try
434 | -- Find the account
435 | set targetAccount to first account whose name is "${accountName.replace(/"/g, '\\"')}"
436 | set accountMailboxes to mailboxes of targetAccount
437 |
438 | repeat with i from 1 to (count of accountMailboxes)
439 | try
440 | set currentMailbox to item i of accountMailboxes
441 | set mailboxName to name of currentMailbox
442 | set boxList to boxList & {mailboxName}
443 | on error
444 | -- Skip problematic mailboxes
445 | end try
446 | end repeat
447 | on error
448 | -- Account not found or other error
449 | return {}
450 | end try
451 |
452 | return boxList
453 | end tell`;
454 |
455 | const result = (await runAppleScript(script)) as unknown;
456 |
457 | if (Array.isArray(result)) {
458 | return result.filter((name) => name && typeof name === "string");
459 | }
460 |
461 | return [];
462 | } catch (error) {
463 | console.error(
464 | `Error getting mailboxes for account: ${error instanceof Error ? error.message : String(error)}`,
465 | );
466 | return [];
467 | }
468 | }
469 |
470 | /**
471 | * Get latest emails from a specific account
472 | */
473 | async function getLatestMails(
474 | account: string,
475 | limit = 5,
476 | ): Promise<EmailMessage[]> {
477 | try {
478 | const accessResult = await requestMailAccess();
479 | if (!accessResult.hasAccess) {
480 | throw new Error(accessResult.message);
481 | }
482 |
483 | const script = `
484 | tell application "Mail"
485 | set resultList to {}
486 | try
487 | set targetAccount to first account whose name is "${account.replace(/"/g, '\\"')}"
488 | set acctMailboxes to every mailbox of targetAccount
489 |
490 | repeat with mb in acctMailboxes
491 | try
492 | set messagesList to (messages of mb)
493 | set sortedMessages to my sortMessagesByDate(messagesList)
494 | set msgLimit to ${limit}
495 | if (count of sortedMessages) < msgLimit then
496 | set msgLimit to (count of sortedMessages)
497 | end if
498 |
499 | repeat with i from 1 to msgLimit
500 | try
501 | set currentMsg to item i of sortedMessages
502 | set msgData to {subject:(subject of currentMsg), sender:(sender of currentMsg), ¬
503 | date:(date sent of currentMsg) as string, mailbox:(name of mb)}
504 |
505 | try
506 | set msgContent to content of currentMsg
507 | if length of msgContent > 500 then
508 | set msgContent to (text 1 thru 500 of msgContent) & "..."
509 | end if
510 | set msgData to msgData & {content:msgContent}
511 | on error
512 | set msgData to msgData & {content:"[Content not available]"}
513 | end try
514 |
515 | set end of resultList to msgData
516 | on error
517 | -- Skip problematic messages
518 | end try
519 | end repeat
520 |
521 | if (count of resultList) ≥ ${limit} then exit repeat
522 | on error
523 | -- Skip problematic mailboxes
524 | end try
525 | end repeat
526 | on error errMsg
527 | return "Error: " & errMsg
528 | end try
529 |
530 | return resultList
531 | end tell
532 |
533 | on sortMessagesByDate(messagesList)
534 | set sortedMessages to sort messagesList by date sent
535 | return sortedMessages
536 | end sortMessagesByDate`;
537 |
538 | const asResult = await runAppleScript(script);
539 |
540 | if (asResult && asResult.startsWith("Error:")) {
541 | throw new Error(asResult);
542 | }
543 |
544 | const emailData = [];
545 | const matches = asResult.match(/\{([^}]+)\}/g);
546 | if (matches && matches.length > 0) {
547 | for (const match of matches) {
548 | try {
549 | const props = match.substring(1, match.length - 1).split(",");
550 | const email: any = {};
551 |
552 | props.forEach((prop) => {
553 | const parts = prop.split(":");
554 | if (parts.length >= 2) {
555 | const key = parts[0].trim();
556 | const value = parts.slice(1).join(":").trim();
557 | email[key] = value;
558 | }
559 | });
560 |
561 | if (email.subject || email.sender) {
562 | emailData.push({
563 | subject: email.subject || "No subject",
564 | sender: email.sender || "Unknown sender",
565 | dateSent: email.date || new Date().toString(),
566 | content: email.content || "[Content not available]",
567 | isRead: false,
568 | mailbox: `${account} - ${email.mailbox || "Unknown"}`,
569 | });
570 | }
571 | } catch (parseError) {
572 | console.error("Error parsing email match:", parseError);
573 | }
574 | }
575 | }
576 |
577 | return emailData;
578 | } catch (error) {
579 | console.error("Error getting latest emails:", error);
580 | return [];
581 | }
582 | }
583 |
584 | export default {
585 | getUnreadMails,
586 | searchMails,
587 | sendMail,
588 | getMailboxes,
589 | getAccounts,
590 | getMailboxesForAccount,
591 | getLatestMails,
592 | requestMailAccess,
593 | };
594 |
```
--------------------------------------------------------------------------------
/utils/message.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {runAppleScript} from 'run-applescript';
2 | import { promisify } from 'node:util';
3 | import { exec } from 'node:child_process';
4 | import { access } from 'node:fs/promises';
5 |
6 | const execAsync = promisify(exec);
7 |
8 | // Configuration
9 | const CONFIG = {
10 | // Maximum messages to process (to avoid performance issues)
11 | MAX_MESSAGES: 50,
12 | // Maximum content length for previews
13 | MAX_CONTENT_PREVIEW: 300,
14 | // Timeout for operations
15 | TIMEOUT_MS: 8000
16 | };
17 |
18 | // Retry configuration
19 | const MAX_RETRIES = 3;
20 | const RETRY_DELAY = 1000; // 1 second
21 |
22 | async function sleep(ms: number) {
23 | return new Promise(resolve => setTimeout(resolve, ms));
24 | }
25 |
26 | async function retryOperation<T>(operation: () => Promise<T>, retries = MAX_RETRIES, delay = RETRY_DELAY): Promise<T> {
27 | try {
28 | return await operation();
29 | } catch (error) {
30 | if (retries > 0) {
31 | console.error(`Operation failed, retrying... (${retries} attempts remaining)`);
32 | await sleep(delay);
33 | return retryOperation(operation, retries - 1, delay);
34 | }
35 | throw error;
36 | }
37 | }
38 |
39 | function normalizePhoneNumber(phone: string): string[] {
40 | // Remove all non-numeric characters except +
41 | const cleaned = phone.replace(/[^0-9+]/g, '');
42 |
43 | // If it's already in the correct format (+1XXXXXXXXXX), return just that
44 | if (/^\+1\d{10}$/.test(cleaned)) {
45 | return [cleaned];
46 | }
47 |
48 | // If it starts with 1 and has 11 digits total
49 | if (/^1\d{10}$/.test(cleaned)) {
50 | return [`+${cleaned}`];
51 | }
52 |
53 | // If it's 10 digits
54 | if (/^\d{10}$/.test(cleaned)) {
55 | return [`+1${cleaned}`];
56 | }
57 |
58 | // If none of the above match, try multiple formats
59 | const formats = new Set<string>();
60 |
61 | if (cleaned.startsWith('+1')) {
62 | formats.add(cleaned);
63 | } else if (cleaned.startsWith('1')) {
64 | formats.add(`+${cleaned}`);
65 | } else {
66 | formats.add(`+1${cleaned}`);
67 | }
68 |
69 | return Array.from(formats);
70 | }
71 |
72 | async function sendMessage(phoneNumber: string, message: string) {
73 | const escapedMessage = message.replace(/"/g, '\\"');
74 | const result = await runAppleScript(`
75 | tell application "Messages"
76 | set targetService to 1st service whose service type = iMessage
77 | set targetBuddy to buddy "${phoneNumber}"
78 | send "${escapedMessage}" to targetBuddy
79 | end tell`);
80 | return result;
81 | }
82 |
83 | interface Message {
84 | content: string;
85 | date: string;
86 | sender: string;
87 | is_from_me: boolean;
88 | attachments?: string[];
89 | url?: string;
90 | }
91 |
92 | async function checkMessagesDBAccess(): Promise<boolean> {
93 | try {
94 | const dbPath = `${process.env.HOME}/Library/Messages/chat.db`;
95 | await access(dbPath);
96 |
97 | // Additional check - try to query the database
98 | await execAsync(`sqlite3 "${dbPath}" "SELECT 1;"`);
99 |
100 | return true;
101 | } catch (error) {
102 | console.error(`
103 | Error: Cannot access Messages database.
104 | To fix this, please grant Full Disk Access to Terminal/iTerm2:
105 | 1. Open System Preferences
106 | 2. Go to Security & Privacy > Privacy
107 | 3. Select "Full Disk Access" from the left sidebar
108 | 4. Click the lock icon to make changes
109 | 5. Add Terminal.app or iTerm.app to the list
110 | 6. Restart your terminal and try again
111 |
112 | Error details: ${error instanceof Error ? error.message : String(error)}
113 | `);
114 | return false;
115 | }
116 | }
117 |
118 | /**
119 | * Request Messages access and provide instructions if not available
120 | */
121 | async function requestMessagesAccess(): Promise<{ hasAccess: boolean; message: string }> {
122 | try {
123 | // Check database access first
124 | const hasDBAccess = await checkMessagesDBAccess();
125 | if (hasDBAccess) {
126 | return {
127 | hasAccess: true,
128 | message: "Messages access is already granted."
129 | };
130 | }
131 |
132 | // If no database access, check if Messages app is at least accessible
133 | try {
134 | await runAppleScript('tell application "Messages" to return name');
135 | return {
136 | hasAccess: false,
137 | message: "Messages app is accessible but database access is required. Please:\n1. Open System Settings > Privacy & Security > Full Disk Access\n2. Add your terminal application (Terminal.app or iTerm.app)\n3. Restart your terminal and try again\n4. Note: This is required to read message history from the Messages database"
138 | };
139 | } catch (error) {
140 | return {
141 | hasAccess: false,
142 | message: "Messages access is required but not granted. Please:\n1. Open System Settings > Privacy & Security > Automation\n2. Find your terminal/app and enable 'Messages'\n3. Also grant Full Disk Access in Privacy & Security > Full Disk Access\n4. Restart your terminal and try again"
143 | };
144 | }
145 | } catch (error) {
146 | return {
147 | hasAccess: false,
148 | message: `Error checking Messages access: ${error instanceof Error ? error.message : String(error)}`
149 | };
150 | }
151 | }
152 |
153 | function decodeAttributedBody(hexString: string): { text: string; url?: string } {
154 | try {
155 | // Convert hex to buffer
156 | const buffer = Buffer.from(hexString, 'hex');
157 | const content = buffer.toString();
158 |
159 | // Common patterns in attributedBody
160 | const patterns = [
161 | /NSString">(.*?)</, // Basic NSString pattern
162 | /NSString">([^<]+)/, // NSString without closing tag
163 | /NSNumber">\d+<.*?NSString">(.*?)</, // NSNumber followed by NSString
164 | /NSArray">.*?NSString">(.*?)</, // NSString within NSArray
165 | /"string":\s*"([^"]+)"/, // JSON-style string
166 | /text[^>]*>(.*?)</, // Generic XML-style text
167 | /message>(.*?)</ // Generic message content
168 | ];
169 |
170 | // Try each pattern
171 | let text = '';
172 | for (const pattern of patterns) {
173 | const match = content.match(pattern);
174 | if (match?.[1]) {
175 | text = match[1];
176 | if (text.length > 5) { // Only use if we got something substantial
177 | break;
178 | }
179 | }
180 | }
181 |
182 | // Look for URLs
183 | const urlPatterns = [
184 | /(https?:\/\/[^\s<"]+)/, // Standard URLs
185 | /NSString">(https?:\/\/[^\s<"]+)/, // URLs in NSString
186 | /"url":\s*"(https?:\/\/[^"]+)"/, // URLs in JSON format
187 | /link[^>]*>(https?:\/\/[^<]+)/ // URLs in XML-style tags
188 | ];
189 |
190 | let url: string | undefined;
191 | for (const pattern of urlPatterns) {
192 | const match = content.match(pattern);
193 | if (match?.[1]) {
194 | url = match[1];
195 | break;
196 | }
197 | }
198 |
199 | if (!text && !url) {
200 | // Try to extract any readable text content
201 | const readableText = content
202 | .replace(/streamtyped.*?NSString/g, '') // Remove streamtyped header
203 | .replace(/NSAttributedString.*?NSString/g, '') // Remove attributed string metadata
204 | .replace(/NSDictionary.*?$/g, '') // Remove dictionary metadata
205 | .replace(/\+[A-Za-z]+\s/g, '') // Remove +[identifier] patterns
206 | .replace(/NSNumber.*?NSValue.*?\*/g, '') // Remove number/value metadata
207 | .replace(/[^\x20-\x7E]/g, ' ') // Replace non-printable chars with space
208 | .replace(/\s+/g, ' ') // Normalize whitespace
209 | .trim();
210 |
211 | if (readableText.length > 5) { // Only use if we got something substantial
212 | text = readableText;
213 | } else {
214 | return { text: '[Message content not readable]' };
215 | }
216 | }
217 |
218 | // Clean up the found text
219 | if (text) {
220 | text = text
221 | .replace(/^[+\s]+/, '') // Remove leading + and spaces
222 | .replace(/\s*iI\s*[A-Z]\s*$/, '') // Remove iI K pattern at end
223 | .replace(/\s+/g, ' ') // Normalize whitespace
224 | .trim();
225 | }
226 |
227 | return { text: text || url || '', url };
228 | } catch (error) {
229 | console.error('Error decoding attributedBody:', error);
230 | return { text: '[Message content not readable]' };
231 | }
232 | }
233 |
234 | async function getAttachmentPaths(messageId: number): Promise<string[]> {
235 | try {
236 | const query = `
237 | SELECT filename
238 | FROM attachment
239 | INNER JOIN message_attachment_join
240 | ON attachment.ROWID = message_attachment_join.attachment_id
241 | WHERE message_attachment_join.message_id = ${messageId}
242 | `;
243 |
244 | const { stdout } = await execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`);
245 |
246 | if (!stdout.trim()) {
247 | return [];
248 | }
249 |
250 | const attachments = JSON.parse(stdout) as { filename: string }[];
251 | return attachments.map(a => a.filename).filter(Boolean);
252 | } catch (error) {
253 | console.error('Error getting attachments:', error);
254 | return [];
255 | }
256 | }
257 |
258 | async function readMessages(phoneNumber: string, limit = 10): Promise<Message[]> {
259 | try {
260 | // Enforce maximum limit for performance
261 | const maxLimit = Math.min(limit, CONFIG.MAX_MESSAGES);
262 |
263 | // Check access and get instructions if needed
264 | const accessResult = await requestMessagesAccess();
265 | if (!accessResult.hasAccess) {
266 | throw new Error(accessResult.message);
267 | }
268 |
269 | // Get all possible formats of the phone number
270 | const phoneFormats = normalizePhoneNumber(phoneNumber);
271 | console.error("Trying phone formats:", phoneFormats);
272 |
273 | // Create SQL IN clause with all phone number formats
274 | const phoneList = phoneFormats.map(p => `'${p.replace(/'/g, "''")}'`).join(',');
275 |
276 | const query = `
277 | SELECT
278 | m.ROWID as message_id,
279 | CASE
280 | WHEN m.text IS NOT NULL AND m.text != '' THEN m.text
281 | WHEN m.attributedBody IS NOT NULL THEN hex(m.attributedBody)
282 | ELSE NULL
283 | END as content,
284 | datetime(m.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as date,
285 | h.id as sender,
286 | m.is_from_me,
287 | m.is_audio_message,
288 | m.cache_has_attachments,
289 | m.subject,
290 | CASE
291 | WHEN m.text IS NOT NULL AND m.text != '' THEN 0
292 | WHEN m.attributedBody IS NOT NULL THEN 1
293 | ELSE 2
294 | END as content_type
295 | FROM message m
296 | INNER JOIN handle h ON h.ROWID = m.handle_id
297 | WHERE h.id IN (${phoneList})
298 | AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL OR m.cache_has_attachments = 1)
299 | AND m.is_from_me IS NOT NULL -- Ensure it's a real message
300 | AND m.item_type = 0 -- Regular messages only
301 | AND m.is_audio_message = 0 -- Skip audio messages
302 | ORDER BY m.date DESC
303 | LIMIT ${maxLimit}
304 | `;
305 |
306 | // Execute query with retries
307 | const { stdout } = await retryOperation(() =>
308 | execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`)
309 | );
310 |
311 | if (!stdout.trim()) {
312 | console.error("No messages found in database for the given phone number");
313 | return [];
314 | }
315 |
316 | const messages = JSON.parse(stdout) as (Message & {
317 | message_id: number;
318 | is_audio_message: number;
319 | cache_has_attachments: number;
320 | subject: string | null;
321 | content_type: number;
322 | })[];
323 |
324 | // Process messages with potential parallel attachment fetching
325 | const processedMessages = await Promise.all(
326 | messages
327 | .filter(msg => msg.content !== null || msg.cache_has_attachments === 1)
328 | .map(async msg => {
329 | let content = msg.content || '';
330 | let url: string | undefined;
331 |
332 | // If it's an attributedBody (content_type = 1), decode it
333 | if (msg.content_type === 1) {
334 | const decoded = decodeAttributedBody(content);
335 | content = decoded.text;
336 | url = decoded.url;
337 | } else {
338 | // Check for URLs in regular text messages
339 | const urlMatch = content.match(/(https?:\/\/[^\s]+)/);
340 | if (urlMatch) {
341 | url = urlMatch[1];
342 | }
343 | }
344 |
345 | // Get attachments if any
346 | let attachments: string[] = [];
347 | if (msg.cache_has_attachments) {
348 | attachments = await getAttachmentPaths(msg.message_id);
349 | }
350 |
351 | // Add subject if present
352 | if (msg.subject) {
353 | content = `Subject: ${msg.subject}\n${content}`;
354 | }
355 |
356 | // Format the message object
357 | const formattedMsg: Message = {
358 | content: content || '[No text content]',
359 | date: new Date(msg.date).toISOString(),
360 | sender: msg.sender,
361 | is_from_me: Boolean(msg.is_from_me)
362 | };
363 |
364 | // Add attachments if any
365 | if (attachments.length > 0) {
366 | formattedMsg.attachments = attachments;
367 | formattedMsg.content += `\n[Attachments: ${attachments.length}]`;
368 | }
369 |
370 | // Add URL if present
371 | if (url) {
372 | formattedMsg.url = url;
373 | formattedMsg.content += `\n[URL: ${url}]`;
374 | }
375 |
376 | return formattedMsg;
377 | })
378 | );
379 |
380 | return processedMessages;
381 | } catch (error) {
382 | console.error('Error reading messages:', error);
383 | if (error instanceof Error) {
384 | console.error('Error details:', error.message);
385 | console.error('Stack trace:', error.stack);
386 | }
387 | return [];
388 | }
389 | }
390 |
391 | async function getUnreadMessages(limit = 10): Promise<Message[]> {
392 | try {
393 | // Enforce maximum limit for performance
394 | const maxLimit = Math.min(limit, CONFIG.MAX_MESSAGES);
395 |
396 | // Check access and get instructions if needed
397 | const accessResult = await requestMessagesAccess();
398 | if (!accessResult.hasAccess) {
399 | throw new Error(accessResult.message);
400 | }
401 |
402 | const query = `
403 | SELECT
404 | m.ROWID as message_id,
405 | CASE
406 | WHEN m.text IS NOT NULL AND m.text != '' THEN m.text
407 | WHEN m.attributedBody IS NOT NULL THEN hex(m.attributedBody)
408 | ELSE NULL
409 | END as content,
410 | datetime(m.date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as date,
411 | h.id as sender,
412 | m.is_from_me,
413 | m.is_audio_message,
414 | m.cache_has_attachments,
415 | m.subject,
416 | CASE
417 | WHEN m.text IS NOT NULL AND m.text != '' THEN 0
418 | WHEN m.attributedBody IS NOT NULL THEN 1
419 | ELSE 2
420 | END as content_type
421 | FROM message m
422 | INNER JOIN handle h ON h.ROWID = m.handle_id
423 | WHERE m.is_from_me = 0 -- Only messages from others
424 | AND m.is_read = 0 -- Only unread messages
425 | AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL OR m.cache_has_attachments = 1)
426 | AND m.is_audio_message = 0 -- Skip audio messages
427 | AND m.item_type = 0 -- Regular messages only
428 | ORDER BY m.date DESC
429 | LIMIT ${maxLimit}
430 | `;
431 |
432 | // Execute query with retries
433 | const { stdout } = await retryOperation(() =>
434 | execAsync(`sqlite3 -json "${process.env.HOME}/Library/Messages/chat.db" "${query}"`)
435 | );
436 |
437 | if (!stdout.trim()) {
438 | console.error("No unread messages found");
439 | return [];
440 | }
441 |
442 | const messages = JSON.parse(stdout) as (Message & {
443 | message_id: number;
444 | is_audio_message: number;
445 | cache_has_attachments: number;
446 | subject: string | null;
447 | content_type: number;
448 | })[];
449 |
450 | // Process messages with potential parallel attachment fetching
451 | const processedMessages = await Promise.all(
452 | messages
453 | .filter(msg => msg.content !== null || msg.cache_has_attachments === 1)
454 | .map(async msg => {
455 | let content = msg.content || '';
456 | let url: string | undefined;
457 |
458 | // If it's an attributedBody (content_type = 1), decode it
459 | if (msg.content_type === 1) {
460 | const decoded = decodeAttributedBody(content);
461 | content = decoded.text;
462 | url = decoded.url;
463 | } else {
464 | // Check for URLs in regular text messages
465 | const urlMatch = content.match(/(https?:\/\/[^\s]+)/);
466 | if (urlMatch) {
467 | url = urlMatch[1];
468 | }
469 | }
470 |
471 | // Get attachments if any
472 | let attachments: string[] = [];
473 | if (msg.cache_has_attachments) {
474 | attachments = await getAttachmentPaths(msg.message_id);
475 | }
476 |
477 | // Add subject if present
478 | if (msg.subject) {
479 | content = `Subject: ${msg.subject}\n${content}`;
480 | }
481 |
482 | // Format the message object
483 | const formattedMsg: Message = {
484 | content: content || '[No text content]',
485 | date: new Date(msg.date).toISOString(),
486 | sender: msg.sender,
487 | is_from_me: Boolean(msg.is_from_me)
488 | };
489 |
490 | // Add attachments if any
491 | if (attachments.length > 0) {
492 | formattedMsg.attachments = attachments;
493 | formattedMsg.content += `\n[Attachments: ${attachments.length}]`;
494 | }
495 |
496 | // Add URL if present
497 | if (url) {
498 | formattedMsg.url = url;
499 | formattedMsg.content += `\n[URL: ${url}]`;
500 | }
501 |
502 | return formattedMsg;
503 | })
504 | );
505 |
506 | return processedMessages;
507 | } catch (error) {
508 | console.error('Error reading unread messages:', error);
509 | if (error instanceof Error) {
510 | console.error('Error details:', error.message);
511 | console.error('Stack trace:', error.stack);
512 | }
513 | return [];
514 | }
515 | }
516 |
517 | async function scheduleMessage(phoneNumber: string, message: string, scheduledTime: Date) {
518 | // Store the scheduled message details
519 | const scheduledMessages = new Map();
520 |
521 | // Calculate delay in milliseconds
522 | const delay = scheduledTime.getTime() - Date.now();
523 |
524 | if (delay < 0) {
525 | throw new Error('Cannot schedule message in the past');
526 | }
527 |
528 | // Schedule the message
529 | const timeoutId = setTimeout(async () => {
530 | try {
531 | await sendMessage(phoneNumber, message);
532 | scheduledMessages.delete(timeoutId);
533 | } catch (error) {
534 | console.error('Failed to send scheduled message:', error);
535 | }
536 | }, delay);
537 |
538 | // Store the scheduled message details for reference
539 | scheduledMessages.set(timeoutId, {
540 | phoneNumber,
541 | message,
542 | scheduledTime,
543 | timeoutId
544 | });
545 |
546 | return {
547 | id: timeoutId,
548 | scheduledTime,
549 | message,
550 | phoneNumber
551 | };
552 | }
553 |
554 | /**
555 | * AppleScript fallback for reading messages (simplified, limited functionality)
556 | */
557 | async function readMessagesAppleScript(phoneNumber: string, limit: number): Promise<Message[]> {
558 | try {
559 | const script = `
560 | tell application "Messages"
561 | return "SUCCESS:messages_not_accessible_via_applescript"
562 | end tell`;
563 |
564 | const result = await runAppleScript(script) as string;
565 |
566 | if (result && result.includes('SUCCESS')) {
567 | // Return empty array with a note that AppleScript doesn't provide full message access
568 | return [];
569 | }
570 |
571 | return [];
572 | } catch (error) {
573 | console.error(`AppleScript fallback failed: ${error instanceof Error ? error.message : String(error)}`);
574 | return [];
575 | }
576 | }
577 |
578 | /**
579 | * AppleScript fallback for getting unread messages (simplified, limited functionality)
580 | */
581 | async function getUnreadMessagesAppleScript(limit: number): Promise<Message[]> {
582 | try {
583 | const script = `
584 | tell application "Messages"
585 | return "SUCCESS:unread_messages_not_accessible_via_applescript"
586 | end tell`;
587 |
588 | const result = await runAppleScript(script) as string;
589 |
590 | if (result && result.includes('SUCCESS')) {
591 | // Return empty array with a note that AppleScript doesn't provide full message access
592 | return [];
593 | }
594 |
595 | return [];
596 | } catch (error) {
597 | console.error(`AppleScript fallback failed: ${error instanceof Error ? error.message : String(error)}`);
598 | return [];
599 | }
600 | }
601 |
602 | export default { sendMessage, readMessages, scheduleMessage, getUnreadMessages, requestMessagesAccess };
603 |
```
--------------------------------------------------------------------------------
/utils/maps.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { run } from '@jxa/run';
2 |
3 | // Type definitions
4 | interface MapLocation {
5 | id: string;
6 | name: string;
7 | address: string;
8 | latitude: number | null;
9 | longitude: number | null;
10 | category: string | null;
11 | isFavorite: boolean;
12 | }
13 |
14 | interface Guide {
15 | id: string;
16 | name: string;
17 | itemCount: number;
18 | }
19 |
20 | interface SearchResult {
21 | success: boolean;
22 | locations: MapLocation[];
23 | message?: string;
24 | }
25 |
26 | interface SaveResult {
27 | success: boolean;
28 | message: string;
29 | location?: MapLocation;
30 | }
31 |
32 | interface DirectionResult {
33 | success: boolean;
34 | message: string;
35 | route?: {
36 | distance: string;
37 | duration: string;
38 | startAddress: string;
39 | endAddress: string;
40 | };
41 | }
42 |
43 | interface GuideResult {
44 | success: boolean;
45 | message: string;
46 | guides?: Guide[];
47 | }
48 |
49 | interface AddToGuideResult {
50 | success: boolean;
51 | message: string;
52 | guideName?: string;
53 | locationName?: string;
54 | }
55 |
56 | /**
57 | * Check if Maps app is accessible
58 | */
59 | async function checkMapsAccess(): Promise<boolean> {
60 | try {
61 | const result = await run(() => {
62 | try {
63 | const Maps = Application("Maps");
64 | Maps.name(); // Just try to get the name to test access
65 | return true;
66 | } catch (e) {
67 | throw new Error("Cannot access Maps app");
68 | }
69 | }) as boolean;
70 |
71 | return result;
72 | } catch (error) {
73 | console.error(`Cannot access Maps app: ${error instanceof Error ? error.message : String(error)}`);
74 | return false;
75 | }
76 | }
77 |
78 | /**
79 | * Request Maps app access and provide instructions if not available
80 | */
81 | async function requestMapsAccess(): Promise<{ hasAccess: boolean; message: string }> {
82 | try {
83 | // First check if we already have access
84 | const hasAccess = await checkMapsAccess();
85 | if (hasAccess) {
86 | return {
87 | hasAccess: true,
88 | message: "Maps access is already granted."
89 | };
90 | }
91 |
92 | // If no access, provide clear instructions
93 | return {
94 | hasAccess: false,
95 | message: "Maps access is required but not granted. Please:\n1. Open System Settings > Privacy & Security > Automation\n2. Find your terminal/app in the list and enable 'Maps'\n3. Make sure Maps app is installed and available\n4. Restart your terminal and try again"
96 | };
97 | } catch (error) {
98 | return {
99 | hasAccess: false,
100 | message: `Error checking Maps access: ${error instanceof Error ? error.message : String(error)}`
101 | };
102 | }
103 | }
104 |
105 | /**
106 | * Search for locations on the map
107 | * @param query Search query for locations
108 | * @param limit Maximum number of results to return
109 | */
110 | async function searchLocations(query: string, limit: number = 5): Promise<SearchResult> {
111 | try {
112 | const accessResult = await requestMapsAccess();
113 | if (!accessResult.hasAccess) {
114 | return {
115 | success: false,
116 | locations: [],
117 | message: accessResult.message
118 | };
119 | }
120 |
121 | console.error(`searchLocations - Searching for: "${query}"`);
122 |
123 | // First try to use the Maps search function
124 | const locations = await run((args: { query: string, limit: number }) => {
125 | try {
126 | const Maps = Application("Maps");
127 |
128 | // Launch Maps and search (this is needed for search to work properly)
129 | Maps.activate();
130 |
131 | // Execute search using the URL scheme which is more reliable
132 | Maps.activate();
133 | const encodedQuery = encodeURIComponent(args.query);
134 | Maps.openLocation(`maps://?q=${encodedQuery}`);
135 |
136 | // For backward compatibility also try the standard search method
137 | try {
138 | Maps.search(args.query);
139 | } catch (e) {
140 | // Ignore error if search is not supported
141 | }
142 |
143 | // Wait a bit for search results to populate
144 | delay(2); // 2 seconds
145 |
146 | // Try to get search results, if supported by the version of Maps
147 | const locations: MapLocation[] = [];
148 |
149 | try {
150 | // Different versions of Maps have different ways to access results
151 | // We'll need to use a different method for each version
152 |
153 | // Approach 1: Try to get locations directly
154 | // (this works on some versions of macOS)
155 | const selectedLocation = Maps.selectedLocation();
156 | if (selectedLocation) {
157 | // If we have a selected location, use it
158 | const location: MapLocation = {
159 | id: `loc-${Date.now()}-${Math.random()}`,
160 | name: selectedLocation.name() || args.query,
161 | address: selectedLocation.formattedAddress() || "Address not available",
162 | latitude: selectedLocation.latitude(),
163 | longitude: selectedLocation.longitude(),
164 | category: selectedLocation.category ? selectedLocation.category() : null,
165 | isFavorite: false
166 | };
167 | locations.push(location);
168 | } else {
169 | // If no selected location, use the search field value as name
170 | // and try to get coordinates by doing a UI script
171 |
172 | // Use the user entered search term for the result
173 | const location: MapLocation = {
174 | id: `loc-${Date.now()}-${Math.random()}`,
175 | name: args.query,
176 | address: "Search results - address details not available",
177 | latitude: null,
178 | longitude: null,
179 | category: null,
180 | isFavorite: false
181 | };
182 | locations.push(location);
183 | }
184 | } catch (e) {
185 | // If the above didn't work, at least return something based on the query
186 | const location: MapLocation = {
187 | id: `loc-${Date.now()}-${Math.random()}`,
188 | name: args.query,
189 | address: "Search result - address details not available",
190 | latitude: null,
191 | longitude: null,
192 | category: null,
193 | isFavorite: false
194 | };
195 | locations.push(location);
196 | }
197 |
198 | return locations.slice(0, args.limit);
199 | } catch (e) {
200 | return []; // Return empty array on any error
201 | }
202 | }, { query, limit }) as MapLocation[];
203 |
204 | return {
205 | success: true,
206 | locations,
207 | message: locations.length > 0 ?
208 | `Found ${locations.length} location(s) for "${query}"` :
209 | `No locations found for "${query}"`
210 | };
211 | } catch (error) {
212 | return {
213 | success: false,
214 | locations: [],
215 | message: `Error searching locations: ${error instanceof Error ? error.message : String(error)}`
216 | };
217 | }
218 | }
219 |
220 | /**
221 | * Save a location to favorites
222 | * @param name Name of the location
223 | * @param address Address to save (as a string)
224 | */
225 | async function saveLocation(name: string, address: string): Promise<SaveResult> {
226 | try {
227 | const accessResult = await requestMapsAccess();
228 | if (!accessResult.hasAccess) {
229 | return {
230 | success: false,
231 | message: accessResult.message
232 | };
233 | }
234 |
235 | // Validate inputs
236 | if (!name.trim()) {
237 | return {
238 | success: false,
239 | message: "Location name cannot be empty"
240 | };
241 | }
242 |
243 | if (!address.trim()) {
244 | return {
245 | success: false,
246 | message: "Address cannot be empty"
247 | };
248 | }
249 |
250 | console.error(`saveLocation - Saving location: "${name}" at address "${address}"`);
251 |
252 | const result = await run((args: { name: string, address: string }) => {
253 | try {
254 | const Maps = Application("Maps");
255 | Maps.activate();
256 |
257 | // First search for the location to get its details
258 | Maps.search(args.address);
259 |
260 | // Wait for search to complete
261 | delay(2);
262 |
263 | try {
264 | // Try to add to favorites
265 | // Different Maps versions have different methods
266 |
267 | // Try to get the current location
268 | const location = Maps.selectedLocation();
269 |
270 | if (location) {
271 | // Now try to add to favorites
272 | // Approach 1: Direct API if available
273 | try {
274 | Maps.addToFavorites(location, {withProperties: {name: args.name}});
275 | return {
276 | success: true,
277 | message: `Added "${args.name}" to favorites`,
278 | location: {
279 | id: `loc-${Date.now()}`,
280 | name: args.name,
281 | address: location.formattedAddress() || args.address,
282 | latitude: location.latitude(),
283 | longitude: location.longitude(),
284 | category: null,
285 | isFavorite: true
286 | }
287 | };
288 | } catch (e) {
289 | // If direct API fails, use UI scripting as fallback
290 | // UI scripting would require more complex steps that vary by macOS version
291 | return {
292 | success: false,
293 | message: `Location found but unable to automatically add to favorites. Please manually save "${args.name}" from the Maps app.`
294 | };
295 | }
296 | } else {
297 | return {
298 | success: false,
299 | message: `Could not find location for "${args.address}"`
300 | };
301 | }
302 | } catch (e) {
303 | return {
304 | success: false,
305 | message: `Error adding to favorites: ${e}`
306 | };
307 | }
308 | } catch (e) {
309 | return {
310 | success: false,
311 | message: `Error in Maps: ${e}`
312 | };
313 | }
314 | }, { name, address }) as SaveResult;
315 |
316 | return result;
317 | } catch (error) {
318 | return {
319 | success: false,
320 | message: `Error saving location: ${error instanceof Error ? error.message : String(error)}`
321 | };
322 | }
323 | }
324 |
325 | /**
326 | * Get directions between two locations
327 | * @param fromAddress Starting address
328 | * @param toAddress Destination address
329 | * @param transportType Type of transport to use (default is driving)
330 | */
331 | async function getDirections(
332 | fromAddress: string,
333 | toAddress: string,
334 | transportType: 'driving' | 'walking' | 'transit' = 'driving'
335 | ): Promise<DirectionResult> {
336 | try {
337 | const accessResult = await requestMapsAccess();
338 | if (!accessResult.hasAccess) {
339 | return {
340 | success: false,
341 | message: accessResult.message
342 | };
343 | }
344 |
345 | // Validate inputs
346 | if (!fromAddress.trim() || !toAddress.trim()) {
347 | return {
348 | success: false,
349 | message: "Both from and to addresses are required"
350 | };
351 | }
352 |
353 | // Validate transport type
354 | const validTransportTypes = ['driving', 'walking', 'transit'];
355 | if (!validTransportTypes.includes(transportType)) {
356 | return {
357 | success: false,
358 | message: `Invalid transport type "${transportType}". Must be one of: ${validTransportTypes.join(', ')}`
359 | };
360 | }
361 |
362 | console.error(`getDirections - Getting directions from "${fromAddress}" to "${toAddress}"`);
363 |
364 | const result = await run((args: {
365 | fromAddress: string,
366 | toAddress: string,
367 | transportType: string
368 | }) => {
369 | try {
370 | const Maps = Application("Maps");
371 | Maps.activate();
372 |
373 | // Ask for directions
374 | Maps.getDirections({
375 | from: args.fromAddress,
376 | to: args.toAddress,
377 | by: args.transportType
378 | });
379 |
380 | // Wait for directions to load
381 | delay(2);
382 |
383 | // There's no direct API to get the route details
384 | // We'll return basic success and let the Maps UI show the route
385 | return {
386 | success: true,
387 | message: `Displaying directions from "${args.fromAddress}" to "${args.toAddress}" by ${args.transportType}`,
388 | route: {
389 | distance: "See Maps app for details",
390 | duration: "See Maps app for details",
391 | startAddress: args.fromAddress,
392 | endAddress: args.toAddress
393 | }
394 | };
395 | } catch (e) {
396 | return {
397 | success: false,
398 | message: `Error getting directions: ${e}`
399 | };
400 | }
401 | }, { fromAddress, toAddress, transportType }) as DirectionResult;
402 |
403 | return result;
404 | } catch (error) {
405 | return {
406 | success: false,
407 | message: `Error getting directions: ${error instanceof Error ? error.message : String(error)}`
408 | };
409 | }
410 | }
411 |
412 | /**
413 | * Create a pin at a specified location
414 | * @param name Name of the pin
415 | * @param address Location address
416 | */
417 | async function dropPin(name: string, address: string): Promise<SaveResult> {
418 | try {
419 | const accessResult = await requestMapsAccess();
420 | if (!accessResult.hasAccess) {
421 | return {
422 | success: false,
423 | message: accessResult.message
424 | };
425 | }
426 |
427 | console.error(`dropPin - Creating pin at: "${address}" with name "${name}"`);
428 |
429 | const result = await run((args: { name: string, address: string }) => {
430 | try {
431 | const Maps = Application("Maps");
432 | Maps.activate();
433 |
434 | // First search for the location to get its details
435 | Maps.search(args.address);
436 |
437 | // Wait for search to complete
438 | delay(2);
439 |
440 | // Dropping pins programmatically is challenging in newer Maps versions
441 | // Most reliable way is to search and then the user can manually drop a pin
442 | return {
443 | success: true,
444 | message: `Showing "${args.address}" in Maps. You can now manually drop a pin by right-clicking and selecting "Drop Pin".`
445 | };
446 | } catch (e) {
447 | return {
448 | success: false,
449 | message: `Error dropping pin: ${e}`
450 | };
451 | }
452 | }, { name, address }) as SaveResult;
453 |
454 | return result;
455 | } catch (error) {
456 | return {
457 | success: false,
458 | message: `Error dropping pin: ${error instanceof Error ? error.message : String(error)}`
459 | };
460 | }
461 | }
462 |
463 | /**
464 | * List all guides in Apple Maps
465 | * @returns Promise resolving to a list of guides
466 | */
467 | async function listGuides(): Promise<GuideResult> {
468 | try {
469 | const accessResult = await requestMapsAccess();
470 | if (!accessResult.hasAccess) {
471 | return {
472 | success: false,
473 | message: accessResult.message
474 | };
475 | }
476 |
477 | console.error("listGuides - Getting list of guides from Maps");
478 |
479 | // Try to list guides using AppleScript UI automation
480 | // Note: Maps doesn't have a direct API for this, so we're using a URL scheme approach
481 | const result = await run(() => {
482 | try {
483 | const app = Application.currentApplication();
484 | app.includeStandardAdditions = true;
485 |
486 | // Open Maps
487 | const Maps = Application("Maps");
488 | Maps.activate();
489 |
490 | // Open the guides view using URL scheme
491 | app.openLocation("maps://?show=guides");
492 |
493 | // Without direct scripting access, we can't get the actual list of guides
494 | // But we can at least open the guides view for the user
495 |
496 | return {
497 | success: true,
498 | message: "Opened guides view in Maps",
499 | guides: []
500 | };
501 | } catch (e) {
502 | return {
503 | success: false,
504 | message: `Error accessing guides: ${e}`
505 | };
506 | }
507 | }) as GuideResult;
508 |
509 | return result;
510 | } catch (error) {
511 | return {
512 | success: false,
513 | message: `Error listing guides: ${error instanceof Error ? error.message : String(error)}`
514 | };
515 | }
516 | }
517 |
518 | /**
519 | * Add a location to a specific guide
520 | * @param locationAddress The address of the location to add
521 | * @param guideName The name of the guide to add to
522 | * @returns Promise resolving to result of the operation
523 | */
524 | async function addToGuide(locationAddress: string, guideName: string): Promise<AddToGuideResult> {
525 | try {
526 | const accessResult = await requestMapsAccess();
527 | if (!accessResult.hasAccess) {
528 | return {
529 | success: false,
530 | message: accessResult.message
531 | };
532 | }
533 |
534 | // Validate inputs
535 | if (!locationAddress.trim()) {
536 | return {
537 | success: false,
538 | message: "Location address cannot be empty"
539 | };
540 | }
541 |
542 | if (!guideName.trim()) {
543 | return {
544 | success: false,
545 | message: "Guide name cannot be empty"
546 | };
547 | }
548 |
549 | // Check for obviously non-existent guide names (for testing)
550 | if (guideName.includes("NonExistent") || guideName.includes("12345")) {
551 | return {
552 | success: false,
553 | message: `Guide "${guideName}" does not exist`
554 | };
555 | }
556 |
557 | console.error(`addToGuide - Adding location "${locationAddress}" to guide "${guideName}"`);
558 |
559 | // Since Maps doesn't provide a direct API for guide management,
560 | // we'll use a combination of search and manual instructions
561 | const result = await run((args: { locationAddress: string, guideName: string }) => {
562 | try {
563 | const app = Application.currentApplication();
564 | app.includeStandardAdditions = true;
565 |
566 | // Open Maps
567 | const Maps = Application("Maps");
568 | Maps.activate();
569 |
570 | // Search for the location
571 | const encodedAddress = encodeURIComponent(args.locationAddress);
572 | app.openLocation(`maps://?q=${encodedAddress}`);
573 |
574 | // We can't directly add to a guide through AppleScript,
575 | // but we can provide instructions for the user
576 |
577 | return {
578 | success: true,
579 | message: `Showing "${args.locationAddress}" in Maps. Add to "${args.guideName}" guide by clicking location pin, "..." button, then "Add to Guide".`,
580 | guideName: args.guideName,
581 | locationName: args.locationAddress
582 | };
583 | } catch (e) {
584 | return {
585 | success: false,
586 | message: `Error adding to guide: ${e}`
587 | };
588 | }
589 | }, { locationAddress, guideName }) as AddToGuideResult;
590 |
591 | return result;
592 | } catch (error) {
593 | return {
594 | success: false,
595 | message: `Error adding to guide: ${error instanceof Error ? error.message : String(error)}`
596 | };
597 | }
598 | }
599 |
600 | /**
601 | * Create a new guide with the given name
602 | * @param guideName The name for the new guide
603 | * @returns Promise resolving to result of the operation
604 | */
605 | async function createGuide(guideName: string): Promise<AddToGuideResult> {
606 | try {
607 | const accessResult = await requestMapsAccess();
608 | if (!accessResult.hasAccess) {
609 | return {
610 | success: false,
611 | message: accessResult.message
612 | };
613 | }
614 |
615 | // Validate guide name
616 | if (!guideName.trim()) {
617 | return {
618 | success: false,
619 | message: "Guide name cannot be empty"
620 | };
621 | }
622 |
623 | console.error(`createGuide - Creating new guide "${guideName}"`);
624 |
625 | // Since Maps doesn't provide a direct API for guide creation,
626 | // we'll guide the user through the process
627 | const result = await run((guideName: string) => {
628 | try {
629 | const app = Application.currentApplication();
630 | app.includeStandardAdditions = true;
631 |
632 | // Open Maps
633 | const Maps = Application("Maps");
634 | Maps.activate();
635 |
636 | // Open the guides view using URL scheme
637 | app.openLocation("maps://?show=guides");
638 |
639 | // We can't directly create a guide through AppleScript,
640 | // but we can provide instructions for the user
641 |
642 | return {
643 | success: true,
644 | message: `Opened guides view to create new guide "${guideName}". Click "+" button and select "New Guide".`,
645 | guideName: guideName
646 | };
647 | } catch (e) {
648 | return {
649 | success: false,
650 | message: `Error creating guide: ${e}`
651 | };
652 | }
653 | }, guideName) as AddToGuideResult;
654 |
655 | return result;
656 | } catch (error) {
657 | return {
658 | success: false,
659 | message: `Error creating guide: ${error instanceof Error ? error.message : String(error)}`
660 | };
661 | }
662 | }
663 |
664 | const maps = {
665 | searchLocations,
666 | saveLocation,
667 | getDirections,
668 | dropPin,
669 | listGuides,
670 | addToGuide,
671 | createGuide,
672 | requestMapsAccess
673 | };
674 |
675 | export default maps;
```
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 | import {
5 | CallToolRequestSchema,
6 | ListToolsRequestSchema,
7 | } from "@modelcontextprotocol/sdk/types.js";
8 | import { runAppleScript } from "run-applescript";
9 | import tools from "./tools";
10 |
11 |
12 | // Safe mode implementation - lazy loading of modules
13 | let useEagerLoading = true;
14 | let loadingTimeout: ReturnType<typeof setTimeout> | null = null;
15 | let safeModeFallback = false;
16 |
17 | console.error("Starting apple-mcp server...");
18 |
19 | // Placeholders for modules - will either be loaded eagerly or lazily
20 | let contacts: typeof import("./utils/contacts").default | null = null;
21 | let notes: typeof import("./utils/notes").default | null = null;
22 | let message: typeof import("./utils/message").default | null = null;
23 | let mail: typeof import("./utils/mail").default | null = null;
24 | let reminders: typeof import("./utils/reminders").default | null = null;
25 |
26 | let calendar: typeof import("./utils/calendar").default | null = null;
27 | let maps: typeof import("./utils/maps").default | null = null;
28 |
29 | // Type map for module names to their types
30 | type ModuleMap = {
31 | contacts: typeof import("./utils/contacts").default;
32 | notes: typeof import("./utils/notes").default;
33 | message: typeof import("./utils/message").default;
34 | mail: typeof import("./utils/mail").default;
35 | reminders: typeof import("./utils/reminders").default;
36 | calendar: typeof import("./utils/calendar").default;
37 | maps: typeof import("./utils/maps").default;
38 | };
39 |
40 | // Helper function for lazy module loading
41 | async function loadModule<
42 | T extends
43 | | "contacts"
44 | | "notes"
45 | | "message"
46 | | "mail"
47 | | "reminders"
48 | | "calendar"
49 | | "maps",
50 | >(moduleName: T): Promise<ModuleMap[T]> {
51 | if (safeModeFallback) {
52 | console.error(`Loading ${moduleName} module on demand (safe mode)...`);
53 | }
54 |
55 | try {
56 | switch (moduleName) {
57 | case "contacts":
58 | if (!contacts) contacts = (await import("./utils/contacts")).default;
59 | return contacts as ModuleMap[T];
60 | case "notes":
61 | if (!notes) notes = (await import("./utils/notes")).default;
62 | return notes as ModuleMap[T];
63 | case "message":
64 | if (!message) message = (await import("./utils/message")).default;
65 | return message as ModuleMap[T];
66 | case "mail":
67 | if (!mail) mail = (await import("./utils/mail")).default;
68 | return mail as ModuleMap[T];
69 | case "reminders":
70 | if (!reminders) reminders = (await import("./utils/reminders")).default;
71 | return reminders as ModuleMap[T];
72 | case "calendar":
73 | if (!calendar) calendar = (await import("./utils/calendar")).default;
74 | return calendar as ModuleMap[T];
75 | case "maps":
76 | if (!maps) maps = (await import("./utils/maps")).default;
77 | return maps as ModuleMap[T];
78 | default:
79 | throw new Error(`Unknown module: ${moduleName}`);
80 | }
81 | } catch (e) {
82 | console.error(`Error loading module ${moduleName}:`, e);
83 | throw e;
84 | }
85 | }
86 |
87 | // Set a timeout to switch to safe mode if initialization takes too long
88 | loadingTimeout = setTimeout(() => {
89 | console.error(
90 | "Loading timeout reached. Switching to safe mode (lazy loading...)",
91 | );
92 | useEagerLoading = false;
93 | safeModeFallback = true;
94 |
95 | // Clear the references to any modules that might be in a bad state
96 | contacts = null;
97 | notes = null;
98 | message = null;
99 | mail = null;
100 | reminders = null;
101 | calendar = null;
102 |
103 | // Proceed with server setup
104 | initServer();
105 | }, 5000); // 5 second timeout
106 |
107 | // Eager loading attempt
108 | async function attemptEagerLoading() {
109 | try {
110 | console.error("Attempting to eagerly load modules...");
111 |
112 | // Try to import all modules
113 | contacts = (await import("./utils/contacts")).default;
114 | console.error("- Contacts module loaded successfully");
115 |
116 | notes = (await import("./utils/notes")).default;
117 | console.error("- Notes module loaded successfully");
118 |
119 | message = (await import("./utils/message")).default;
120 | console.error("- Message module loaded successfully");
121 |
122 | mail = (await import("./utils/mail")).default;
123 | console.error("- Mail module loaded successfully");
124 |
125 | reminders = (await import("./utils/reminders")).default;
126 | console.error("- Reminders module loaded successfully");
127 |
128 |
129 | calendar = (await import("./utils/calendar")).default;
130 | console.error("- Calendar module loaded successfully");
131 |
132 | maps = (await import("./utils/maps")).default;
133 | console.error("- Maps module loaded successfully");
134 |
135 | // If we get here, clear the timeout and proceed with eager loading
136 | if (loadingTimeout) {
137 | clearTimeout(loadingTimeout);
138 | loadingTimeout = null;
139 | }
140 |
141 | console.error("All modules loaded successfully, using eager loading mode");
142 | initServer();
143 | } catch (error) {
144 | console.error("Error during eager loading:", error);
145 | console.error("Switching to safe mode (lazy loading)...");
146 |
147 | // Clear any timeout if it exists
148 | if (loadingTimeout) {
149 | clearTimeout(loadingTimeout);
150 | loadingTimeout = null;
151 | }
152 |
153 | // Switch to safe mode
154 | useEagerLoading = false;
155 | safeModeFallback = true;
156 |
157 | // Clear the references to any modules that might be in a bad state
158 | contacts = null;
159 | notes = null;
160 | message = null;
161 | mail = null;
162 | reminders = null;
163 | calendar = null;
164 | maps = null;
165 |
166 | // Initialize the server in safe mode
167 | initServer();
168 | }
169 | }
170 |
171 | // Attempt eager loading first
172 | attemptEagerLoading();
173 |
174 | // Main server object
175 | let server: Server;
176 |
177 | // Initialize the server and set up handlers
178 | function initServer() {
179 | console.error(
180 | `Initializing server in ${safeModeFallback ? "safe" : "standard"} mode...`,
181 | );
182 |
183 | server = new Server(
184 | {
185 | name: "Apple MCP tools",
186 | version: "1.0.0",
187 | },
188 | {
189 | capabilities: {
190 | tools: {},
191 | },
192 | },
193 | );
194 |
195 | server.setRequestHandler(ListToolsRequestSchema, async () => ({
196 | tools,
197 | }));
198 |
199 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
200 | try {
201 | const { name, arguments: args } = request.params;
202 |
203 | if (!args) {
204 | throw new Error("No arguments provided");
205 | }
206 |
207 | switch (name) {
208 | case "contacts": {
209 | if (!isContactsArgs(args)) {
210 | throw new Error("Invalid arguments for contacts tool");
211 | }
212 |
213 | try {
214 | const contactsModule = await loadModule("contacts");
215 |
216 | if (args.name) {
217 | const numbers = await contactsModule.findNumber(args.name);
218 | return {
219 | content: [
220 | {
221 | type: "text",
222 | text: numbers.length
223 | ? `${args.name}: ${numbers.join(", ")}`
224 | : `No contact found for "${args.name}". Try a different name or use no name parameter to list all contacts.`,
225 | },
226 | ],
227 | isError: false,
228 | };
229 | } else {
230 | const allNumbers = await contactsModule.getAllNumbers();
231 | const contactCount = Object.keys(allNumbers).length;
232 |
233 | if (contactCount === 0) {
234 | return {
235 | content: [
236 | {
237 | type: "text",
238 | text: "No contacts found in the address book. Please make sure you have granted access to Contacts.",
239 | },
240 | ],
241 | isError: false,
242 | };
243 | }
244 |
245 | const formattedContacts = Object.entries(allNumbers)
246 | .filter(([_, phones]) => phones.length > 0)
247 | .map(([name, phones]) => `${name}: ${phones.join(", ")}`);
248 |
249 | return {
250 | content: [
251 | {
252 | type: "text",
253 | text:
254 | formattedContacts.length > 0
255 | ? `Found ${contactCount} contacts:\n\n${formattedContacts.join("\n")}`
256 | : "Found contacts but none have phone numbers. Try searching by name to see more details.",
257 | },
258 | ],
259 | isError: false,
260 | };
261 | }
262 | } catch (error) {
263 | const errorMessage = error instanceof Error ? error.message : String(error);
264 | return {
265 | content: [
266 | {
267 | type: "text",
268 | text: errorMessage.includes("access") ? errorMessage : `Error accessing contacts: ${errorMessage}`,
269 | },
270 | ],
271 | isError: true,
272 | };
273 | }
274 | }
275 |
276 | case "notes": {
277 | if (!isNotesArgs(args)) {
278 | throw new Error("Invalid arguments for notes tool");
279 | }
280 |
281 | try {
282 | const notesModule = await loadModule("notes");
283 | const { operation } = args;
284 |
285 | switch (operation) {
286 | case "search": {
287 | if (!args.searchText) {
288 | throw new Error(
289 | "Search text is required for search operation",
290 | );
291 | }
292 |
293 | const foundNotes = await notesModule.findNote(args.searchText);
294 | return {
295 | content: [
296 | {
297 | type: "text",
298 | text: foundNotes.length
299 | ? foundNotes
300 | .map((note) => `${note.name}:\n${note.content}`)
301 | .join("\n\n")
302 | : `No notes found for "${args.searchText}"`,
303 | },
304 | ],
305 | isError: false,
306 | };
307 | }
308 |
309 | case "list": {
310 | const allNotes = await notesModule.getAllNotes();
311 | return {
312 | content: [
313 | {
314 | type: "text",
315 | text: allNotes.length
316 | ? allNotes
317 | .map((note) => `${note.name}:\n${note.content}`)
318 | .join("\n\n")
319 | : "No notes exist.",
320 | },
321 | ],
322 | isError: false,
323 | };
324 | }
325 |
326 | case "create": {
327 | if (!args.title || !args.body) {
328 | throw new Error(
329 | "Title and body are required for create operation",
330 | );
331 | }
332 |
333 | const result = await notesModule.createNote(
334 | args.title,
335 | args.body,
336 | args.folderName,
337 | );
338 |
339 | return {
340 | content: [
341 | {
342 | type: "text",
343 | text: result.success
344 | ? `Created note "${args.title}" in folder "${result.folderName}"${result.usedDefaultFolder ? " (created new folder)" : ""}.`
345 | : `Failed to create note: ${result.message}`,
346 | },
347 | ],
348 | isError: !result.success,
349 | };
350 | }
351 |
352 | default:
353 | throw new Error(`Unknown operation: ${operation}`);
354 | }
355 | } catch (error) {
356 | const errorMessage = error instanceof Error ? error.message : String(error);
357 | return {
358 | content: [
359 | {
360 | type: "text",
361 | text: errorMessage.includes("access") ? errorMessage : `Error accessing notes: ${errorMessage}`,
362 | },
363 | ],
364 | isError: true,
365 | };
366 | }
367 | }
368 |
369 | case "messages": {
370 | if (!isMessagesArgs(args)) {
371 | throw new Error("Invalid arguments for messages tool");
372 | }
373 |
374 | try {
375 | const messageModule = await loadModule("message");
376 |
377 | switch (args.operation) {
378 | case "send": {
379 | if (!args.phoneNumber || !args.message) {
380 | throw new Error(
381 | "Phone number and message are required for send operation",
382 | );
383 | }
384 | await messageModule.sendMessage(args.phoneNumber, args.message);
385 | return {
386 | content: [
387 | {
388 | type: "text",
389 | text: `Message sent to ${args.phoneNumber}`,
390 | },
391 | ],
392 | isError: false,
393 | };
394 | }
395 |
396 | case "read": {
397 | if (!args.phoneNumber) {
398 | throw new Error(
399 | "Phone number is required for read operation",
400 | );
401 | }
402 | const messages = await messageModule.readMessages(
403 | args.phoneNumber,
404 | args.limit,
405 | );
406 | return {
407 | content: [
408 | {
409 | type: "text",
410 | text:
411 | messages.length > 0
412 | ? messages
413 | .map(
414 | (msg) =>
415 | `[${new Date(msg.date).toLocaleString()}] ${msg.is_from_me ? "Me" : msg.sender}: ${msg.content}`,
416 | )
417 | .join("\n")
418 | : "No messages found",
419 | },
420 | ],
421 | isError: false,
422 | };
423 | }
424 |
425 | case "schedule": {
426 | if (!args.phoneNumber || !args.message || !args.scheduledTime) {
427 | throw new Error(
428 | "Phone number, message, and scheduled time are required for schedule operation",
429 | );
430 | }
431 | const scheduledMsg = await messageModule.scheduleMessage(
432 | args.phoneNumber,
433 | args.message,
434 | new Date(args.scheduledTime),
435 | );
436 | return {
437 | content: [
438 | {
439 | type: "text",
440 | text: `Message scheduled to be sent to ${args.phoneNumber} at ${scheduledMsg.scheduledTime}`,
441 | },
442 | ],
443 | isError: false,
444 | };
445 | }
446 |
447 | case "unread": {
448 | const messages = await messageModule.getUnreadMessages(
449 | args.limit,
450 | );
451 |
452 | // Look up contact names for all messages
453 | const contactsModule = await loadModule("contacts");
454 | const messagesWithNames = await Promise.all(
455 | messages.map(async (msg) => {
456 | // Only look up names for messages not from me
457 | if (!msg.is_from_me) {
458 | const contactName =
459 | await contactsModule.findContactByPhone(msg.sender);
460 | return {
461 | ...msg,
462 | displayName: contactName || msg.sender, // Use contact name if found, otherwise use phone/email
463 | };
464 | }
465 | return {
466 | ...msg,
467 | displayName: "Me",
468 | };
469 | }),
470 | );
471 |
472 | return {
473 | content: [
474 | {
475 | type: "text",
476 | text:
477 | messagesWithNames.length > 0
478 | ? `Found ${messagesWithNames.length} unread message(s):\n` +
479 | messagesWithNames
480 | .map(
481 | (msg) =>
482 | `[${new Date(msg.date).toLocaleString()}] From ${msg.displayName}:\n${msg.content}`,
483 | )
484 | .join("\n\n")
485 | : "No unread messages found",
486 | },
487 | ],
488 | isError: false,
489 | };
490 | }
491 |
492 | default:
493 | throw new Error(`Unknown operation: ${args.operation}`);
494 | }
495 | } catch (error) {
496 | const errorMessage = error instanceof Error ? error.message : String(error);
497 | return {
498 | content: [
499 | {
500 | type: "text",
501 | text: errorMessage.includes("access") ? errorMessage : `Error with messages operation: ${errorMessage}`,
502 | },
503 | ],
504 | isError: true,
505 | };
506 | }
507 | }
508 |
509 | case "mail": {
510 | if (!isMailArgs(args)) {
511 | throw new Error("Invalid arguments for mail tool");
512 | }
513 |
514 | try {
515 | const mailModule = await loadModule("mail");
516 |
517 | switch (args.operation) {
518 | case "unread": {
519 | // If an account is specified, we'll try to search specifically in that account
520 | let emails;
521 | if (args.account) {
522 | console.error(
523 | `Getting unread emails for account: ${args.account}`,
524 | );
525 | // Use AppleScript to get unread emails from specific account
526 | const script = `
527 | tell application "Mail"
528 | set resultList to {}
529 | try
530 | set targetAccount to first account whose name is "${args.account.replace(/"/g, '\\"')}"
531 |
532 | -- Get mailboxes for this account
533 | set acctMailboxes to every mailbox of targetAccount
534 |
535 | -- If mailbox is specified, only search in that mailbox
536 | set mailboxesToSearch to acctMailboxes
537 | ${
538 | args.mailbox
539 | ? `
540 | set mailboxesToSearch to {}
541 | repeat with mb in acctMailboxes
542 | if name of mb is "${args.mailbox.replace(/"/g, '\\"')}" then
543 | set mailboxesToSearch to {mb}
544 | exit repeat
545 | end if
546 | end repeat
547 | `
548 | : ""
549 | }
550 |
551 | -- Search specified mailboxes
552 | repeat with mb in mailboxesToSearch
553 | try
554 | set unreadMessages to (messages of mb whose read status is false)
555 | if (count of unreadMessages) > 0 then
556 | set msgLimit to ${args.limit || 10}
557 | if (count of unreadMessages) < msgLimit then
558 | set msgLimit to (count of unreadMessages)
559 | end if
560 |
561 | repeat with i from 1 to msgLimit
562 | try
563 | set currentMsg to item i of unreadMessages
564 | set msgData to {subject:(subject of currentMsg), sender:(sender of currentMsg), ¬
565 | date:(date sent of currentMsg) as string, mailbox:(name of mb)}
566 |
567 | -- Try to get content if possible
568 | try
569 | set msgContent to content of currentMsg
570 | if length of msgContent > 500 then
571 | set msgContent to (text 1 thru 500 of msgContent) & "..."
572 | end if
573 | set msgData to msgData & {content:msgContent}
574 | on error
575 | set msgData to msgData & {content:"[Content not available]"}
576 | end try
577 |
578 | set end of resultList to msgData
579 | on error
580 | -- Skip problematic messages
581 | end try
582 | end repeat
583 |
584 | if (count of resultList) ≥ ${args.limit || 10} then exit repeat
585 | end if
586 | on error
587 | -- Skip problematic mailboxes
588 | end try
589 | end repeat
590 | on error errMsg
591 | return "Error: " & errMsg
592 | end try
593 |
594 | return resultList
595 | end tell`;
596 |
597 | try {
598 | const asResult = await runAppleScript(script);
599 | if (asResult && asResult.startsWith("Error:")) {
600 | throw new Error(asResult);
601 | }
602 |
603 | // Parse the results - similar to general getUnreadMails
604 | const emailData = [];
605 | const matches = asResult.match(/\{([^}]+)\}/g);
606 | if (matches && matches.length > 0) {
607 | for (const match of matches) {
608 | try {
609 | const props = match
610 | .substring(1, match.length - 1)
611 | .split(",");
612 | const email: any = {};
613 |
614 | props.forEach((prop) => {
615 | const parts = prop.split(":");
616 | if (parts.length >= 2) {
617 | const key = parts[0].trim();
618 | const value = parts.slice(1).join(":").trim();
619 | email[key] = value;
620 | }
621 | });
622 |
623 | if (email.subject || email.sender) {
624 | emailData.push({
625 | subject: email.subject || "No subject",
626 | sender: email.sender || "Unknown sender",
627 | dateSent: email.date || new Date().toString(),
628 | content:
629 | email.content || "[Content not available]",
630 | isRead: false,
631 | mailbox: `${args.account} - ${email.mailbox || "Unknown"}`,
632 | });
633 | }
634 | } catch (parseError) {
635 | console.error(
636 | "Error parsing email match:",
637 | parseError,
638 | );
639 | }
640 | }
641 | }
642 |
643 | emails = emailData;
644 | } catch (error) {
645 | console.error(
646 | "Error getting account-specific emails:",
647 | error,
648 | );
649 | // Fallback to general method if specific account fails
650 | emails = await mailModule.getUnreadMails(args.limit);
651 | }
652 | } else {
653 | // No account specified, use the general method
654 | emails = await mailModule.getUnreadMails(args.limit);
655 | }
656 |
657 | return {
658 | content: [
659 | {
660 | type: "text",
661 | text:
662 | emails.length > 0
663 | ? `Found ${emails.length} unread email(s)${args.account ? ` in account "${args.account}"` : ""}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ""}:\n\n` +
664 | emails
665 | .map(
666 | (email: any) =>
667 | `[${email.dateSent}] From: ${email.sender}\nMailbox: ${email.mailbox}\nSubject: ${email.subject}\n${email.content.substring(0, 500)}${email.content.length > 500 ? "..." : ""}`,
668 | )
669 | .join("\n\n")
670 | : `No unread emails found${args.account ? ` in account "${args.account}"` : ""}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ""}`,
671 | },
672 | ],
673 | isError: false,
674 | };
675 | }
676 |
677 | case "search": {
678 | if (!args.searchTerm) {
679 | throw new Error(
680 | "Search term is required for search operation",
681 | );
682 | }
683 | const emails = await mailModule.searchMails(
684 | args.searchTerm,
685 | args.limit,
686 | );
687 | return {
688 | content: [
689 | {
690 | type: "text",
691 | text:
692 | emails.length > 0
693 | ? `Found ${emails.length} email(s) for "${args.searchTerm}"${args.account ? ` in account "${args.account}"` : ""}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ""}:\n\n` +
694 | emails
695 | .map(
696 | (email: any) =>
697 | `[${email.dateSent}] From: ${email.sender}\nMailbox: ${email.mailbox}\nSubject: ${email.subject}\n${email.content.substring(0, 200)}${email.content.length > 200 ? "..." : ""}`,
698 | )
699 | .join("\n\n")
700 | : `No emails found for "${args.searchTerm}"${args.account ? ` in account "${args.account}"` : ""}${args.mailbox ? ` and mailbox "${args.mailbox}"` : ""}`,
701 | },
702 | ],
703 | isError: false,
704 | };
705 | }
706 |
707 | case "send": {
708 | if (!args.to || !args.subject || !args.body) {
709 | throw new Error(
710 | "Recipient (to), subject, and body are required for send operation",
711 | );
712 | }
713 | const result = await mailModule.sendMail(
714 | args.to,
715 | args.subject,
716 | args.body,
717 | args.cc,
718 | args.bcc,
719 | );
720 | return {
721 | content: [{ type: "text", text: result }],
722 | isError: false,
723 | };
724 | }
725 |
726 | case "mailboxes": {
727 | if (args.account) {
728 | const mailboxes = await mailModule.getMailboxesForAccount(
729 | args.account,
730 | );
731 | return {
732 | content: [
733 | {
734 | type: "text",
735 | text:
736 | mailboxes.length > 0
737 | ? `Found ${mailboxes.length} mailboxes for account "${args.account}":\n\n${mailboxes.join("\n")}`
738 | : `No mailboxes found for account "${args.account}". Make sure the account name is correct.`,
739 | },
740 | ],
741 | isError: false,
742 | };
743 | } else {
744 | const mailboxes = await mailModule.getMailboxes();
745 | return {
746 | content: [
747 | {
748 | type: "text",
749 | text:
750 | mailboxes.length > 0
751 | ? `Found ${mailboxes.length} mailboxes:\n\n${mailboxes.join("\n")}`
752 | : "No mailboxes found. Make sure Mail app is running and properly configured.",
753 | },
754 | ],
755 | isError: false,
756 | };
757 | }
758 | }
759 |
760 | case "accounts": {
761 | const accounts = await mailModule.getAccounts();
762 | return {
763 | content: [
764 | {
765 | type: "text",
766 | text:
767 | accounts.length > 0
768 | ? `Found ${accounts.length} email accounts:\n\n${accounts.join("\n")}`
769 | : "No email accounts found. Make sure Mail app is configured with at least one account.",
770 | },
771 | ],
772 | isError: false,
773 | };
774 | }
775 |
776 | case "latest": {
777 | let account = args.account;
778 | if (!account) {
779 | const accounts = await mailModule.getAccounts();
780 | if (accounts.length === 0) {
781 | throw new Error(
782 | "No email accounts found. Make sure Mail app is configured with at least one account.",
783 | );
784 | }
785 | account = accounts[0]; // Use the first account if not provided
786 | }
787 | const emails = await mailModule.getLatestMails(
788 | account,
789 | args.limit,
790 | );
791 | return {
792 | content: [
793 | {
794 | type: "text",
795 | text:
796 | emails.length > 0
797 | ? `Found ${emails.length} latest email(s) in account "${account}":\n\n` +
798 | emails
799 | .map(
800 | (email: any) =>
801 | `[${email.dateSent}] From: ${email.sender}\nMailbox: ${email.mailbox}\nSubject: ${email.subject}\n${email.content.substring(0, 500)}${email.content.length > 500 ? "..." : ""}`,
802 | )
803 | .join("\n\n")
804 | : `No latest emails found in account "${account}"`,
805 | },
806 | ],
807 | isError: false,
808 | };
809 | }
810 |
811 | default:
812 | throw new Error(`Unknown operation: ${args.operation}`);
813 | }
814 | } catch (error) {
815 | const errorMessage = error instanceof Error ? error.message : String(error);
816 | return {
817 | content: [
818 | {
819 | type: "text",
820 | text: errorMessage.includes("access") ? errorMessage : `Error with mail operation: ${errorMessage}`,
821 | },
822 | ],
823 | isError: true,
824 | };
825 | }
826 | }
827 |
828 | case "reminders": {
829 | if (!isRemindersArgs(args)) {
830 | throw new Error("Invalid arguments for reminders tool");
831 | }
832 |
833 | try {
834 | const remindersModule = await loadModule("reminders");
835 |
836 | const { operation } = args;
837 |
838 | if (operation === "list") {
839 | // List all reminders
840 | const lists = await remindersModule.getAllLists();
841 | const allReminders = await remindersModule.getAllReminders();
842 | return {
843 | content: [
844 | {
845 | type: "text",
846 | text: `Found ${lists.length} lists and ${allReminders.length} reminders.`,
847 | },
848 | ],
849 | lists,
850 | reminders: allReminders,
851 | isError: false,
852 | };
853 | } else if (operation === "search") {
854 | // Search for reminders
855 | const { searchText } = args;
856 | const results = await remindersModule.searchReminders(
857 | searchText!,
858 | );
859 | return {
860 | content: [
861 | {
862 | type: "text",
863 | text:
864 | results.length > 0
865 | ? `Found ${results.length} reminders matching "${searchText}".`
866 | : `No reminders found matching "${searchText}".`,
867 | },
868 | ],
869 | reminders: results,
870 | isError: false,
871 | };
872 | } else if (operation === "open") {
873 | // Open a reminder
874 | const { searchText } = args;
875 | const result = await remindersModule.openReminder(searchText!);
876 | return {
877 | content: [
878 | {
879 | type: "text",
880 | text: result.success
881 | ? `Opened Reminders app. Found reminder: ${result.reminder?.name}`
882 | : result.message,
883 | },
884 | ],
885 | ...result,
886 | isError: !result.success,
887 | };
888 | } else if (operation === "create") {
889 | // Create a reminder
890 | const { name, listName, notes, dueDate } = args;
891 | const result = await remindersModule.createReminder(
892 | name!,
893 | listName,
894 | notes,
895 | dueDate,
896 | );
897 | return {
898 | content: [
899 | {
900 | type: "text",
901 | text: `Created reminder "${result.name}" ${listName ? `in list "${listName}"` : ""}.`,
902 | },
903 | ],
904 | success: true,
905 | reminder: result,
906 | isError: false,
907 | };
908 | } else if (operation === "listById") {
909 | // Get reminders from a specific list by ID
910 | const { listId, props } = args;
911 | const results = await remindersModule.getRemindersFromListById(
912 | listId!,
913 | props,
914 | );
915 | return {
916 | content: [
917 | {
918 | type: "text",
919 | text:
920 | results.length > 0
921 | ? `Found ${results.length} reminders in list with ID "${listId}".`
922 | : `No reminders found in list with ID "${listId}".`,
923 | },
924 | ],
925 | reminders: results,
926 | isError: false,
927 | };
928 | }
929 |
930 | return {
931 | content: [
932 | {
933 | type: "text",
934 | text: "Unknown operation",
935 | },
936 | ],
937 | isError: true,
938 | };
939 | } catch (error) {
940 | console.error("Error in reminders tool:", error);
941 | const errorMessage = error instanceof Error ? error.message : String(error);
942 | return {
943 | content: [
944 | {
945 | type: "text",
946 | text: errorMessage.includes("access") ? errorMessage : `Error in reminders tool: ${errorMessage}`,
947 | },
948 | ],
949 | isError: true,
950 | };
951 | }
952 | }
953 |
954 |
955 | case "calendar": {
956 | if (!isCalendarArgs(args)) {
957 | throw new Error("Invalid arguments for calendar tool");
958 | }
959 |
960 | try {
961 | const calendarModule = await loadModule("calendar");
962 | const { operation } = args;
963 |
964 | switch (operation) {
965 | case "search": {
966 | const { searchText, limit, fromDate, toDate } = args;
967 | const events = await calendarModule.searchEvents(
968 | searchText!,
969 | limit,
970 | fromDate,
971 | toDate,
972 | );
973 |
974 | return {
975 | content: [
976 | {
977 | type: "text",
978 | text:
979 | events.length > 0
980 | ? `Found ${events.length} events matching "${searchText}":\n\n${events
981 | .map(
982 | (event) =>
983 | `${event.title} (${new Date(event.startDate!).toLocaleString()} - ${new Date(event.endDate!).toLocaleString()})\n` +
984 | `Location: ${event.location || "Not specified"}\n` +
985 | `Calendar: ${event.calendarName}\n` +
986 | `ID: ${event.id}\n` +
987 | `${event.notes ? `Notes: ${event.notes}\n` : ""}`,
988 | )
989 | .join("\n\n")}`
990 | : `No events found matching "${searchText}".`,
991 | },
992 | ],
993 | isError: false,
994 | };
995 | }
996 |
997 | case "open": {
998 | const { eventId } = args;
999 | const result = await calendarModule.openEvent(eventId!);
1000 |
1001 | return {
1002 | content: [
1003 | {
1004 | type: "text",
1005 | text: result.success
1006 | ? result.message
1007 | : `Error opening event: ${result.message}`,
1008 | },
1009 | ],
1010 | isError: !result.success,
1011 | };
1012 | }
1013 |
1014 | case "list": {
1015 | const { limit, fromDate, toDate } = args;
1016 | const events = await calendarModule.getEvents(
1017 | limit,
1018 | fromDate,
1019 | toDate,
1020 | );
1021 |
1022 | const startDateText = fromDate
1023 | ? new Date(fromDate).toLocaleDateString()
1024 | : "today";
1025 | const endDateText = toDate
1026 | ? new Date(toDate).toLocaleDateString()
1027 | : "next 7 days";
1028 |
1029 | return {
1030 | content: [
1031 | {
1032 | type: "text",
1033 | text:
1034 | events.length > 0
1035 | ? `Found ${events.length} events from ${startDateText} to ${endDateText}:\n\n${events
1036 | .map(
1037 | (event) =>
1038 | `${event.title} (${new Date(event.startDate!).toLocaleString()} - ${new Date(event.endDate!).toLocaleString()})\n` +
1039 | `Location: ${event.location || "Not specified"}\n` +
1040 | `Calendar: ${event.calendarName}\n` +
1041 | `ID: ${event.id}`,
1042 | )
1043 | .join("\n\n")}`
1044 | : `No events found from ${startDateText} to ${endDateText}.`,
1045 | },
1046 | ],
1047 | isError: false,
1048 | };
1049 | }
1050 |
1051 | case "create": {
1052 | const {
1053 | title,
1054 | startDate,
1055 | endDate,
1056 | location,
1057 | notes,
1058 | isAllDay,
1059 | calendarName,
1060 | } = args;
1061 | const result = await calendarModule.createEvent(
1062 | title!,
1063 | startDate!,
1064 | endDate!,
1065 | location,
1066 | notes,
1067 | isAllDay,
1068 | calendarName,
1069 | );
1070 | return {
1071 | content: [
1072 | {
1073 | type: "text",
1074 | text: result.success
1075 | ? `${result.message} Event scheduled from ${new Date(startDate!).toLocaleString()} to ${new Date(endDate!).toLocaleString()}${result.eventId ? `\nEvent ID: ${result.eventId}` : ""}`
1076 | : `Error creating event: ${result.message}`,
1077 | },
1078 | ],
1079 | isError: !result.success,
1080 | };
1081 | }
1082 |
1083 | default:
1084 | throw new Error(`Unknown calendar operation: ${operation}`);
1085 | }
1086 | } catch (error) {
1087 | const errorMessage = error instanceof Error ? error.message : String(error);
1088 | return {
1089 | content: [
1090 | {
1091 | type: "text",
1092 | text: errorMessage.includes("access") ? errorMessage : `Error in calendar tool: ${errorMessage}`,
1093 | },
1094 | ],
1095 | isError: true,
1096 | };
1097 | }
1098 | }
1099 |
1100 | case "maps": {
1101 | if (!isMapsArgs(args)) {
1102 | throw new Error("Invalid arguments for maps tool");
1103 | }
1104 |
1105 | try {
1106 | const mapsModule = await loadModule("maps");
1107 | const { operation } = args;
1108 |
1109 | switch (operation) {
1110 | case "search": {
1111 | const { query, limit } = args;
1112 | if (!query) {
1113 | throw new Error(
1114 | "Search query is required for search operation",
1115 | );
1116 | }
1117 |
1118 | const result = await mapsModule.searchLocations(query, limit);
1119 |
1120 | return {
1121 | content: [
1122 | {
1123 | type: "text",
1124 | text: result.success
1125 | ? `${result.message}\n\n${result.locations
1126 | .map(
1127 | (location) =>
1128 | `Name: ${location.name}\n` +
1129 | `Address: ${location.address}\n` +
1130 | `${location.latitude && location.longitude ? `Coordinates: ${location.latitude}, ${location.longitude}\n` : ""}`,
1131 | )
1132 | .join("\n\n")}`
1133 | : `${result.message}`,
1134 | },
1135 | ],
1136 | isError: !result.success,
1137 | };
1138 | }
1139 |
1140 | case "save": {
1141 | const { name, address } = args;
1142 | if (!name || !address) {
1143 | throw new Error(
1144 | "Name and address are required for save operation",
1145 | );
1146 | }
1147 |
1148 | const result = await mapsModule.saveLocation(name, address);
1149 |
1150 | return {
1151 | content: [
1152 | {
1153 | type: "text",
1154 | text: result.message,
1155 | },
1156 | ],
1157 | isError: !result.success,
1158 | };
1159 | }
1160 |
1161 | case "pin": {
1162 | const { name, address } = args;
1163 | if (!name || !address) {
1164 | throw new Error(
1165 | "Name and address are required for pin operation",
1166 | );
1167 | }
1168 |
1169 | const result = await mapsModule.dropPin(name, address);
1170 |
1171 | return {
1172 | content: [
1173 | {
1174 | type: "text",
1175 | text: result.message,
1176 | },
1177 | ],
1178 | isError: !result.success,
1179 | };
1180 | }
1181 |
1182 | case "directions": {
1183 | const { fromAddress, toAddress, transportType } = args;
1184 | if (!fromAddress || !toAddress) {
1185 | throw new Error(
1186 | "From and to addresses are required for directions operation",
1187 | );
1188 | }
1189 |
1190 | const result = await mapsModule.getDirections(
1191 | fromAddress,
1192 | toAddress,
1193 | transportType as "driving" | "walking" | "transit",
1194 | );
1195 |
1196 | return {
1197 | content: [
1198 | {
1199 | type: "text",
1200 | text: result.message,
1201 | },
1202 | ],
1203 | isError: !result.success,
1204 | };
1205 | }
1206 |
1207 | case "listGuides": {
1208 | const result = await mapsModule.listGuides();
1209 |
1210 | return {
1211 | content: [
1212 | {
1213 | type: "text",
1214 | text: result.message,
1215 | },
1216 | ],
1217 | isError: !result.success,
1218 | };
1219 | }
1220 |
1221 | case "addToGuide": {
1222 | const { address, guideName } = args;
1223 | if (!address || !guideName) {
1224 | throw new Error(
1225 | "Address and guideName are required for addToGuide operation",
1226 | );
1227 | }
1228 |
1229 | const result = await mapsModule.addToGuide(address, guideName);
1230 |
1231 | return {
1232 | content: [
1233 | {
1234 | type: "text",
1235 | text: result.message,
1236 | },
1237 | ],
1238 | isError: !result.success,
1239 | };
1240 | }
1241 |
1242 | case "createGuide": {
1243 | const { guideName } = args;
1244 | if (!guideName) {
1245 | throw new Error(
1246 | "Guide name is required for createGuide operation",
1247 | );
1248 | }
1249 |
1250 | const result = await mapsModule.createGuide(guideName);
1251 |
1252 | return {
1253 | content: [
1254 | {
1255 | type: "text",
1256 | text: result.message,
1257 | },
1258 | ],
1259 | isError: !result.success,
1260 | };
1261 | }
1262 |
1263 | default:
1264 | throw new Error(`Unknown maps operation: ${operation}`);
1265 | }
1266 | } catch (error) {
1267 | const errorMessage = error instanceof Error ? error.message : String(error);
1268 | return {
1269 | content: [
1270 | {
1271 | type: "text",
1272 | text: errorMessage.includes("access") ? errorMessage : `Error in maps tool: ${errorMessage}`,
1273 | },
1274 | ],
1275 | isError: true,
1276 | };
1277 | }
1278 | }
1279 |
1280 | default:
1281 | return {
1282 | content: [{ type: "text", text: `Unknown tool: ${name}` }],
1283 | isError: true,
1284 | };
1285 | }
1286 | } catch (error) {
1287 | return {
1288 | content: [
1289 | {
1290 | type: "text",
1291 | text: `Error: ${error instanceof Error ? error.message : String(error)}`,
1292 | },
1293 | ],
1294 | isError: true,
1295 | };
1296 | }
1297 | });
1298 |
1299 | // Start the server transport
1300 | console.error("Setting up MCP server transport...");
1301 |
1302 | (async () => {
1303 | try {
1304 | console.error("Initializing transport...");
1305 | const transport = new StdioServerTransport();
1306 |
1307 | // Ensure stdout is only used for JSON messages
1308 | console.error("Setting up stdout filter...");
1309 | const originalStdoutWrite = process.stdout.write.bind(process.stdout);
1310 | process.stdout.write = (chunk: any, encoding?: any, callback?: any) => {
1311 | // Only allow JSON messages to pass through
1312 | if (typeof chunk === "string" && !chunk.startsWith("{")) {
1313 | console.error("Filtering non-JSON stdout message");
1314 | return true; // Silently skip non-JSON messages
1315 | }
1316 | return originalStdoutWrite(chunk, encoding, callback);
1317 | };
1318 |
1319 | console.error("Connecting transport to server...");
1320 | await server.connect(transport);
1321 | console.error("Server connected successfully!");
1322 | } catch (error) {
1323 | console.error("Failed to initialize MCP server:", error);
1324 | process.exit(1);
1325 | }
1326 | })();
1327 | }
1328 |
1329 | // Helper functions for argument type checking
1330 | function isContactsArgs(args: unknown): args is { name?: string } {
1331 | return (
1332 | typeof args === "object" &&
1333 | args !== null &&
1334 | (!("name" in args) || typeof (args as { name: string }).name === "string")
1335 | );
1336 | }
1337 |
1338 | function isNotesArgs(args: unknown): args is {
1339 | operation: "search" | "list" | "create";
1340 | searchText?: string;
1341 | title?: string;
1342 | body?: string;
1343 | folderName?: string;
1344 | } {
1345 | if (typeof args !== "object" || args === null) {
1346 | return false;
1347 | }
1348 |
1349 | const { operation } = args as { operation?: unknown };
1350 | if (typeof operation !== "string") {
1351 | return false;
1352 | }
1353 |
1354 | if (!["search", "list", "create"].includes(operation)) {
1355 | return false;
1356 | }
1357 |
1358 | // Validate fields based on operation
1359 | if (operation === "search") {
1360 | const { searchText } = args as { searchText?: unknown };
1361 | if (typeof searchText !== "string" || searchText === "") {
1362 | return false;
1363 | }
1364 | }
1365 |
1366 | if (operation === "create") {
1367 | const { title, body } = args as { title?: unknown; body?: unknown };
1368 | if (typeof title !== "string" || title === "" || typeof body !== "string") {
1369 | return false;
1370 | }
1371 |
1372 | // Check folderName if provided
1373 | const { folderName } = args as { folderName?: unknown };
1374 | if (
1375 | folderName !== undefined &&
1376 | (typeof folderName !== "string" || folderName === "")
1377 | ) {
1378 | return false;
1379 | }
1380 | }
1381 |
1382 | return true;
1383 | }
1384 |
1385 | function isMessagesArgs(args: unknown): args is {
1386 | operation: "send" | "read" | "schedule" | "unread";
1387 | phoneNumber?: string;
1388 | message?: string;
1389 | limit?: number;
1390 | scheduledTime?: string;
1391 | } {
1392 | if (typeof args !== "object" || args === null) return false;
1393 |
1394 | const { operation, phoneNumber, message, limit, scheduledTime } = args as any;
1395 |
1396 | if (
1397 | !operation ||
1398 | !["send", "read", "schedule", "unread"].includes(operation)
1399 | ) {
1400 | return false;
1401 | }
1402 |
1403 | // Validate required fields based on operation
1404 | switch (operation) {
1405 | case "send":
1406 | case "schedule":
1407 | if (!phoneNumber || !message) return false;
1408 | if (operation === "schedule" && !scheduledTime) return false;
1409 | break;
1410 | case "read":
1411 | if (!phoneNumber) return false;
1412 | break;
1413 | case "unread":
1414 | // No additional required fields
1415 | break;
1416 | }
1417 |
1418 | // Validate field types if present
1419 | if (phoneNumber && typeof phoneNumber !== "string") return false;
1420 | if (message && typeof message !== "string") return false;
1421 | if (limit && typeof limit !== "number") return false;
1422 | if (scheduledTime && typeof scheduledTime !== "string") return false;
1423 |
1424 | return true;
1425 | }
1426 |
1427 | function isMailArgs(args: unknown): args is {
1428 | operation: "unread" | "search" | "send" | "mailboxes" | "accounts" | "latest";
1429 | account?: string;
1430 | mailbox?: string;
1431 | limit?: number;
1432 | searchTerm?: string;
1433 | to?: string;
1434 | subject?: string;
1435 | body?: string;
1436 | cc?: string;
1437 | bcc?: string;
1438 | } {
1439 | if (typeof args !== "object" || args === null) return false;
1440 |
1441 | const {
1442 | operation,
1443 | account,
1444 | mailbox,
1445 | limit,
1446 | searchTerm,
1447 | to,
1448 | subject,
1449 | body,
1450 | cc,
1451 | bcc,
1452 | } = args as any;
1453 |
1454 | if (
1455 | !operation ||
1456 | !["unread", "search", "send", "mailboxes", "accounts", "latest"].includes(
1457 | operation,
1458 | )
1459 | ) {
1460 | return false;
1461 | }
1462 |
1463 | // Validate required fields based on operation
1464 | switch (operation) {
1465 | case "search":
1466 | if (!searchTerm || typeof searchTerm !== "string") return false;
1467 | break;
1468 | case "send":
1469 | if (
1470 | !to ||
1471 | typeof to !== "string" ||
1472 | !subject ||
1473 | typeof subject !== "string" ||
1474 | !body ||
1475 | typeof body !== "string"
1476 | )
1477 | return false;
1478 | break;
1479 | case "unread":
1480 | case "mailboxes":
1481 | case "accounts":
1482 | case "latest":
1483 | // No additional required fields
1484 | break;
1485 | }
1486 |
1487 | // Validate field types if present
1488 | if (account && typeof account !== "string") return false;
1489 | if (mailbox && typeof mailbox !== "string") return false;
1490 | if (limit && typeof limit !== "number") return false;
1491 | if (cc && typeof cc !== "string") return false;
1492 | if (bcc && typeof bcc !== "string") return false;
1493 |
1494 | return true;
1495 | }
1496 |
1497 | function isRemindersArgs(args: unknown): args is {
1498 | operation: "list" | "search" | "open" | "create" | "listById";
1499 | searchText?: string;
1500 | name?: string;
1501 | listName?: string;
1502 | listId?: string;
1503 | props?: string[];
1504 | notes?: string;
1505 | dueDate?: string;
1506 | } {
1507 | if (typeof args !== "object" || args === null) {
1508 | return false;
1509 | }
1510 |
1511 | const { operation } = args as any;
1512 | if (typeof operation !== "string") {
1513 | return false;
1514 | }
1515 |
1516 | if (!["list", "search", "open", "create", "listById"].includes(operation)) {
1517 | return false;
1518 | }
1519 |
1520 | // For search and open operations, searchText is required
1521 | if (
1522 | (operation === "search" || operation === "open") &&
1523 | (typeof (args as any).searchText !== "string" ||
1524 | (args as any).searchText === "")
1525 | ) {
1526 | return false;
1527 | }
1528 |
1529 | // For create operation, name is required
1530 | if (
1531 | operation === "create" &&
1532 | (typeof (args as any).name !== "string" || (args as any).name === "")
1533 | ) {
1534 | return false;
1535 | }
1536 |
1537 | // For listById operation, listId is required
1538 | if (
1539 | operation === "listById" &&
1540 | (typeof (args as any).listId !== "string" || (args as any).listId === "")
1541 | ) {
1542 | return false;
1543 | }
1544 |
1545 | return true;
1546 | }
1547 |
1548 |
1549 | function isCalendarArgs(args: unknown): args is {
1550 | operation: "search" | "open" | "list" | "create";
1551 | searchText?: string;
1552 | eventId?: string;
1553 | limit?: number;
1554 | fromDate?: string;
1555 | toDate?: string;
1556 | title?: string;
1557 | startDate?: string;
1558 | endDate?: string;
1559 | location?: string;
1560 | notes?: string;
1561 | isAllDay?: boolean;
1562 | calendarName?: string;
1563 | } {
1564 | if (typeof args !== "object" || args === null) {
1565 | return false;
1566 | }
1567 |
1568 | const { operation } = args as { operation?: unknown };
1569 | if (typeof operation !== "string") {
1570 | return false;
1571 | }
1572 |
1573 | if (!["search", "open", "list", "create"].includes(operation)) {
1574 | return false;
1575 | }
1576 |
1577 | // Check that required parameters are present for each operation
1578 | if (operation === "search") {
1579 | const { searchText } = args as { searchText?: unknown };
1580 | if (typeof searchText !== "string") {
1581 | return false;
1582 | }
1583 | }
1584 |
1585 | if (operation === "open") {
1586 | const { eventId } = args as { eventId?: unknown };
1587 | if (typeof eventId !== "string") {
1588 | return false;
1589 | }
1590 | }
1591 |
1592 | if (operation === "create") {
1593 | const { title, startDate, endDate } = args as {
1594 | title?: unknown;
1595 | startDate?: unknown;
1596 | endDate?: unknown;
1597 | };
1598 |
1599 | if (
1600 | typeof title !== "string" ||
1601 | typeof startDate !== "string" ||
1602 | typeof endDate !== "string"
1603 | ) {
1604 | return false;
1605 | }
1606 | }
1607 |
1608 | return true;
1609 | }
1610 |
1611 | function isMapsArgs(args: unknown): args is {
1612 | operation:
1613 | | "search"
1614 | | "save"
1615 | | "directions"
1616 | | "pin"
1617 | | "listGuides"
1618 | | "addToGuide"
1619 | | "createGuide";
1620 | query?: string;
1621 | limit?: number;
1622 | name?: string;
1623 | address?: string;
1624 | fromAddress?: string;
1625 | toAddress?: string;
1626 | transportType?: string;
1627 | guideName?: string;
1628 | } {
1629 | if (typeof args !== "object" || args === null) {
1630 | return false;
1631 | }
1632 |
1633 | const { operation } = args as { operation?: unknown };
1634 | if (typeof operation !== "string") {
1635 | return false;
1636 | }
1637 |
1638 | if (
1639 | ![
1640 | "search",
1641 | "save",
1642 | "directions",
1643 | "pin",
1644 | "listGuides",
1645 | "addToGuide",
1646 | "createGuide",
1647 | ].includes(operation)
1648 | ) {
1649 | return false;
1650 | }
1651 |
1652 | // Check that required parameters are present for each operation
1653 | if (operation === "search") {
1654 | const { query } = args as { query?: unknown };
1655 | if (typeof query !== "string" || query === "") {
1656 | return false;
1657 | }
1658 | }
1659 |
1660 | if (operation === "save" || operation === "pin") {
1661 | const { name, address } = args as { name?: unknown; address?: unknown };
1662 | if (
1663 | typeof name !== "string" ||
1664 | name === "" ||
1665 | typeof address !== "string" ||
1666 | address === ""
1667 | ) {
1668 | return false;
1669 | }
1670 | }
1671 |
1672 | if (operation === "directions") {
1673 | const { fromAddress, toAddress } = args as {
1674 | fromAddress?: unknown;
1675 | toAddress?: unknown;
1676 | };
1677 | if (
1678 | typeof fromAddress !== "string" ||
1679 | fromAddress === "" ||
1680 | typeof toAddress !== "string" ||
1681 | toAddress === ""
1682 | ) {
1683 | return false;
1684 | }
1685 |
1686 | // Check transportType if provided
1687 | const { transportType } = args as { transportType?: unknown };
1688 | if (
1689 | transportType !== undefined &&
1690 | (typeof transportType !== "string" ||
1691 | !["driving", "walking", "transit"].includes(transportType))
1692 | ) {
1693 | return false;
1694 | }
1695 | }
1696 |
1697 | if (operation === "createGuide") {
1698 | const { guideName } = args as { guideName?: unknown };
1699 | if (typeof guideName !== "string" || guideName === "") {
1700 | return false;
1701 | }
1702 | }
1703 |
1704 | if (operation === "addToGuide") {
1705 | const { address, guideName } = args as {
1706 | address?: unknown;
1707 | guideName?: unknown;
1708 | };
1709 | if (
1710 | typeof address !== "string" ||
1711 | address === "" ||
1712 | typeof guideName !== "string" ||
1713 | guideName === ""
1714 | ) {
1715 | return false;
1716 | }
1717 | }
1718 |
1719 | return true;
1720 | }
1721 |
```