apple-tools-mcp 3.0.7 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contacts.js +13 -1
- package/index.js +3 -3
- package/indexer.js +10 -8
- package/lib/appleScript.js +2 -2
- package/lib/mailWrite.js +193 -60
- package/lib/messageContact.js +106 -0
- package/lib/writeTools.js +4 -4
- package/package.json +1 -1
- package/search.js +22 -9
package/contacts.js
CHANGED
|
@@ -104,7 +104,7 @@ function findContactsDatabase() {
|
|
|
104
104
|
* Normalize phone number for consistent matching
|
|
105
105
|
* Strips all non-digit characters except leading +
|
|
106
106
|
*/
|
|
107
|
-
function normalizePhone(phone) {
|
|
107
|
+
export function normalizePhone(phone) {
|
|
108
108
|
if (!phone) return "";
|
|
109
109
|
// Keep leading + for international, then only digits
|
|
110
110
|
const hasPlus = phone.startsWith("+");
|
|
@@ -373,6 +373,18 @@ export function resolveByName(name) {
|
|
|
373
373
|
return matches;
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Resolve a name to contacts by exact full name, first name, or nickname
|
|
378
|
+
* (case-insensitive). No partial matching.
|
|
379
|
+
* @param {string} name - Name to look up
|
|
380
|
+
* @returns {object[]} Matching contacts (may be empty)
|
|
381
|
+
*/
|
|
382
|
+
export function resolveByExactName(name) {
|
|
383
|
+
if (!name) return [];
|
|
384
|
+
loadContacts(); // Ensure contacts are loaded
|
|
385
|
+
return nameToContact.get(name.toLowerCase().trim()) || [];
|
|
386
|
+
}
|
|
387
|
+
|
|
376
388
|
/**
|
|
377
389
|
* Get all identifiers (emails and phones) for a contact
|
|
378
390
|
* @param {number} contactId - Contact ID
|
package/index.js
CHANGED
|
@@ -1185,7 +1185,7 @@ function createServer() {
|
|
|
1185
1185
|
query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
|
|
1186
1186
|
limit: { type: "number", description: "Maximum results (default 30)" },
|
|
1187
1187
|
days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
|
|
1188
|
-
contact: { type: "string", description: "Filter by contact name or phone number" },
|
|
1188
|
+
contact: { type: "string", description: "Filter by contact: exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Includes my replies in that 1:1 chat." },
|
|
1189
1189
|
group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
|
|
1190
1190
|
group_chat_name: { type: "string", description: "Filter by specific group chat name" },
|
|
1191
1191
|
has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
|
|
@@ -1196,7 +1196,7 @@ function createServer() {
|
|
|
1196
1196
|
},
|
|
1197
1197
|
{
|
|
1198
1198
|
name: "messages_recent",
|
|
1199
|
-
description: "Get most recent messages without semantic search. Use this when the user asks for 'recent messages', 'latest texts', or 'what messages did I get'.",
|
|
1199
|
+
description: "Get most recent messages without semantic search. Use this when the user asks for 'recent messages', 'latest texts', or 'what messages did I get'. My own messages show a To: line naming the contact or group chat they were sent to.",
|
|
1200
1200
|
inputSchema: {
|
|
1201
1201
|
type: "object",
|
|
1202
1202
|
properties: {
|
|
@@ -1211,7 +1211,7 @@ function createServer() {
|
|
|
1211
1211
|
inputSchema: {
|
|
1212
1212
|
type: "object",
|
|
1213
1213
|
properties: {
|
|
1214
|
-
contact: { type: "string", description: "
|
|
1214
|
+
contact: { type: "string", description: "Exact Contacts name (full name, first name, or nickname, e.g. 'Dad'), phone number in any format, or email. Returns both sides of the 1:1 thread, including my replies." },
|
|
1215
1215
|
limit: { type: "number", description: "Maximum messages to return (default 50)" }
|
|
1216
1216
|
},
|
|
1217
1217
|
required: ["contact"],
|
package/indexer.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
|
|
17
17
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
18
18
|
import { createLanceTableCache, LANCE_CONNECT_OPTIONS } from "./lib/lancedbTables.js";
|
|
19
|
+
import { createMessageContactMatcher } from "./lib/messageContact.js";
|
|
20
|
+
import { resolveByExactName, normalizePhone } from "./contacts.js";
|
|
19
21
|
|
|
20
22
|
// Re-export contact functions for use by other modules
|
|
21
23
|
export {
|
|
@@ -23,6 +25,8 @@ export {
|
|
|
23
25
|
resolveEmail,
|
|
24
26
|
resolvePhone,
|
|
25
27
|
resolveByName,
|
|
28
|
+
resolveByExactName,
|
|
29
|
+
normalizePhone,
|
|
26
30
|
lookupContact,
|
|
27
31
|
searchContacts,
|
|
28
32
|
getContactIdentifiers,
|
|
@@ -1538,7 +1542,7 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
|
|
|
1538
1542
|
try {
|
|
1539
1543
|
const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
|
|
1540
1544
|
const results = await tables.messages.query()
|
|
1541
|
-
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
|
|
1545
|
+
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier", "chatName", "isGroupChat"])
|
|
1542
1546
|
.toArray();
|
|
1543
1547
|
|
|
1544
1548
|
const filtered = results
|
|
@@ -1559,17 +1563,15 @@ export async function getConversation(contact, limit = 50) {
|
|
|
1559
1563
|
if (!tables.messages) return [];
|
|
1560
1564
|
|
|
1561
1565
|
try {
|
|
1562
|
-
const
|
|
1566
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1567
|
+
if (!matcher) return [];
|
|
1563
1568
|
const results = await tables.messages.query()
|
|
1564
1569
|
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier"])
|
|
1565
1570
|
.toArray();
|
|
1566
1571
|
|
|
1567
|
-
//
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1571
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1572
|
-
});
|
|
1572
|
+
// Sender or chat handle matches the contact (name resolved to its handles);
|
|
1573
|
+
// from-me rows match through the 1:1 chat's handle
|
|
1574
|
+
const filtered = results.filter(r => matcher.matches(r));
|
|
1573
1575
|
|
|
1574
1576
|
// Sort chronologically (oldest first for conversation view)
|
|
1575
1577
|
return filtered
|
package/lib/appleScript.js
CHANGED
|
@@ -340,7 +340,7 @@ export const MAIL_TCC_GUIDANCE =
|
|
|
340
340
|
*/
|
|
341
341
|
export const MAIL_ACCESSIBILITY_GUIDANCE =
|
|
342
342
|
"macOS denied Accessibility (System Events could not drive Mail's compose window). " +
|
|
343
|
-
"mail_send / mail_draft fill the body via
|
|
343
|
+
"mail_send / mail_draft / mail_reply / mail_forward fill the body via System Events so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
|
|
344
344
|
"Allow node in System Settings → Privacy & Security → Accessibility, and keep Automation → Mail allowed. " +
|
|
345
345
|
"This is not a Mail Automation deny (-1743 / -10004).";
|
|
346
346
|
|
|
@@ -355,7 +355,7 @@ export const SYSTEM_EVENTS_STICKY_DENY_GUIDANCE =
|
|
|
355
355
|
/** System Events GUI scripting missing — not a Mail send hang. */
|
|
356
356
|
export const MAIL_GUI_SCRIPTING_GUIDANCE =
|
|
357
357
|
"System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
|
|
358
|
-
"mail_send / mail_draft locate the compose body via System Events (title-marker + AX hit-test) so Mail does not cite-wrap the Sent body (FB11734014). " +
|
|
358
|
+
"mail_send / mail_draft / mail_reply / mail_forward locate the compose body via System Events (title-marker + AX hit-test) so Mail does not cite-wrap the Sent body (FB11734014). " +
|
|
359
359
|
"Allow node → System Events in System Settings → Privacy & Security → Automation (run apple-tools-mcp permissions after install/upgrade) in addition to node → Mail, and keep Accessibility allowed. " +
|
|
360
360
|
SYSTEM_EVENTS_STICKY_DENY_GUIDANCE + " " +
|
|
361
361
|
"This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
|
package/lib/mailWrite.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
runAppleScript,
|
|
23
23
|
asString,
|
|
24
24
|
asInteger,
|
|
25
|
+
asBoolean,
|
|
25
26
|
MAIL_TCC_GUIDANCE,
|
|
26
27
|
MAIL_SEND_TIMEOUT_GUIDANCE,
|
|
27
28
|
MAIL_ACCESSIBILITY_GUIDANCE,
|
|
@@ -197,6 +198,8 @@ export const SYSTEM_EVENTS_AUTOMATION_PROBE_MARKER = "ATM_SYSTEM_EVENTS_AUTOMATI
|
|
|
197
198
|
export const MAIL_BODY_MIN_AX_HEIGHT = 50;
|
|
198
199
|
/** Historical Mini Tab count. 2.1.3 finds the body via title-marker + AX hit-test, not Tab. */
|
|
199
200
|
export const MAIL_BODY_TAB_MAX = 6;
|
|
201
|
+
/** Command-Up in the compose body (start of document). Reply/forward prepend uses this instead of Cmd-A. */
|
|
202
|
+
export const MAIL_BODY_START_KEY_CODE = 126;
|
|
200
203
|
/** Mail window titles may truncate; match this many leading subject characters. */
|
|
201
204
|
export const MAIL_COMPOSE_TITLE_MARKER_MAX = 60;
|
|
202
205
|
/** Top-level `UI elements` of process Mail only — never a deep tree walk. */
|
|
@@ -507,7 +510,9 @@ function systemEventsGrantKind(result) {
|
|
|
507
510
|
* — not `set value`, not `AXUIElementSetAttributeValue`. Re-read process
|
|
508
511
|
* `AXFocusedUIElement` and require role `AXWebArea` plus the same position
|
|
509
512
|
* and size. Mismatch is `BODY_FOCUS_UNPROVEN` (draft deleted, nothing sent).
|
|
510
|
-
* Deliver with
|
|
513
|
+
* Deliver with clipboard paste. Compose passes replaceBody=true (Cmd-A then
|
|
514
|
+
* paste). Reply/forward pass false: Command-Up to the top of the body, then
|
|
515
|
+
* paste, so the quoted original stays. No coordinate click. No Mail
|
|
511
516
|
* `content` / `html content`. AXTitle reads in the title helpers stay inside
|
|
512
517
|
* `tell application "System Events"` (2.1.4 osacompile -2741).
|
|
513
518
|
*/
|
|
@@ -515,6 +520,7 @@ export function buildMailBodyPasteHandler() {
|
|
|
515
520
|
const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
|
|
516
521
|
const topMax = asInteger(MAIL_COMPOSE_TOP_UI_MAX, { min: 1, max: 64, field: "topUiMax" });
|
|
517
522
|
const tokenId = asInteger(MAIL_RECIPIENT_TOKEN_CHAR_ID, { min: 1, max: 1114111, field: "tokenCharId" });
|
|
523
|
+
const startKey = asInteger(MAIL_BODY_START_KEY_CODE, { min: 1, max: 127, field: "bodyStartKey" });
|
|
518
524
|
return `property atmProvenBx : -1
|
|
519
525
|
property atmProvenBy : -1
|
|
520
526
|
property atmProvenBw : -1
|
|
@@ -561,6 +567,13 @@ on atmTitleMatches(atmTitle, atmMarker)
|
|
|
561
567
|
return false
|
|
562
568
|
end atmTitleMatches
|
|
563
569
|
|
|
570
|
+
on atmComposeTitleMarker(atmSubject)
|
|
571
|
+
set atmText to atmSubject as string
|
|
572
|
+
if atmText is "" then return "New Message"
|
|
573
|
+
if (length of atmText) > ${MAIL_COMPOSE_TITLE_MARKER_MAX} then return text 1 thru ${MAIL_COMPOSE_TITLE_MARKER_MAX} of atmText
|
|
574
|
+
return atmText
|
|
575
|
+
end atmComposeTitleMarker
|
|
576
|
+
|
|
564
577
|
on atmTopElemExactTitle(atmEl, atmMarker)
|
|
565
578
|
try
|
|
566
579
|
if my atmTitleMatches(name of atmEl as string, atmMarker) then return true
|
|
@@ -655,7 +668,7 @@ on atmStopScreenSaver()
|
|
|
655
668
|
end try
|
|
656
669
|
end atmStopScreenSaver
|
|
657
670
|
|
|
658
|
-
on atmFillMailBody(bodyText, needle, titleMarker, msg)
|
|
671
|
+
on atmFillMailBody(bodyText, needle, titleMarker, msg, replaceBody)
|
|
659
672
|
try
|
|
660
673
|
tell application "Mail" to activate
|
|
661
674
|
delay 0.25
|
|
@@ -767,8 +780,13 @@ on atmFillMailBody(bodyText, needle, titleMarker, msg)
|
|
|
767
780
|
set hadClip to true
|
|
768
781
|
end try
|
|
769
782
|
try
|
|
770
|
-
|
|
771
|
-
|
|
783
|
+
if replaceBody then
|
|
784
|
+
keystroke "a" using command down
|
|
785
|
+
delay 0.2
|
|
786
|
+
else
|
|
787
|
+
key code ${startKey} using command down
|
|
788
|
+
delay 0.2
|
|
789
|
+
end if
|
|
772
790
|
set the clipboard to bodyText
|
|
773
791
|
delay 0.2
|
|
774
792
|
keystroke "v" using command down
|
|
@@ -845,8 +863,9 @@ ${bodyCheck}`;
|
|
|
845
863
|
* is filled by Probe 3: exact subject title, first AXWebArea under that
|
|
846
864
|
* window (`entire contents`), `set focused of` that element, prove focus
|
|
847
865
|
* (role + position + size) via a fresh AXFocusedUIElement read, then Cmd-A
|
|
848
|
-
* and clipboard paste. No coordinate click. No AXValue
|
|
849
|
-
* `content of` oracle (Mail 16 / macOS 26 stays empty). Missing
|
|
866
|
+
* (`replaceBody` true) and clipboard paste. No coordinate click. No AXValue
|
|
867
|
+
* write. No Mail `content of` oracle (Mail 16 / macOS 26 stays empty). Missing
|
|
868
|
+
* window or
|
|
850
869
|
* body is BODY_FOCUS_FAILED; a focus-proof mismatch is BODY_FOCUS_UNPROVEN.
|
|
851
870
|
* Either deletes the draft and sends nothing. Header checks stay. The body
|
|
852
871
|
* needle runs only when the focused element is still that proven body, so a
|
|
@@ -865,7 +884,7 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
|
|
|
865
884
|
const needle = composeBodyNeedle(plainBody);
|
|
866
885
|
const titleMarker = composeTitleMarker(subject);
|
|
867
886
|
const pasteAndVerify = plainBody
|
|
868
|
-
? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage)
|
|
887
|
+
? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, ${asString(titleMarker)}, newMessage, ${asBoolean(true)})
|
|
869
888
|
tell application "Mail"
|
|
870
889
|
${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
|
|
871
890
|
end tell`
|
|
@@ -1558,35 +1577,110 @@ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerM
|
|
|
1558
1577
|
});
|
|
1559
1578
|
}
|
|
1560
1579
|
|
|
1580
|
+
/**
|
|
1581
|
+
* Intro text pasted above a reply/forward quote. Trailing newline keeps the
|
|
1582
|
+
* note from gluing to "On … wrote:". Needle still comes from composeBodyNeedle.
|
|
1583
|
+
*/
|
|
1584
|
+
export function quoteIntroPasteText(body) {
|
|
1585
|
+
const text = composeNativeBody(body);
|
|
1586
|
+
if (!text) return "";
|
|
1587
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function quoteBodyNeedleCheck(body) {
|
|
1591
|
+
const needle = composeBodyNeedle(body);
|
|
1592
|
+
if (!needle) return "";
|
|
1593
|
+
return ` if my atmFocusedIsProvenBody() then
|
|
1594
|
+
set atmAxVal to my atmAxFocusedValue()
|
|
1595
|
+
if atmAxVal is not "" then
|
|
1596
|
+
if my atmAxValueIsRecipientToken(atmAxVal) is false then
|
|
1597
|
+
if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
|
|
1598
|
+
end if
|
|
1599
|
+
end if
|
|
1600
|
+
end if`;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* Probe 3 prepend: visible window title from the live subject, no Cmd-A, no
|
|
1605
|
+
* AppleScript `content`. Mail 16 / macOS 26 leaves `set content` empty, which
|
|
1606
|
+
* is why reply/forward used to send a blank intro.
|
|
1607
|
+
*/
|
|
1608
|
+
function quoteBodyPasteAndVerify(outgoingVar, body) {
|
|
1609
|
+
const intro = quoteIntroPasteText(body);
|
|
1610
|
+
if (!intro) return "";
|
|
1611
|
+
const bodyCheck = quoteBodyNeedleCheck(intro);
|
|
1612
|
+
return ` tell application "Mail"
|
|
1613
|
+
set atmTitleMarker to my atmComposeTitleMarker(subject of ${outgoingVar} as string)
|
|
1614
|
+
end tell
|
|
1615
|
+
atmFillMailBody(${asString(intro)}, ${asString(composeBodyNeedle(intro))}, atmTitleMarker, ${outgoingVar}, ${asBoolean(false)})
|
|
1616
|
+
tell application "Mail"
|
|
1617
|
+
${bodyCheck}
|
|
1618
|
+
end tell`;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function quotePasteGuard(outgoingVar, fillBlock) {
|
|
1622
|
+
if (!fillBlock) return "";
|
|
1623
|
+
return `try
|
|
1624
|
+
${fillBlock}
|
|
1625
|
+
on error errMsg number errNum
|
|
1626
|
+
try
|
|
1627
|
+
tell application "Mail" to delete ${outgoingVar}
|
|
1628
|
+
end try
|
|
1629
|
+
error errMsg number errNum
|
|
1630
|
+
end try
|
|
1631
|
+
`;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function finishMailGuiWrite({ action, summary, result, indexerMode, finalize }) {
|
|
1635
|
+
if (
|
|
1636
|
+
!result.ok &&
|
|
1637
|
+
indexerMode &&
|
|
1638
|
+
(isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result))
|
|
1639
|
+
) {
|
|
1640
|
+
return guiScriptingUnavailableResult(action, summary, result, { indexerMode });
|
|
1641
|
+
}
|
|
1642
|
+
return finalize();
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1561
1645
|
export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
|
|
1562
|
-
|
|
1646
|
+
const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
|
|
1647
|
+
const fillBlock = quoteBodyPasteAndVerify("theReply", body);
|
|
1648
|
+
const pasteAndVerify = quotePasteGuard("theReply", fillBlock);
|
|
1649
|
+
const replyAllClause = replyAll ? "with reply to all" : "without reply to all";
|
|
1650
|
+
|
|
1651
|
+
return `${buildMailBodyPasteHandler()}
|
|
1652
|
+
${findMessageHandler()}
|
|
1563
1653
|
|
|
1564
1654
|
set theMessage to atmFindMessage(${asString(messageId)})
|
|
1565
1655
|
tell application "Mail"
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
end
|
|
1584
|
-
|
|
1656
|
+
with timeout of ${mailSecs} seconds
|
|
1657
|
+
set theReply to missing value
|
|
1658
|
+
try
|
|
1659
|
+
set theReply to reply theMessage with opening window ${replyAllClause}
|
|
1660
|
+
end try
|
|
1661
|
+
if theReply is missing value then
|
|
1662
|
+
-- Some Mail versions do not return the outgoing message from a reply.
|
|
1663
|
+
-- Fall back to a new visible message addressed to the original sender.
|
|
1664
|
+
-- Never set AppleScript content (Mail 16 / macOS 26 stays empty).
|
|
1665
|
+
set origSubject to subject of theMessage
|
|
1666
|
+
set origSender to extract address from (sender of theMessage)
|
|
1667
|
+
set theReply to make new outgoing message with properties {subject:("Re: " & origSubject), visible:true}
|
|
1668
|
+
tell theReply
|
|
1669
|
+
make new to recipient at end of to recipients with properties {address:origSender}
|
|
1670
|
+
end tell
|
|
1671
|
+
end if
|
|
1672
|
+
activate
|
|
1673
|
+
end timeout
|
|
1674
|
+
end tell
|
|
1675
|
+
${pasteAndVerify}tell application "Mail"
|
|
1676
|
+
with timeout of ${mailSecs} seconds
|
|
1677
|
+
${sendNow ? "send theReply" : "save theReply"}
|
|
1678
|
+
end timeout
|
|
1585
1679
|
end tell
|
|
1586
1680
|
return "OK"`;
|
|
1587
1681
|
}
|
|
1588
1682
|
|
|
1589
|
-
export function mailReply(args = {}) {
|
|
1683
|
+
export function mailReply(args = {}, { indexerMode = isIndexerMode() } = {}) {
|
|
1590
1684
|
const action = "mail_reply";
|
|
1591
1685
|
const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
|
|
1592
1686
|
if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
|
|
@@ -1609,47 +1703,72 @@ export function mailReply(args = {}) {
|
|
|
1609
1703
|
});
|
|
1610
1704
|
if (!plan.proceed) return plannedWriteResult(plan);
|
|
1611
1705
|
|
|
1706
|
+
if (quoteIntroPasteText(body.text)) {
|
|
1707
|
+
const se = probeSystemEventsGui();
|
|
1708
|
+
if (!se.ok) {
|
|
1709
|
+
return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1612
1713
|
const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
|
|
1714
|
+
wakeDisplay();
|
|
1613
1715
|
const result = runAppleScript(
|
|
1614
1716
|
buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
|
|
1615
1717
|
{ timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
|
|
1616
1718
|
);
|
|
1617
|
-
return
|
|
1719
|
+
return finishMailGuiWrite({
|
|
1618
1720
|
action,
|
|
1619
1721
|
summary,
|
|
1620
1722
|
result,
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1723
|
+
indexerMode,
|
|
1724
|
+
finalize: () => finalizeMailWrite({
|
|
1725
|
+
action,
|
|
1726
|
+
summary,
|
|
1727
|
+
result,
|
|
1728
|
+
secrets: [body.text],
|
|
1729
|
+
sendNow,
|
|
1730
|
+
inReplyTo: sendNow ? resolved.messageId : null,
|
|
1731
|
+
successSummary: sendNow ? "reply sent" : "reply saved to Drafts",
|
|
1732
|
+
details: {
|
|
1733
|
+
message_id: resolved.messageId,
|
|
1734
|
+
reply_all: String(replyAll)
|
|
1735
|
+
},
|
|
1736
|
+
deadlineMs
|
|
1737
|
+
})
|
|
1630
1738
|
});
|
|
1631
1739
|
}
|
|
1632
1740
|
|
|
1633
1741
|
export function buildForwardScript({ messageId, to, body, sendNow }) {
|
|
1634
|
-
|
|
1742
|
+
const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
|
|
1743
|
+
const fillBlock = quoteBodyPasteAndVerify("theForward", body);
|
|
1744
|
+
const pasteAndVerify = quotePasteGuard("theForward", fillBlock);
|
|
1745
|
+
const handlers = fillBlock ? `${buildMailBodyPasteHandler()}\n` : "";
|
|
1746
|
+
|
|
1747
|
+
return `${handlers}${findMessageHandler()}
|
|
1635
1748
|
|
|
1636
1749
|
set theMessage to atmFindMessage(${asString(messageId)})
|
|
1637
1750
|
tell application "Mail"
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1751
|
+
with timeout of ${mailSecs} seconds
|
|
1752
|
+
set theForward to missing value
|
|
1753
|
+
try
|
|
1754
|
+
set theForward to forward theMessage with opening window
|
|
1755
|
+
end try
|
|
1756
|
+
if theForward is missing value then error "FORWARD_UNSUPPORTED"
|
|
1757
|
+
tell theForward
|
|
1645
1758
|
${to.map((address) => ` make new to recipient at end of to recipients with properties {address:${asString(address)}}`).join("\n")}
|
|
1646
|
-
|
|
1647
|
-
|
|
1759
|
+
end tell
|
|
1760
|
+
activate
|
|
1761
|
+
end timeout
|
|
1762
|
+
end tell
|
|
1763
|
+
${pasteAndVerify}tell application "Mail"
|
|
1764
|
+
with timeout of ${mailSecs} seconds
|
|
1765
|
+
${sendNow ? "send theForward" : "save theForward"}
|
|
1766
|
+
end timeout
|
|
1648
1767
|
end tell
|
|
1649
1768
|
return "OK"`;
|
|
1650
1769
|
}
|
|
1651
1770
|
|
|
1652
|
-
export function mailForward(args = {}) {
|
|
1771
|
+
export function mailForward(args = {}, { indexerMode = isIndexerMode() } = {}) {
|
|
1653
1772
|
const action = "mail_forward";
|
|
1654
1773
|
const resolved = resolveMailMessageId({ messageId: args.message_id, filePath: args.file_path });
|
|
1655
1774
|
if (resolved.error) return { ok: false, message: `${action} refused: ${resolved.error}` };
|
|
@@ -1674,25 +1793,39 @@ export function mailForward(args = {}) {
|
|
|
1674
1793
|
});
|
|
1675
1794
|
if (!plan.proceed) return plannedWriteResult(plan);
|
|
1676
1795
|
|
|
1796
|
+
if (quoteIntroPasteText(body.text)) {
|
|
1797
|
+
const se = probeSystemEventsGui();
|
|
1798
|
+
if (!se.ok) {
|
|
1799
|
+
return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1677
1803
|
const deadlineMs = sentVerifyClock.now() + MCP_CALL_DEADLINE_MS;
|
|
1804
|
+
wakeDisplay();
|
|
1678
1805
|
const result = runAppleScript(
|
|
1679
1806
|
buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
|
|
1680
1807
|
{ timeout: MAIL_FIND_AND_SEND_TIMEOUT_MS, appName: "Mail" }
|
|
1681
1808
|
);
|
|
1682
|
-
return
|
|
1809
|
+
return finishMailGuiWrite({
|
|
1683
1810
|
action,
|
|
1684
1811
|
summary,
|
|
1685
1812
|
result,
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1813
|
+
indexerMode,
|
|
1814
|
+
finalize: () => finalizeMailWrite({
|
|
1815
|
+
action,
|
|
1816
|
+
summary,
|
|
1817
|
+
result,
|
|
1818
|
+
secrets: [body.text],
|
|
1819
|
+
sendNow,
|
|
1820
|
+
inReplyTo: sendNow ? resolved.messageId : null,
|
|
1821
|
+
forwardTo: sendNow ? to.addresses : null,
|
|
1822
|
+
successSummary: sendNow ? "forwarded" : "forward saved to Drafts",
|
|
1823
|
+
details: {
|
|
1824
|
+
message_id: resolved.messageId,
|
|
1825
|
+
to: to.addresses.join(", ")
|
|
1826
|
+
},
|
|
1827
|
+
deadlineMs
|
|
1828
|
+
})
|
|
1696
1829
|
});
|
|
1697
1830
|
}
|
|
1698
1831
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact matching and peer naming for indexed Messages rows.
|
|
3
|
+
*
|
|
4
|
+
* Rows carry raw handles (`sender` is a phone/email or "Me"; `chatIdentifier`
|
|
5
|
+
* is the other party's handle in a 1:1 chat, or "chatNNN" for a group). Tool
|
|
6
|
+
* output shows contact names, so callers pass names back ("Dad"). These helpers
|
|
7
|
+
* map that name to handles and tell a reader who a from-me row was sent to.
|
|
8
|
+
*
|
|
9
|
+
* Lookups are injected so the logic is testable without the AddressBook.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MIN_PHONE_DIGITS = 7;
|
|
13
|
+
|
|
14
|
+
function looksLikeEmail(value) {
|
|
15
|
+
return value.includes("@");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function looksLikePhone(value) {
|
|
19
|
+
return value.replace(/\D/g, "").length >= MIN_PHONE_DIGITS;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a predicate that matches message rows to a caller-supplied contact.
|
|
24
|
+
*
|
|
25
|
+
* Keeps the old raw substring match on sender / chatIdentifier, and adds:
|
|
26
|
+
* - phone input matched on normalized digits (605-360-2675 == +16053602675)
|
|
27
|
+
* - email input matched exactly (case-insensitive)
|
|
28
|
+
* - a name matched exactly (full name, first name, or nickname) against
|
|
29
|
+
* Contacts, then every phone and email on those contacts. No fuzzy name
|
|
30
|
+
* matching: a short name must not pull in other people's threads.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} contact - Name, phone, or email
|
|
33
|
+
* @param {object} deps
|
|
34
|
+
* @param {(name: string) => object[]} deps.resolveExactName - contacts with that exact name
|
|
35
|
+
* @param {(phone: string) => string} deps.normalizePhone
|
|
36
|
+
* @returns {{ matches: (row: object) => boolean, isName: boolean, resolvedCount: number } | null}
|
|
37
|
+
*/
|
|
38
|
+
export function createMessageContactMatcher(contact, { resolveExactName, normalizePhone }) {
|
|
39
|
+
const raw = String(contact || "").trim().toLowerCase();
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
|
|
42
|
+
const phones = new Set();
|
|
43
|
+
const emails = new Set();
|
|
44
|
+
const isName = !looksLikeEmail(raw) && !looksLikePhone(raw);
|
|
45
|
+
let resolvedCount = 0;
|
|
46
|
+
|
|
47
|
+
if (looksLikeEmail(raw)) {
|
|
48
|
+
emails.add(raw);
|
|
49
|
+
} else if (looksLikePhone(raw)) {
|
|
50
|
+
const normalized = normalizePhone(raw);
|
|
51
|
+
if (normalized) phones.add(normalized);
|
|
52
|
+
} else {
|
|
53
|
+
const people = resolveExactName(raw) || [];
|
|
54
|
+
resolvedCount = people.length;
|
|
55
|
+
for (const person of people) {
|
|
56
|
+
for (const p of person.phones || []) {
|
|
57
|
+
const normalized = p.normalized || normalizePhone(p.phone || "");
|
|
58
|
+
if (normalized) phones.add(normalized);
|
|
59
|
+
}
|
|
60
|
+
for (const e of person.emails || []) {
|
|
61
|
+
if (e.email) emails.add(e.email.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fieldMatches = (value) => {
|
|
67
|
+
const lower = String(value || "").toLowerCase();
|
|
68
|
+
if (!lower || lower === "me" || lower === "unknown") return false;
|
|
69
|
+
if (lower.includes(raw)) return true;
|
|
70
|
+
if (emails.has(lower)) return true;
|
|
71
|
+
if (phones.size > 0 && !looksLikeEmail(lower)) {
|
|
72
|
+
const normalized = normalizePhone(lower);
|
|
73
|
+
if (normalized && phones.has(normalized)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
matches: (row) => fieldMatches(row.sender) || fieldMatches(row.chatIdentifier),
|
|
80
|
+
isName,
|
|
81
|
+
resolvedCount
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Name the other side of a from-me row, so "Me" lines say who they went to.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} row - Indexed message row (chatIdentifier, chatName, isGroupChat)
|
|
89
|
+
* @param {object} deps
|
|
90
|
+
* @param {(phone: string) => object|null} deps.resolvePhone
|
|
91
|
+
* @param {(email: string) => object|null} deps.resolveEmail
|
|
92
|
+
* @param {(contact: object) => string} deps.formatContact
|
|
93
|
+
* @returns {string|null} e.g. "Dad (+16053602675)", "group chat \"Family\"", or null
|
|
94
|
+
*/
|
|
95
|
+
export function describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact }) {
|
|
96
|
+
const chatIdentifier = row.chatIdentifier || "";
|
|
97
|
+
if (row.isGroupChat) {
|
|
98
|
+
if (row.chatName) return `group chat "${row.chatName}"`;
|
|
99
|
+
return chatIdentifier ? `group chat ${chatIdentifier}` : "group chat";
|
|
100
|
+
}
|
|
101
|
+
if (!chatIdentifier) return null;
|
|
102
|
+
const person = looksLikeEmail(chatIdentifier)
|
|
103
|
+
? resolveEmail(chatIdentifier)
|
|
104
|
+
: resolvePhone(chatIdentifier);
|
|
105
|
+
return person ? `${formatContact(person)} (${chatIdentifier})` : chatIdentifier;
|
|
106
|
+
}
|
package/lib/writeTools.js
CHANGED
|
@@ -82,12 +82,12 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
name: "mail_reply",
|
|
85
|
-
description: "Reply to an existing email. reply_all=true fans out to every original recipient and requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover uses In-Reply-To. Clients must Sent-check before retrying a timed-out send.",
|
|
85
|
+
description: "Reply to an existing email. The reply text is pasted above the quoted original in a visible Mail window (System Events focus on the message body, then Command-Up and clipboard paste — not Cmd-A, which would replace the quote; AppleScript content is not set because Mail 16 leaves it empty). reply_all=true fans out to every original recipient and requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover uses In-Reply-To. Clients must Sent-check before retrying a timed-out send.",
|
|
86
86
|
inputSchema: {
|
|
87
87
|
type: "object",
|
|
88
88
|
properties: {
|
|
89
89
|
...MESSAGE_TARGET_PROPS,
|
|
90
|
-
body: { type: "string", description: "Reply text, prepended above the quoted original (required)" },
|
|
90
|
+
body: { type: "string", description: "Reply text, prepended above the quoted original via System Events paste (required). AppleScript content is not set." },
|
|
91
91
|
reply_all: { type: "boolean", description: "Reply to all original recipients (default false)" },
|
|
92
92
|
save_as_draft: { type: "boolean", description: "Save the reply to Drafts instead of sending (default false)" },
|
|
93
93
|
...CONFIRM_PROPS
|
|
@@ -97,13 +97,13 @@ export const WRITE_TOOL_DEFINITIONS = [
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
name: "mail_forward",
|
|
100
|
-
description: "Forward an existing email to new recipients. More than one recipient requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches intended To plus Fwd: subject or original Message-ID. Clients must Sent-check before retrying a timed-out send.",
|
|
100
|
+
description: "Forward an existing email to new recipients. An optional note is pasted above the forwarded original in a visible Mail window (same System Events prepend as mail_reply; AppleScript content is not set). More than one recipient requires confirm=true. A real send is verified in Sent/Outbox before success. A send hang is a timeout, not TCC, unless Mail reports -1743/-10004; hang recover matches intended To plus Fwd: subject or original Message-ID. Clients must Sent-check before retrying a timed-out send.",
|
|
101
101
|
inputSchema: {
|
|
102
102
|
type: "object",
|
|
103
103
|
properties: {
|
|
104
104
|
...MESSAGE_TARGET_PROPS,
|
|
105
105
|
to: { type: "array", items: { type: "string" }, description: "Forward recipients (required, at least one)" },
|
|
106
|
-
body: { type: "string", description: "Optional note
|
|
106
|
+
body: { type: "string", description: "Optional note pasted above the forwarded message (System Events prepend; omitted note skips paste)" },
|
|
107
107
|
save_as_draft: { type: "boolean", description: "Save the forward to Drafts instead of sending (default false)" },
|
|
108
108
|
...CONFIRM_PROPS
|
|
109
109
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.9",
|
|
4
4
|
"description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts. Speaks stdio (local) or bearer-token-authenticated Streamable HTTP (LAN / Tailscale).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/search.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as chrono from "chrono-node";
|
|
2
2
|
import { safeOsascript } from "./lib/shell.js";
|
|
3
3
|
import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
|
|
4
|
-
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
|
|
4
|
+
import { embed, getOpenTable, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, resolveByExactName, normalizePhone, formatContact } from "./indexer.js";
|
|
5
|
+
import { createMessageContactMatcher, describeMessagePeer } from "./lib/messageContact.js";
|
|
5
6
|
import { indexUnavailableMessage } from "./lib/indexGate.js";
|
|
6
7
|
|
|
7
8
|
// ============ CACHING ============
|
|
@@ -1024,12 +1025,8 @@ export async function searchMessages(query, options = {}) {
|
|
|
1024
1025
|
}
|
|
1025
1026
|
|
|
1026
1027
|
if (contact) {
|
|
1027
|
-
const
|
|
1028
|
-
results = results.filter(r =>
|
|
1029
|
-
const sender = (r.sender || "").toLowerCase();
|
|
1030
|
-
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1031
|
-
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1032
|
-
});
|
|
1028
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1029
|
+
if (matcher) results = results.filter(r => matcher.matches(r));
|
|
1033
1030
|
}
|
|
1034
1031
|
|
|
1035
1032
|
if (groupChatOnly) {
|
|
@@ -1057,7 +1054,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1057
1054
|
if (results.length === 0) {
|
|
1058
1055
|
let filterMsg = "";
|
|
1059
1056
|
if (daysBack > 0) filterMsg += ` in the last ${daysBack} days`;
|
|
1060
|
-
if (contact)
|
|
1057
|
+
if (contact) {
|
|
1058
|
+
filterMsg += ` with ${contact}`;
|
|
1059
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1060
|
+
if (matcher?.isName && matcher.resolvedCount === 0) filterMsg += ` (no contact named "${contact}")`;
|
|
1061
|
+
}
|
|
1061
1062
|
if (groupChatOnly) filterMsg += " in group chats";
|
|
1062
1063
|
if (groupChatName) filterMsg += ` in "${groupChatName}"`;
|
|
1063
1064
|
if (hasAttachment) filterMsg += " with attachments";
|
|
@@ -1082,6 +1083,7 @@ export async function searchMessages(query, options = {}) {
|
|
|
1082
1083
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1083
1084
|
sender: sender,
|
|
1084
1085
|
senderContact: senderContact, // Resolved contact name (if found)
|
|
1086
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1085
1087
|
text: row.text || "",
|
|
1086
1088
|
chatName: row.chatName || "",
|
|
1087
1089
|
isGroupChat: row.isGroupChat || false,
|
|
@@ -1105,6 +1107,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
1105
1107
|
}
|
|
1106
1108
|
}
|
|
1107
1109
|
|
|
1110
|
+
// Who a from-me message was sent to (contact name + handle, or the group chat)
|
|
1111
|
+
function messagePeer(row) {
|
|
1112
|
+
return describeMessagePeer(row, { resolvePhone, resolveEmail, formatContact });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1108
1115
|
// Get recent messages without semantic search
|
|
1109
1116
|
export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
1110
1117
|
try {
|
|
@@ -1125,6 +1132,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
|
1125
1132
|
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1126
1133
|
sender: sender,
|
|
1127
1134
|
senderContact: senderContact,
|
|
1135
|
+
to: sender === "Me" ? messagePeer(row) : null,
|
|
1128
1136
|
text: row.text || "",
|
|
1129
1137
|
isGroupChat: row.isGroupChat || false
|
|
1130
1138
|
};
|
|
@@ -1153,7 +1161,11 @@ export async function getConversationResults(contact, limit = 50) {
|
|
|
1153
1161
|
}));
|
|
1154
1162
|
|
|
1155
1163
|
if (formattedResults.length === 0) {
|
|
1156
|
-
|
|
1164
|
+
const matcher = createMessageContactMatcher(contact, { resolveExactName: resolveByExactName, normalizePhone });
|
|
1165
|
+
const hint = matcher?.isName && matcher.resolvedCount === 0
|
|
1166
|
+
? ` (no contact named "${contact}"; try a phone number or email)`
|
|
1167
|
+
: "";
|
|
1168
|
+
return { success: true, results: [], message: `No conversation found with ${contact}${hint}` };
|
|
1157
1169
|
}
|
|
1158
1170
|
|
|
1159
1171
|
return { success: true, results: formattedResults, contact };
|
|
@@ -1173,6 +1185,7 @@ export function formatMessageResults(searchResult) {
|
|
|
1173
1185
|
const senderDisplay = r.senderContact ? `${r.senderContact} (${r.sender})` : r.sender;
|
|
1174
1186
|
result += `\nDate: ${r.date}\nFrom: ${senderDisplay}`;
|
|
1175
1187
|
if (r.isGroupChat) result += " (Group)";
|
|
1188
|
+
if (r.to) result += `\nTo: ${r.to}`;
|
|
1176
1189
|
result += `\nMessage: ${r.text}`;
|
|
1177
1190
|
return result + "\n---";
|
|
1178
1191
|
}).join("\n");
|