apple-tools-mcp 1.1.0 → 1.1.1

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/README.md CHANGED
@@ -137,10 +137,10 @@ Once configured, Claude can use these tools:
137
137
  | Tool | Description |
138
138
  |------|-------------|
139
139
  | `calendar_search` | Semantic search for events with filters |
140
- | `calendar_date` | Get events on a specific date |
140
+ | `calendar_date` | Get events on a specific date (live Calendar.app, not the search index) |
141
141
  | `calendar_upcoming` | Get next N upcoming events |
142
142
  | `calendar_week` | Get all events for current or future week |
143
- | `calendar_free_time` | Find available time slots on a date |
143
+ | `calendar_free_time` | Find available time slots on a date (live Calendar.app, not the search index) |
144
144
  | `calendar_recurring` | List recurring events |
145
145
 
146
146
  ### Contacts Tools
package/index.js CHANGED
@@ -436,29 +436,11 @@ async function calendarSearch(query, options = {}) {
436
436
  }
437
437
 
438
438
  async function calendarDate(date) {
439
- if (!sessionIndexComplete) {
440
- return getIndexingMessage();
441
- }
442
-
443
- const ready = await isIndexReady("calendar");
444
- if (!ready) {
445
- return "Calendar index not available. Please try again shortly.";
446
- }
447
-
448
439
  const result = await getCalendarDateResults(date);
449
440
  return formatCalendarResults(result);
450
441
  }
451
442
 
452
443
  async function calendarFreeTime(date, options = {}) {
453
- if (!sessionIndexComplete) {
454
- return getIndexingMessage();
455
- }
456
-
457
- const ready = await isIndexReady("calendar");
458
- if (!ready) {
459
- return "Calendar index not available. Please try again shortly.";
460
- }
461
-
462
444
  const result = await calculateFreeTime(date, options);
463
445
  return formatFreeTimeResults(result);
464
446
  }
@@ -1454,7 +1436,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1454
1436
  async function main() {
1455
1437
  const transport = new StdioServerTransport();
1456
1438
  await server.connect(transport);
1457
- console.error("Apple Tools MCP server running (v1.1.0)");
1439
+ console.error("Apple Tools MCP server running (v1.1.1)");
1458
1440
  // Background indexing runs automatically on startup and every INDEX_INTERVAL
1459
1441
  }
1460
1442
 
package/indexer.js CHANGED
@@ -63,6 +63,7 @@ const BATCH_DELAY_MS = 0; // No delay needed - benchmarks showed no thermal thro
63
63
 
64
64
  // Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
65
65
  const MAC_ABSOLUTE_EPOCH = 978307200;
66
+ export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
66
67
 
67
68
  let embeddingPipeline = null;
68
69
  let db = null;
@@ -1687,6 +1688,117 @@ export function getUpcomingEvents(limit = 10) {
1687
1688
  }
1688
1689
  }
1689
1690
 
1691
+ // Convert local-day unix ms bounds to Apple/Core Data seconds (unix - 978307200)
1692
+ export function localDayToMacBounds(startMs, endMs) {
1693
+ const startMac = Math.floor(Number(startMs) / 1000) - MAC_ABSOLUTE_EPOCH;
1694
+ const endMac = Math.floor(Number(endMs) / 1000) - MAC_ABSOLUTE_EPOCH;
1695
+ if (!Number.isFinite(startMac) || !Number.isFinite(endMac)) {
1696
+ throw new Error("Invalid date bounds");
1697
+ }
1698
+ return { startMac, endMac };
1699
+ }
1700
+
1701
+ // Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
1702
+ export function buildEventsOnDateQuery(startMac, endMac) {
1703
+ const startBound = Math.floor(Number(startMac));
1704
+ const endBound = Math.floor(Number(endMac));
1705
+ if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
1706
+ throw new Error("Invalid Mac Absolute Time bounds");
1707
+ }
1708
+ const timedStart = "COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)";
1709
+ const occEnd = "COALESCE(oc.occurrence_end_date, ci.end_date)";
1710
+ // First local day = occurrence-end local date minus (durationDays - 1). Day math, not seconds (DST-safe).
1711
+ const allDayStartDate = `date(${occEnd} + 978307200, 'unixepoch', 'localtime', '-' || (CAST(round((ci.end_date - ci.start_date) / 86400.0) AS INTEGER) - 1) || ' days')`;
1712
+ const allDayStartMac = `CAST(strftime('%s', ${allDayStartDate} || ' 00:00:00', 'utc') AS INTEGER) - 978307200`;
1713
+ return `
1714
+ SELECT DISTINCT
1715
+ ci.ROWID as itemId,
1716
+ ci.summary as title,
1717
+ datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
1718
+ datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
1719
+ CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END as startMac,
1720
+ ${occEnd} as endMac,
1721
+ ci.all_day as isAllDay,
1722
+ c.title as calendar,
1723
+ l.title as location
1724
+ FROM OccurrenceCache oc
1725
+ INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
1726
+ LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
1727
+ LEFT JOIN Location l ON ci.location_id = l.ROWID
1728
+ WHERE ci.summary IS NOT NULL AND ci.summary <> ''
1729
+ AND (c.title IS NULL OR c.title NOT IN ('Found in Mail', 'Found in Natural Language'))
1730
+ AND (
1731
+ (oc.day >= ${startBound} AND oc.day < ${endBound})
1732
+ OR (
1733
+ ci.all_day = 0
1734
+ AND ${timedStart} < ${startBound}
1735
+ AND ${occEnd} > ${startBound}
1736
+ )
1737
+ )
1738
+ ORDER BY startMac ASC, title ASC
1739
+ `;
1740
+ }
1741
+
1742
+ // Copy Calendar.sqlitedb (+ wal/shm) to /tmp for one query, then delete the copies.
1743
+ // Calendar.app holds a lock on the live db; sqlite can read a snapshot copy.
1744
+ function withCalendarCopy(fn) {
1745
+ const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1746
+ const tmpDb = path.join("/tmp", `${CALENDAR_TMP_PREFIX}${id}.sqlitedb`);
1747
+ const tmpWal = `${tmpDb}-wal`;
1748
+ const tmpShm = `${tmpDb}-shm`;
1749
+ const srcWal = `${CALENDAR_DB}-wal`;
1750
+ const srcShm = `${CALENDAR_DB}-shm`;
1751
+
1752
+ try {
1753
+ if (!fs.existsSync(CALENDAR_DB)) {
1754
+ throw new Error("Calendar database not found");
1755
+ }
1756
+ fs.copyFileSync(CALENDAR_DB, tmpDb);
1757
+ if (fs.existsSync(srcWal)) {
1758
+ fs.copyFileSync(srcWal, tmpWal);
1759
+ }
1760
+ if (fs.existsSync(srcShm)) {
1761
+ fs.copyFileSync(srcShm, tmpShm);
1762
+ }
1763
+ return fn(tmpDb);
1764
+ } finally {
1765
+ for (const file of [tmpDb, tmpWal, tmpShm]) {
1766
+ try {
1767
+ fs.unlinkSync(file);
1768
+ } catch {
1769
+ // already gone or never created
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1774
+
1775
+ // calendar_date: live OccurrenceCache for every occurrence on a local day
1776
+ export function getEventsOnDate(startMs, endMs) {
1777
+ try {
1778
+ if (!fs.existsSync(CALENDAR_DB)) {
1779
+ return { events: [], error: "Calendar database not found" };
1780
+ }
1781
+ const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
1782
+ const query = buildEventsOnDateQuery(startMac, endMac);
1783
+ let lastError;
1784
+ for (let attempt = 0; attempt < 3; attempt++) {
1785
+ try {
1786
+ const events = withCalendarCopy((dbPath) => {
1787
+ return safeSqlite3Json(dbPath, query, { timeout: 15000 });
1788
+ });
1789
+ return { events, error: null };
1790
+ } catch (e) {
1791
+ lastError = e;
1792
+ console.error(`Error getting events on date (attempt ${attempt + 1}):`, e.message);
1793
+ }
1794
+ }
1795
+ return { events: [], error: lastError.message };
1796
+ } catch (e) {
1797
+ console.error("Error getting events on date:", e.message);
1798
+ return { events: [], error: e.message };
1799
+ }
1800
+ }
1801
+
1690
1802
  // ============ NEW TOOLS - PHASE 2 ============
1691
1803
 
1692
1804
  // mail_unread_count: Get count of unread emails via AppleScript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/search.js CHANGED
@@ -2,7 +2,7 @@ import * as lancedb from "@lancedb/lancedb";
2
2
  import * as chrono from "chrono-node";
3
3
  import { safeOsascript } from "./lib/shell.js";
4
4
  import { safeMatch, validateSearchQuery } from "./lib/validators.js";
5
- import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getCalendarByDate, getAllCalendarEvents, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
5
+ import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
6
6
 
7
7
  let db = null;
8
8
  let tables = {};
@@ -1319,28 +1319,40 @@ export async function searchCalendar(query, options = {}) {
1319
1319
  }
1320
1320
  }
1321
1321
 
1322
- // Get events on a specific date
1322
+ // Local midnight of parsed date through next local midnight (handles DST; not +24h)
1323
+ export function getLocalDayBounds(dateStr) {
1324
+ const start = parseNaturalDate(dateStr);
1325
+ if (start == null) return null;
1326
+ const endDate = new Date(start);
1327
+ endDate.setDate(endDate.getDate() + 1);
1328
+ return { start, end: endDate.getTime() };
1329
+ }
1330
+
1331
+ // Get events on a specific date from live Calendar.sqlitedb (not the vector index)
1323
1332
  export async function getCalendarDateResults(dateStr) {
1324
1333
  try {
1325
- const range = getDateRange(dateStr);
1334
+ const range = getLocalDayBounds(dateStr);
1326
1335
  if (!range) {
1327
1336
  return { success: false, error: `Could not parse date: ${dateStr}` };
1328
1337
  }
1329
1338
 
1330
1339
  console.error(`[Calendar Date] Query for "${dateStr}"`);
1331
- console.error(`[Calendar Date] Range: ${new Date(range.start).toISOString()} to ${new Date(range.end).toISOString()}`);
1340
+ console.error(`[Calendar Date] Range: ${new Date(range.start).toString()} to ${new Date(range.end).toString()}`);
1332
1341
 
1333
- const results = await getCalendarByDate(range.start, range.end);
1342
+ const { events, error } = getEventsOnDate(range.start, range.end);
1343
+ if (error) {
1344
+ return { success: false, error: `Error getting calendar events: ${error}` };
1345
+ }
1334
1346
 
1335
- const formattedResults = results.map((row, idx) => ({
1347
+ const formattedResults = events.map((row, idx) => ({
1336
1348
  index: idx + 1,
1337
1349
  title: row.title || "No title",
1338
- start: formatLocalDate(row.start) || "Unknown",
1339
- startTimestamp: row.startTimestamp || null,
1340
- end: formatLocalDate(row.end) || "Unknown",
1350
+ start: formatLocalDate(row.start) || row.start || "Unknown",
1351
+ startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
1352
+ end: formatLocalDate(row.end) || row.end || "Unknown",
1341
1353
  calendar: row.calendar || "Unknown",
1342
1354
  location: row.location || "",
1343
- isAllDay: row.isAllDay || false
1355
+ isAllDay: !!row.isAllDay
1344
1356
  }));
1345
1357
 
1346
1358
  const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
@@ -1360,6 +1372,24 @@ export async function getCalendarDateResults(dateStr) {
1360
1372
  }
1361
1373
  }
1362
1374
 
1375
+ // Clamp a timed event to [dayStartMs, dayEndMs) and return minutes from local midnight.
1376
+ // Overnight events (23:00–01:00) contribute only the slice that falls on this day.
1377
+ export function clampBusyToLocalDay(evtStartMs, evtEndMs, dayStartMs, dayEndMs) {
1378
+ const clampedStart = Math.max(evtStartMs, dayStartMs);
1379
+ const clampedEnd = Math.min(evtEndMs, dayEndMs);
1380
+ if (!(clampedEnd > clampedStart)) return null;
1381
+
1382
+ const startMinutes = clampedStart <= dayStartMs
1383
+ ? 0
1384
+ : new Date(clampedStart).getHours() * 60 + new Date(clampedStart).getMinutes();
1385
+ const endMinutes = clampedEnd >= dayEndMs
1386
+ ? 24 * 60
1387
+ : new Date(clampedEnd).getHours() * 60 + new Date(clampedEnd).getMinutes();
1388
+
1389
+ if (!(endMinutes > startMinutes)) return null;
1390
+ return { startMinutes, endMinutes };
1391
+ }
1392
+
1363
1393
  // Calculate free time slots on a specific date
1364
1394
  export async function calculateFreeTime(dateStr, options = {}) {
1365
1395
  const {
@@ -1369,12 +1399,17 @@ export async function calculateFreeTime(dateStr, options = {}) {
1369
1399
  } = options;
1370
1400
 
1371
1401
  try {
1372
- const range = getDateRange(dateStr);
1402
+ const range = getLocalDayBounds(dateStr);
1373
1403
  if (!range) {
1374
1404
  return { success: false, error: `Could not parse date: ${dateStr}` };
1375
1405
  }
1376
1406
 
1377
- let events = await getCalendarByDate(range.start, range.end);
1407
+ const { events: liveEvents, error } = getEventsOnDate(range.start, range.end);
1408
+ if (error) {
1409
+ return { success: false, error: `Error calculating free time: ${error}` };
1410
+ }
1411
+
1412
+ let events = liveEvents;
1378
1413
 
1379
1414
  // Filter by calendar if specified
1380
1415
  if (calendarName) {
@@ -1390,16 +1425,21 @@ export async function calculateFreeTime(dateStr, options = {}) {
1390
1425
  continue;
1391
1426
  }
1392
1427
 
1393
- const evtStart = new Date(evt.startTimestamp);
1394
- const evtEnd = evt.end ? parseDate(evt.end) : evt.startTimestamp + (60 * 60 * 1000); // Default 1 hour
1428
+ const evtStart = evt.startMac != null
1429
+ ? (Number(evt.startMac) + 978307200) * 1000
1430
+ : (evt.startTimestamp || parseDate(evt.start));
1431
+ const evtEnd = evt.endMac != null
1432
+ ? (Number(evt.endMac) + 978307200) * 1000
1433
+ : (evt.end ? parseDate(evt.end) : evtStart + (60 * 60 * 1000));
1395
1434
 
1396
- const startMinutes = evtStart.getHours() * 60 + evtStart.getMinutes();
1397
- const endMinutes = new Date(evtEnd).getHours() * 60 + new Date(evtEnd).getMinutes();
1435
+ const clamped = clampBusyToLocalDay(evtStart, evtEnd, range.start, range.end);
1436
+ if (!clamped) continue;
1398
1437
 
1399
- busyPeriods.push({
1400
- start: Math.max(startMinutes, startHour * 60),
1401
- end: Math.min(endMinutes, endHour * 60)
1402
- });
1438
+ const start = Math.max(clamped.startMinutes, startHour * 60);
1439
+ const end = Math.min(clamped.endMinutes, endHour * 60);
1440
+ if (end > start) {
1441
+ busyPeriods.push({ start, end });
1442
+ }
1403
1443
  }
1404
1444
 
1405
1445
  // Sort busy periods