apple-tools-mcp 2.1.4 → 3.0.0

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/index.js CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
6
  import {
6
7
  CallToolRequestSchema,
7
8
  ListToolsRequestSchema,
8
9
  } from "@modelcontextprotocol/sdk/types.js";
10
+ import http from "http";
9
11
  import fs from "fs";
10
12
  import path from "path";
11
13
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
14
  import { cycleEndFlags, indexUnavailableMessage, indexQueryGate } from "./lib/indexGate.js";
13
- import { isIndexerMode, isPermissionsMode } from "./lib/processMode.js";
15
+ import { isIndexerMode, isPermissionsMode, isHttpMode, isHttpTokenMode } from "./lib/processMode.js";
14
16
  import { runPermissionsCommand } from "./lib/permissions.js";
15
- import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
17
+ import { loadResolvedIndexInterval, logResolvedInterval, resolveHttpServerConfig } from "./lib/config.js";
18
+ import { loadOrCreateHttpAuthToken, verifyAuthHeader } from "./lib/httpAuth.js";
19
+ import { createHttpRequestHandler } from "./lib/httpTransport.js";
16
20
  import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
17
21
  import {
18
22
  shouldConnectMcpStdio,
@@ -40,8 +44,13 @@ const PACKAGE_VERSION = JSON.parse(
40
44
 
41
45
  // Canonical indexer entrypoint: `node index.js --mode=indexer` or `apple-tools-indexer`.
42
46
  // Permissions CLI: `apple-tools-mcp permissions` — short-lived, no MCP / indexer.
47
+ // HTTP transport: `node index.js --transport=http` or `apple-tools-http` — same
48
+ // tools as stdio, but bearer-token authenticated (see lib/httpAuth.js).
49
+ // Token CLI: `apple-tools-mcp http-token` — short-lived, prints the token.
43
50
  const PERMISSIONS_MODE = isPermissionsMode();
44
51
  const INDEXER_MODE = isIndexerMode();
52
+ const HTTP_MODE = isHttpMode();
53
+ const HTTP_TOKEN_MODE = isHttpTokenMode();
45
54
  const resolvedIndexInterval = loadResolvedIndexInterval();
46
55
  const INDEX_INTERVAL = resolvedIndexInterval.ms;
47
56
  const LOCK_HEARTBEAT_MS = DEFAULT_LOCK_HEARTBEAT_MS;
@@ -153,9 +162,10 @@ process.on("unhandledRejection", (reason, promise) => {
153
162
  shutdownIndexing(1);
154
163
  });
155
164
 
156
- // MCP stdio clients exit when the host closes stdin. The indexer daemon must
157
- // not — LaunchAgent / KeepAlive often attaches stdin to /dev/null.
158
- bindStdinCloseExit(process.stdin, INDEXER_MODE || PERMISSIONS_MODE, () => {
165
+ // MCP stdio clients exit when the host closes stdin. The indexer daemon,
166
+ // HTTP server, and short-lived CLIs must not — LaunchAgent / KeepAlive
167
+ // often attaches stdin to /dev/null, and the CLIs exit on their own.
168
+ bindStdinCloseExit(process.stdin, INDEXER_MODE || PERMISSIONS_MODE || HTTP_MODE || HTTP_TOKEN_MODE, () => {
159
169
  console.error("Client disconnected. Exiting.");
160
170
  shutdownIndexing(0);
161
171
  });
@@ -458,6 +468,18 @@ if (PERMISSIONS_MODE) {
458
468
  console.error(`Permissions command error: ${e.message}`);
459
469
  process.exit(1);
460
470
  });
471
+ } else if (HTTP_TOKEN_MODE) {
472
+ try {
473
+ // Token itself goes to stdout only, so `apple-tools-mcp http-token` is
474
+ // scriptable (`$(apple-tools-mcp http-token)`). Any one-time generation
475
+ // banner still goes to stderr from loadOrCreateHttpAuthToken() itself.
476
+ const { token } = loadOrCreateHttpAuthToken();
477
+ console.log(token);
478
+ process.exit(0);
479
+ } catch (e) {
480
+ console.error(`Could not read/create HTTP auth token: ${e.message}`);
481
+ process.exit(1);
482
+ }
461
483
  } else {
462
484
  initializeIndexing().catch((e) => {
463
485
  console.error(`Indexing startup failed: ${e.message}`);
@@ -1036,561 +1058,566 @@ function formatPersonSearchResults(results) {
1036
1058
 
1037
1059
  // ============ MCP SERVER SETUP ============
1038
1060
 
1039
- const server = new Server(
1040
- { name: "apple-tools-mcp", version: PACKAGE_VERSION },
1041
- { capabilities: { tools: {} } }
1042
- );
1043
-
1044
- // Define available tools
1045
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
1046
- tools: [
1047
- // ============ SMART SEARCH (AGENTIC) ============
1048
- {
1049
- name: "smart_search",
1050
- description: "Intelligent search across Mail, Messages, and Calendar. Automatically determines which sources to search based on your query. Returns results grouped by time when multiple sources match. Use this for complex queries that might span multiple data sources.",
1051
- inputSchema: {
1052
- type: "object",
1053
- properties: {
1054
- query: { type: "string", description: "Natural language search query (e.g., 'meeting with John', 'budget discussion', 'what happened yesterday')" },
1055
- limit: { type: "number", description: "Max results per source (default 5)" },
1056
- synthesize: { type: "boolean", description: "Group results by time proximity (default true)" }
1061
+ function createServer() {
1062
+ const server = new Server(
1063
+ { name: "apple-tools-mcp", version: PACKAGE_VERSION },
1064
+ { capabilities: { tools: {} } }
1065
+ );
1066
+
1067
+ // Define available tools
1068
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1069
+ tools: [
1070
+ // ============ SMART SEARCH (AGENTIC) ============
1071
+ {
1072
+ name: "smart_search",
1073
+ description: "Intelligent search across Mail, Messages, and Calendar. Automatically determines which sources to search based on your query. Returns results grouped by time when multiple sources match. Use this for complex queries that might span multiple data sources.",
1074
+ inputSchema: {
1075
+ type: "object",
1076
+ properties: {
1077
+ query: { type: "string", description: "Natural language search query (e.g., 'meeting with John', 'budget discussion', 'what happened yesterday')" },
1078
+ limit: { type: "number", description: "Max results per source (default 5)" },
1079
+ synthesize: { type: "boolean", description: "Group results by time proximity (default true)" }
1080
+ },
1081
+ required: ["query"],
1057
1082
  },
1058
- required: ["query"],
1059
1083
  },
1060
- },
1061
1084
 
1062
- // ============ EMAIL TOOLS ============
1063
- {
1064
- name: "mail_search",
1065
- description: "Semantic search for emails using AI embeddings. Finds emails by meaning, not just keywords. Supports filtering by sender, recipient, attachments, mailbox, sent/received, and flagged.",
1066
- inputSchema: {
1067
- type: "object",
1068
- properties: {
1069
- query: { type: "string", description: "Natural language search (e.g., 'invoices', 'meeting notes', 'from John about project')" },
1070
- limit: { type: "number", description: "Maximum results (default 30)" },
1071
- days_back: { type: "number", description: "Only emails from last N days (0 = all time)" },
1072
- sender: { type: "string", description: "Filter by sender name or email address" },
1073
- recipient: { type: "string", description: "Filter by recipient name or email address" },
1074
- has_attachment: { type: "boolean", description: "Filter to only emails with attachments (true) or without (false)" },
1075
- mailbox: { type: "string", description: "Filter by mailbox name (e.g., 'INBOX', 'Archive', 'Sent Messages')" },
1076
- sent_only: { type: "boolean", description: "true = only sent emails, false = only received emails, omit for all" },
1077
- flagged_only: { type: "boolean", description: "Only show flagged/starred emails" },
1078
- include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" },
1079
- sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1085
+ // ============ EMAIL TOOLS ============
1086
+ {
1087
+ name: "mail_search",
1088
+ description: "Semantic search for emails using AI embeddings. Finds emails by meaning, not just keywords. Supports filtering by sender, recipient, attachments, mailbox, sent/received, and flagged.",
1089
+ inputSchema: {
1090
+ type: "object",
1091
+ properties: {
1092
+ query: { type: "string", description: "Natural language search (e.g., 'invoices', 'meeting notes', 'from John about project')" },
1093
+ limit: { type: "number", description: "Maximum results (default 30)" },
1094
+ days_back: { type: "number", description: "Only emails from last N days (0 = all time)" },
1095
+ sender: { type: "string", description: "Filter by sender name or email address" },
1096
+ recipient: { type: "string", description: "Filter by recipient name or email address" },
1097
+ has_attachment: { type: "boolean", description: "Filter to only emails with attachments (true) or without (false)" },
1098
+ mailbox: { type: "string", description: "Filter by mailbox name (e.g., 'INBOX', 'Archive', 'Sent Messages')" },
1099
+ sent_only: { type: "boolean", description: "true = only sent emails, false = only received emails, omit for all" },
1100
+ flagged_only: { type: "boolean", description: "Only show flagged/starred emails" },
1101
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" },
1102
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1103
+ },
1104
+ required: ["query"],
1080
1105
  },
1081
- required: ["query"],
1082
1106
  },
1083
- },
1084
- {
1085
- name: "mail_recent",
1086
- description: "Get most recent emails without semantic search. Use this when the user asks for 'recent emails', 'latest emails', 'what emails did I get', or 'unread emails'.",
1087
- inputSchema: {
1088
- type: "object",
1089
- properties: {
1090
- limit: { type: "number", description: "Maximum results (default 30)" },
1091
- days_back: { type: "number", description: "Only emails from last N days (default 7)" },
1092
- unread_only: { type: "boolean", description: "Only show unread emails (queries Mail.app for read status)" },
1093
- include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1107
+ {
1108
+ name: "mail_recent",
1109
+ description: "Get most recent emails without semantic search. Use this when the user asks for 'recent emails', 'latest emails', 'what emails did I get', or 'unread emails'.",
1110
+ inputSchema: {
1111
+ type: "object",
1112
+ properties: {
1113
+ limit: { type: "number", description: "Maximum results (default 30)" },
1114
+ days_back: { type: "number", description: "Only emails from last N days (default 7)" },
1115
+ unread_only: { type: "boolean", description: "Only show unread emails (queries Mail.app for read status)" },
1116
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1117
+ },
1094
1118
  },
1095
1119
  },
1096
- },
1097
- {
1098
- name: "mail_date",
1099
- description: "Get all emails from a specific date. Supports natural language like 'today', 'yesterday', 'November 13', 'last Friday'. Use this when the user asks for emails on a specific date.",
1100
- inputSchema: {
1101
- type: "object",
1102
- properties: {
1103
- date: { type: "string", description: "Date to retrieve emails (e.g., 'today', 'yesterday', 'Nov 13', '2025-01-15')" },
1104
- include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1120
+ {
1121
+ name: "mail_date",
1122
+ description: "Get all emails from a specific date. Supports natural language like 'today', 'yesterday', 'November 13', 'last Friday'. Use this when the user asks for emails on a specific date.",
1123
+ inputSchema: {
1124
+ type: "object",
1125
+ properties: {
1126
+ date: { type: "string", description: "Date to retrieve emails (e.g., 'today', 'yesterday', 'Nov 13', '2025-01-15')" },
1127
+ include_junk: { type: "boolean", description: "Include emails from Junk/Trash folders (excluded by default)" }
1128
+ },
1129
+ required: ["date"],
1105
1130
  },
1106
- required: ["date"],
1107
1131
  },
1108
- },
1109
- {
1110
- name: "mail_read",
1111
- description: "Read full email content. Use the file_path from mail_search or mail_recent results.",
1112
- inputSchema: {
1113
- type: "object",
1114
- properties: {
1115
- file_path: { type: "string", description: "File path from mail_search results" },
1132
+ {
1133
+ name: "mail_read",
1134
+ description: "Read full email content. Use the file_path from mail_search or mail_recent results.",
1135
+ inputSchema: {
1136
+ type: "object",
1137
+ properties: {
1138
+ file_path: { type: "string", description: "File path from mail_search results" },
1139
+ },
1140
+ required: ["file_path"],
1116
1141
  },
1117
- required: ["file_path"],
1118
1142
  },
1119
- },
1120
1143
 
1121
- // ============ MESSAGES TOOLS ============
1122
- {
1123
- name: "messages_search",
1124
- description: "Semantic search for iMessages/SMS using AI embeddings. Finds messages by meaning. Supports filtering by contact, group chats, specific group name, and attachments.",
1125
- inputSchema: {
1126
- type: "object",
1127
- properties: {
1128
- query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
1129
- limit: { type: "number", description: "Maximum results (default 30)" },
1130
- days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
1131
- contact: { type: "string", description: "Filter by contact name or phone number" },
1132
- group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
1133
- group_chat_name: { type: "string", description: "Filter by specific group chat name" },
1134
- has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
1135
- sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1144
+ // ============ MESSAGES TOOLS ============
1145
+ {
1146
+ name: "messages_search",
1147
+ description: "Semantic search for iMessages/SMS using AI embeddings. Finds messages by meaning. Supports filtering by contact, group chats, specific group name, and attachments.",
1148
+ inputSchema: {
1149
+ type: "object",
1150
+ properties: {
1151
+ query: { type: "string", description: "Natural language search (e.g., 'dinner plans', 'about the trip', 'address')" },
1152
+ limit: { type: "number", description: "Maximum results (default 30)" },
1153
+ days_back: { type: "number", description: "Only messages from last N days (0 = all time)" },
1154
+ contact: { type: "string", description: "Filter by contact name or phone number" },
1155
+ group_chat_only: { type: "boolean", description: "Only show messages from group chats" },
1156
+ group_chat_name: { type: "string", description: "Filter by specific group chat name" },
1157
+ has_attachment: { type: "boolean", description: "Filter to messages with attachments (photos, files)" },
1158
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (newest first)" }
1159
+ },
1160
+ required: ["query"],
1136
1161
  },
1137
- required: ["query"],
1138
1162
  },
1139
- },
1140
- {
1141
- name: "messages_recent",
1142
- 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'.",
1143
- inputSchema: {
1144
- type: "object",
1145
- properties: {
1146
- limit: { type: "number", description: "Maximum results (default 30)" },
1147
- days_back: { type: "number", description: "Only messages from last N days (default 1)" }
1163
+ {
1164
+ name: "messages_recent",
1165
+ 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'.",
1166
+ inputSchema: {
1167
+ type: "object",
1168
+ properties: {
1169
+ limit: { type: "number", description: "Maximum results (default 30)" },
1170
+ days_back: { type: "number", description: "Only messages from last N days (default 1)" }
1171
+ },
1148
1172
  },
1149
1173
  },
1150
- },
1151
- {
1152
- name: "messages_conversation",
1153
- description: "Get full conversation history with a specific contact. Shows messages in chronological order.",
1154
- inputSchema: {
1155
- type: "object",
1156
- properties: {
1157
- contact: { type: "string", description: "Contact name or phone number" },
1158
- limit: { type: "number", description: "Maximum messages to return (default 50)" }
1174
+ {
1175
+ name: "messages_conversation",
1176
+ description: "Get full conversation history with a specific contact. Shows messages in chronological order.",
1177
+ inputSchema: {
1178
+ type: "object",
1179
+ properties: {
1180
+ contact: { type: "string", description: "Contact name or phone number" },
1181
+ limit: { type: "number", description: "Maximum messages to return (default 50)" }
1182
+ },
1183
+ required: ["contact"],
1159
1184
  },
1160
- required: ["contact"],
1161
1185
  },
1162
- },
1163
1186
 
1164
- // ============ CALENDAR TOOLS ============
1165
- {
1166
- name: "calendar_search",
1167
- description: "Semantic search for calendar events using AI embeddings. Finds events by meaning. Supports filtering by calendar name and all-day events.",
1168
- inputSchema: {
1169
- type: "object",
1170
- properties: {
1171
- query: { type: "string", description: "Natural language search (e.g., 'meetings', 'doctor appointments', 'lunch')" },
1172
- limit: { type: "number", description: "Maximum results (default 30)" },
1173
- days_back: { type: "number", description: "Include events from last N days (0 = none)" },
1174
- days_ahead: { type: "number", description: "Include events in next N days (0 = none). Use for 'today', 'this week', etc." },
1175
- calendar_name: { type: "string", description: "Filter to specific calendar (e.g., 'Work', 'Personal')" },
1176
- all_day_only: { type: "boolean", description: "Only show all-day events" },
1177
- sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (chronological)" }
1187
+ // ============ CALENDAR TOOLS ============
1188
+ {
1189
+ name: "calendar_search",
1190
+ description: "Semantic search for calendar events using AI embeddings. Finds events by meaning. Supports filtering by calendar name and all-day events.",
1191
+ inputSchema: {
1192
+ type: "object",
1193
+ properties: {
1194
+ query: { type: "string", description: "Natural language search (e.g., 'meetings', 'doctor appointments', 'lunch')" },
1195
+ limit: { type: "number", description: "Maximum results (default 30)" },
1196
+ days_back: { type: "number", description: "Include events from last N days (0 = none)" },
1197
+ days_ahead: { type: "number", description: "Include events in next N days (0 = none). Use for 'today', 'this week', etc." },
1198
+ calendar_name: { type: "string", description: "Filter to specific calendar (e.g., 'Work', 'Personal')" },
1199
+ all_day_only: { type: "boolean", description: "Only show all-day events" },
1200
+ sort_by: { type: "string", enum: ["relevance", "date"], description: "Sort by relevance (default) or date (chronological)" }
1201
+ },
1202
+ required: ["query"],
1178
1203
  },
1179
- required: ["query"],
1180
1204
  },
1181
- },
1182
- {
1183
- name: "calendar_date",
1184
- description: "Get all events on a specific date. Supports natural language dates like 'today', 'tomorrow', 'next Tuesday', 'Jan 15'.",
1185
- inputSchema: {
1186
- type: "object",
1187
- properties: {
1188
- date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday', '2025-01-15')" }
1205
+ {
1206
+ name: "calendar_date",
1207
+ description: "Get all events on a specific date. Supports natural language dates like 'today', 'tomorrow', 'next Tuesday', 'Jan 15'.",
1208
+ inputSchema: {
1209
+ type: "object",
1210
+ properties: {
1211
+ date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday', '2025-01-15')" }
1212
+ },
1213
+ required: ["date"],
1189
1214
  },
1190
- required: ["date"],
1191
1215
  },
1192
- },
1193
- {
1194
- name: "calendar_free_time",
1195
- description: "Find free time slots on a specific date. Analyzes calendar to find available time windows.",
1196
- inputSchema: {
1197
- type: "object",
1198
- properties: {
1199
- date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday')" },
1200
- start_hour: { type: "number", description: "Start of working hours (default 9 = 9 AM)" },
1201
- end_hour: { type: "number", description: "End of working hours (default 17 = 5 PM)" },
1202
- calendar_name: { type: "string", description: "Only consider events from this calendar" }
1216
+ {
1217
+ name: "calendar_free_time",
1218
+ description: "Find free time slots on a specific date. Analyzes calendar to find available time windows.",
1219
+ inputSchema: {
1220
+ type: "object",
1221
+ properties: {
1222
+ date: { type: "string", description: "Date to check (e.g., 'today', 'tomorrow', 'next Monday')" },
1223
+ start_hour: { type: "number", description: "Start of working hours (default 9 = 9 AM)" },
1224
+ end_hour: { type: "number", description: "End of working hours (default 17 = 5 PM)" },
1225
+ calendar_name: { type: "string", description: "Only consider events from this calendar" }
1226
+ },
1227
+ required: ["date"],
1203
1228
  },
1204
- required: ["date"],
1205
1229
  },
1206
- },
1207
1230
 
1208
- // ============ NEW TOOLS - PHASE 1 ============
1209
-
1210
- // Mail tools
1211
- {
1212
- name: "mail_senders",
1213
- description: "List most frequent email senders. Helps identify who you communicate with most.",
1214
- inputSchema: {
1215
- type: "object",
1216
- properties: {
1217
- limit: { type: "number", description: "Maximum senders to return (default 30)" },
1218
- days_back: { type: "number", description: "Only count emails from last N days (0 = all time)" },
1219
- include_junk: { type: "boolean", description: "Include senders from Junk/Trash folders (excluded by default)" }
1231
+ // ============ NEW TOOLS - PHASE 1 ============
1232
+
1233
+ // Mail tools
1234
+ {
1235
+ name: "mail_senders",
1236
+ description: "List most frequent email senders. Helps identify who you communicate with most.",
1237
+ inputSchema: {
1238
+ type: "object",
1239
+ properties: {
1240
+ limit: { type: "number", description: "Maximum senders to return (default 30)" },
1241
+ days_back: { type: "number", description: "Only count emails from last N days (0 = all time)" },
1242
+ include_junk: { type: "boolean", description: "Include senders from Junk/Trash folders (excluded by default)" }
1243
+ },
1220
1244
  },
1221
1245
  },
1222
- },
1223
- {
1224
- name: "rebuild_index",
1225
- description: "Rebuild the search index for one or more data sources. This clears the existing index and re-indexes all content from scratch. Use this if search results are stale, missing, or if the index is corrupted. Can rebuild emails, messages, calendar, or all sources at once.",
1226
- inputSchema: {
1227
- type: "object",
1228
- properties: {
1229
- sources: {
1230
- type: "array",
1231
- items: { type: "string", enum: ["emails", "messages", "calendar"] },
1232
- description: "Which sources to rebuild. Defaults to all sources if not specified."
1233
- }
1246
+ {
1247
+ name: "rebuild_index",
1248
+ description: "Rebuild the search index for one or more data sources. This clears the existing index and re-indexes all content from scratch. Use this if search results are stale, missing, or if the index is corrupted. Can rebuild emails, messages, calendar, or all sources at once.",
1249
+ inputSchema: {
1250
+ type: "object",
1251
+ properties: {
1252
+ sources: {
1253
+ type: "array",
1254
+ items: { type: "string", enum: ["emails", "messages", "calendar"] },
1255
+ description: "Which sources to rebuild. Defaults to all sources if not specified."
1256
+ }
1257
+ },
1234
1258
  },
1235
1259
  },
1236
- },
1237
- {
1238
- name: "audit_index",
1239
- description: "Audit search index against source data with 0% tolerance. Reports missing items, orphaned entries, and duplicates with detailed file paths and remediation suggestions. Validates 100% of source data.",
1240
- inputSchema: {
1241
- type: "object",
1242
- properties: {
1243
- sources: {
1244
- type: "array",
1245
- items: { type: "string", enum: ["emails", "messages", "calendar"] },
1246
- description: "Data sources to audit (default: all)"
1260
+ {
1261
+ name: "audit_index",
1262
+ description: "Audit search index against source data with 0% tolerance. Reports missing items, orphaned entries, and duplicates with detailed file paths and remediation suggestions. Validates 100% of source data.",
1263
+ inputSchema: {
1264
+ type: "object",
1265
+ properties: {
1266
+ sources: {
1267
+ type: "array",
1268
+ items: { type: "string", enum: ["emails", "messages", "calendar"] },
1269
+ description: "Data sources to audit (default: all)"
1270
+ },
1271
+ max_items: {
1272
+ type: "number",
1273
+ description: "Max items to list per category (default: 100, use 0 for unlimited)"
1274
+ }
1247
1275
  },
1248
- max_items: {
1249
- type: "number",
1250
- description: "Max items to list per category (default: 100, use 0 for unlimited)"
1251
- }
1252
1276
  },
1253
1277
  },
1254
- },
1255
1278
 
1256
- // Messages tools
1257
- {
1258
- name: "messages_contacts",
1259
- description: "List all contacts you've messaged, sorted by most recent. Shows message count and last message date.",
1260
- inputSchema: {
1261
- type: "object",
1262
- properties: {
1263
- limit: { type: "number", description: "Maximum contacts to return (default 50)" }
1279
+ // Messages tools
1280
+ {
1281
+ name: "messages_contacts",
1282
+ description: "List all contacts you've messaged, sorted by most recent. Shows message count and last message date.",
1283
+ inputSchema: {
1284
+ type: "object",
1285
+ properties: {
1286
+ limit: { type: "number", description: "Maximum contacts to return (default 50)" }
1287
+ },
1264
1288
  },
1265
1289
  },
1266
- },
1267
1290
 
1268
- // Calendar tools
1269
- {
1270
- name: "calendar_upcoming",
1271
- description: "Get next N upcoming events across all calendars. Simpler than calendar_search for quick schedule overview.",
1272
- inputSchema: {
1273
- type: "object",
1274
- properties: {
1275
- limit: { type: "number", description: "Maximum events to return (default 10)" }
1291
+ // Calendar tools
1292
+ {
1293
+ name: "calendar_upcoming",
1294
+ description: "Get next N upcoming events across all calendars. Simpler than calendar_search for quick schedule overview.",
1295
+ inputSchema: {
1296
+ type: "object",
1297
+ properties: {
1298
+ limit: { type: "number", description: "Maximum events to return (default 10)" }
1299
+ },
1276
1300
  },
1277
1301
  },
1278
- },
1279
1302
 
1280
- // ============ NEW TOOLS - PHASE 2 ============
1303
+ // ============ NEW TOOLS - PHASE 2 ============
1281
1304
 
1282
- {
1283
- name: "calendar_week",
1284
- description: "Get all events for the current week or a future week. Shows events grouped by day.",
1285
- inputSchema: {
1286
- type: "object",
1287
- properties: {
1288
- week_offset: { type: "number", description: "0 = this week, 1 = next week, 2 = week after, etc. (default 0)" }
1305
+ {
1306
+ name: "calendar_week",
1307
+ description: "Get all events for the current week or a future week. Shows events grouped by day.",
1308
+ inputSchema: {
1309
+ type: "object",
1310
+ properties: {
1311
+ week_offset: { type: "number", description: "0 = this week, 1 = next week, 2 = week after, etc. (default 0)" }
1312
+ },
1289
1313
  },
1290
1314
  },
1291
- },
1292
- // ============ NEW TOOLS - PHASE 3 ============
1293
-
1294
- {
1295
- name: "mail_thread",
1296
- description: "Get all emails in a conversation thread. Finds related emails by matching subject lines.",
1297
- inputSchema: {
1298
- type: "object",
1299
- properties: {
1300
- file_path: { type: "string", description: "File path to any email in the thread" },
1301
- limit: { type: "number", description: "Maximum emails to return (default 30)" }
1315
+ // ============ NEW TOOLS - PHASE 3 ============
1316
+
1317
+ {
1318
+ name: "mail_thread",
1319
+ description: "Get all emails in a conversation thread. Finds related emails by matching subject lines.",
1320
+ inputSchema: {
1321
+ type: "object",
1322
+ properties: {
1323
+ file_path: { type: "string", description: "File path to any email in the thread" },
1324
+ limit: { type: "number", description: "Maximum emails to return (default 30)" }
1325
+ },
1326
+ required: ["file_path"],
1302
1327
  },
1303
- required: ["file_path"],
1304
1328
  },
1305
- },
1306
- {
1307
- name: "calendar_recurring",
1308
- description: "List recurring events (events that appear multiple times). Shows upcoming occurrences.",
1309
- inputSchema: {
1310
- type: "object",
1311
- properties: {
1312
- limit: { type: "number", description: "Maximum recurring events to return (default 30)" }
1329
+ {
1330
+ name: "calendar_recurring",
1331
+ description: "List recurring events (events that appear multiple times). Shows upcoming occurrences.",
1332
+ inputSchema: {
1333
+ type: "object",
1334
+ properties: {
1335
+ limit: { type: "number", description: "Maximum recurring events to return (default 30)" }
1336
+ },
1313
1337
  },
1314
1338
  },
1315
- },
1316
1339
 
1317
- // ============ CONTACTS TOOLS ============
1340
+ // ============ CONTACTS TOOLS ============
1318
1341
 
1319
- {
1320
- name: "contacts_search",
1321
- description: "Search your contacts by name, email, phone, or organization. Returns matching contacts with all their details.",
1322
- inputSchema: {
1323
- type: "object",
1324
- properties: {
1325
- query: { type: "string", description: "Search query (e.g., 'John', 'Acme Corp', 'john@example.com')" },
1326
- limit: { type: "number", description: "Maximum results (default 30)" }
1342
+ {
1343
+ name: "contacts_search",
1344
+ description: "Search your contacts by name, email, phone, or organization. Returns matching contacts with all their details.",
1345
+ inputSchema: {
1346
+ type: "object",
1347
+ properties: {
1348
+ query: { type: "string", description: "Search query (e.g., 'John', 'Acme Corp', 'john@example.com')" },
1349
+ limit: { type: "number", description: "Maximum results (default 30)" }
1350
+ },
1351
+ required: ["query"],
1327
1352
  },
1328
- required: ["query"],
1329
1353
  },
1330
- },
1331
- {
1332
- name: "contacts_lookup",
1333
- description: "Look up a specific contact by email, phone number, or name. Returns full contact details including all emails and phone numbers.",
1334
- inputSchema: {
1335
- type: "object",
1336
- properties: {
1337
- identifier: { type: "string", description: "Email address, phone number, or name to look up" }
1354
+ {
1355
+ name: "contacts_lookup",
1356
+ description: "Look up a specific contact by email, phone number, or name. Returns full contact details including all emails and phone numbers.",
1357
+ inputSchema: {
1358
+ type: "object",
1359
+ properties: {
1360
+ identifier: { type: "string", description: "Email address, phone number, or name to look up" }
1361
+ },
1362
+ required: ["identifier"],
1338
1363
  },
1339
- required: ["identifier"],
1340
1364
  },
1341
- },
1342
- {
1343
- name: "person_search",
1344
- description: "Search ALL communication with a specific person across Mail, Messages, and Calendar. Automatically finds their emails and phone numbers from Contacts to search all sources.",
1345
- inputSchema: {
1346
- type: "object",
1347
- properties: {
1348
- name: { type: "string", description: "Person's name to search for (will resolve to all their email addresses and phone numbers)" },
1349
- limit: { type: "number", description: "Maximum results per source (default 10)" }
1365
+ {
1366
+ name: "person_search",
1367
+ description: "Search ALL communication with a specific person across Mail, Messages, and Calendar. Automatically finds their emails and phone numbers from Contacts to search all sources.",
1368
+ inputSchema: {
1369
+ type: "object",
1370
+ properties: {
1371
+ name: { type: "string", description: "Person's name to search for (will resolve to all their email addresses and phone numbers)" },
1372
+ limit: { type: "number", description: "Maximum results per source (default 10)" }
1373
+ },
1374
+ required: ["name"],
1350
1375
  },
1351
- required: ["name"],
1352
1376
  },
1353
- },
1354
1377
 
1355
- // ============ WRITE TOOLS (2.0.0) ============
1356
- // Mail / Messages / Calendar / Contacts writes with dry_run + confirm.
1357
- ...WRITE_TOOL_DEFINITIONS,
1358
- ],
1359
- }));
1378
+ // ============ WRITE TOOLS (2.0.0) ============
1379
+ // Mail / Messages / Calendar / Contacts writes with dry_run + confirm.
1380
+ ...WRITE_TOOL_DEFINITIONS,
1381
+ ],
1382
+ }));
1360
1383
 
1361
- // Handle tool calls
1362
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1363
- const { name, arguments: args } = request.params;
1364
-
1365
- try {
1366
- let result;
1367
-
1368
- // Writes never wait on the vector index: they talk to Mail / Messages /
1369
- // Calendar / Contacts directly and must keep working while the indexer
1370
- // daemon holds the lock.
1371
- if (isWriteTool(name)) {
1372
- const writeResult = await dispatchWriteTool(name, args || {}, {
1373
- indexerMode: INDEXER_MODE,
1374
- socketPath: WRITE_SOCKET_PATH,
1375
- log: (msg) => console.error(msg)
1376
- });
1377
- return mcpWriteResult(writeResult);
1378
- }
1384
+ // Handle tool calls
1385
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1386
+ const { name, arguments: args } = request.params;
1379
1387
 
1380
- switch (name) {
1381
- // Smart search (agentic)
1382
- case "smart_search":
1383
- result = await smartSearch(args.query, {
1384
- limit: validateLimit(args?.limit, 5, 100),
1385
- synthesize: args?.synthesize !== false
1386
- });
1387
- break;
1388
-
1389
- // Email tools
1390
- case "mail_search":
1391
- result = await mailSearch(args.query, {
1392
- limit: validateLimit(args?.limit, 30),
1393
- daysBack: validateDaysBack(args?.days_back),
1394
- sender: args?.sender || null,
1395
- recipient: args?.recipient || null,
1396
- hasAttachment: args?.has_attachment ?? null,
1397
- mailbox: args?.mailbox || null,
1398
- sentOnly: args?.sent_only ?? null,
1399
- flaggedOnly: args?.flagged_only || false,
1400
- includeJunk: args?.include_junk || false,
1401
- sortBy: args?.sort_by || "relevance"
1388
+ try {
1389
+ let result;
1390
+
1391
+ // Writes never wait on the vector index: they talk to Mail / Messages /
1392
+ // Calendar / Contacts directly and must keep working while the indexer
1393
+ // daemon holds the lock.
1394
+ if (isWriteTool(name)) {
1395
+ const writeResult = await dispatchWriteTool(name, args || {}, {
1396
+ indexerMode: INDEXER_MODE,
1397
+ socketPath: WRITE_SOCKET_PATH,
1398
+ log: (msg) => console.error(msg)
1402
1399
  });
1403
- break;
1400
+ return mcpWriteResult(writeResult);
1401
+ }
1404
1402
 
1405
- case "mail_recent":
1406
- result = await mailRecent(
1407
- validateLimit(args?.limit, 30),
1408
- validateDaysBack(args?.days_back) || 7,
1409
- args?.unread_only || false,
1410
- args?.include_junk || false
1411
- );
1412
- break;
1403
+ switch (name) {
1404
+ // Smart search (agentic)
1405
+ case "smart_search":
1406
+ result = await smartSearch(args.query, {
1407
+ limit: validateLimit(args?.limit, 5, 100),
1408
+ synthesize: args?.synthesize !== false
1409
+ });
1410
+ break;
1413
1411
 
1414
- case "mail_date":
1415
- result = await mailDate(args.date, args?.include_junk || false);
1416
- break;
1412
+ // Email tools
1413
+ case "mail_search":
1414
+ result = await mailSearch(args.query, {
1415
+ limit: validateLimit(args?.limit, 30),
1416
+ daysBack: validateDaysBack(args?.days_back),
1417
+ sender: args?.sender || null,
1418
+ recipient: args?.recipient || null,
1419
+ hasAttachment: args?.has_attachment ?? null,
1420
+ mailbox: args?.mailbox || null,
1421
+ sentOnly: args?.sent_only ?? null,
1422
+ flaggedOnly: args?.flagged_only || false,
1423
+ includeJunk: args?.include_junk || false,
1424
+ sortBy: args?.sort_by || "relevance"
1425
+ });
1426
+ break;
1417
1427
 
1418
- case "mail_read":
1419
- result = readFullEmail(args.file_path);
1420
- break;
1428
+ case "mail_recent":
1429
+ result = await mailRecent(
1430
+ validateLimit(args?.limit, 30),
1431
+ validateDaysBack(args?.days_back) || 7,
1432
+ args?.unread_only || false,
1433
+ args?.include_junk || false
1434
+ );
1435
+ break;
1421
1436
 
1422
- // Messages tools
1423
- case "messages_search":
1424
- result = await messagesSearch(args.query, {
1425
- limit: validateLimit(args?.limit, 30),
1426
- daysBack: validateDaysBack(args?.days_back),
1427
- contact: args?.contact || null,
1428
- groupChatOnly: args?.group_chat_only || false,
1429
- groupChatName: args?.group_chat_name || null,
1430
- hasAttachment: args?.has_attachment ?? null,
1431
- sortBy: args?.sort_by || "relevance"
1432
- });
1433
- break;
1437
+ case "mail_date":
1438
+ result = await mailDate(args.date, args?.include_junk || false);
1439
+ break;
1434
1440
 
1435
- case "messages_recent":
1436
- result = await messagesRecent(
1437
- validateLimit(args?.limit, 30),
1438
- validateDaysBack(args?.days_back) || 1
1439
- );
1440
- break;
1441
+ case "mail_read":
1442
+ result = readFullEmail(args.file_path);
1443
+ break;
1441
1444
 
1442
- case "messages_conversation":
1443
- result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
1444
- break;
1445
+ // Messages tools
1446
+ case "messages_search":
1447
+ result = await messagesSearch(args.query, {
1448
+ limit: validateLimit(args?.limit, 30),
1449
+ daysBack: validateDaysBack(args?.days_back),
1450
+ contact: args?.contact || null,
1451
+ groupChatOnly: args?.group_chat_only || false,
1452
+ groupChatName: args?.group_chat_name || null,
1453
+ hasAttachment: args?.has_attachment ?? null,
1454
+ sortBy: args?.sort_by || "relevance"
1455
+ });
1456
+ break;
1445
1457
 
1446
- // Calendar tools
1447
- case "calendar_search":
1448
- result = await calendarSearch(args.query, {
1449
- limit: validateLimit(args?.limit, 30),
1450
- daysBack: validateDaysBack(args?.days_back),
1451
- daysAhead: validateDaysBack(args?.days_ahead),
1452
- calendarName: args?.calendar_name || null,
1453
- allDayOnly: args?.all_day_only || false,
1454
- sortBy: args?.sort_by || "relevance"
1455
- });
1456
- break;
1458
+ case "messages_recent":
1459
+ result = await messagesRecent(
1460
+ validateLimit(args?.limit, 30),
1461
+ validateDaysBack(args?.days_back) || 1
1462
+ );
1463
+ break;
1457
1464
 
1458
- case "calendar_date":
1459
- result = await calendarDate(args.date);
1460
- break;
1465
+ case "messages_conversation":
1466
+ result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
1467
+ break;
1461
1468
 
1462
- case "calendar_free_time":
1463
- result = await calendarFreeTime(args.date, {
1464
- startHour: args?.start_hour || 9,
1465
- endHour: args?.end_hour || 17,
1466
- calendarName: args?.calendar_name || null
1467
- });
1468
- break;
1469
+ // Calendar tools
1470
+ case "calendar_search":
1471
+ result = await calendarSearch(args.query, {
1472
+ limit: validateLimit(args?.limit, 30),
1473
+ daysBack: validateDaysBack(args?.days_back),
1474
+ daysAhead: validateDaysBack(args?.days_ahead),
1475
+ calendarName: args?.calendar_name || null,
1476
+ allDayOnly: args?.all_day_only || false,
1477
+ sortBy: args?.sort_by || "relevance"
1478
+ });
1479
+ break;
1469
1480
 
1470
- // ============ NEW TOOLS - PHASE 1 ============
1481
+ case "calendar_date":
1482
+ result = await calendarDate(args.date);
1483
+ break;
1471
1484
 
1472
- // Mail tools
1473
- case "mail_senders":
1474
- {
1475
- const blocked = await requireIndex("emails");
1476
- if (blocked) {
1477
- result = blocked;
1478
- break;
1479
- }
1480
- }
1481
- result = formatSendersResults(await getFrequentSenders(
1482
- validateLimit(args?.limit, 30),
1483
- validateDaysBack(args?.days_back),
1484
- args?.include_junk || false
1485
- ));
1486
- break;
1487
-
1488
- case "rebuild_index":
1489
- // Check if indexing is already in progress in this session
1490
- if (indexingInProgress) {
1491
- result = "Indexing is already in progress. Please wait for it to complete before starting a rebuild.";
1485
+ case "calendar_free_time":
1486
+ result = await calendarFreeTime(args.date, {
1487
+ startHour: args?.start_hour || 9,
1488
+ endHour: args?.end_hour || 17,
1489
+ calendarName: args?.calendar_name || null
1490
+ });
1492
1491
  break;
1493
- }
1494
1492
 
1495
- // Acquire lock to prevent parallel rebuilds across multiple MCP instances
1496
- if (!acquireLock()) {
1497
- result = "Indexing is already in progress in a different session. Please wait for it to complete before starting a rebuild.";
1493
+ // ============ NEW TOOLS - PHASE 1 ============
1494
+
1495
+ // Mail tools
1496
+ case "mail_senders":
1497
+ {
1498
+ const blocked = await requireIndex("emails");
1499
+ if (blocked) {
1500
+ result = blocked;
1501
+ break;
1502
+ }
1503
+ }
1504
+ result = formatSendersResults(await getFrequentSenders(
1505
+ validateLimit(args?.limit, 30),
1506
+ validateDaysBack(args?.days_back),
1507
+ args?.include_junk || false
1508
+ ));
1498
1509
  break;
1499
- }
1500
1510
 
1501
- // Start rebuild in background and return immediately
1502
- indexingInProgress = true;
1503
- sessionIndexComplete = false;
1504
- const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1505
-
1506
- // Fire and forget - don't await
1507
- rebuildIndex(rebuildSources).then((rebuildResult) => {
1508
- applyCycleEnd(true);
1509
- console.error("Index rebuild completed:", JSON.stringify({
1510
- cleared: rebuildResult.cleared,
1511
- indexed: Object.fromEntries(
1512
- Object.entries(rebuildResult.indexed).map(([k, v]) => [k, v?.added || 0])
1513
- ),
1514
- errors: rebuildResult.errors.length
1515
- }));
1516
- }).catch(e => {
1517
- console.error("Index rebuild error:", e.message);
1518
- applyCycleEnd(false);
1519
- });
1511
+ case "rebuild_index":
1512
+ // Check if indexing is already in progress in this session
1513
+ if (indexingInProgress) {
1514
+ result = "Indexing is already in progress. Please wait for it to complete before starting a rebuild.";
1515
+ break;
1516
+ }
1517
+
1518
+ // Acquire lock to prevent parallel rebuilds across multiple MCP instances
1519
+ if (!acquireLock()) {
1520
+ result = "Indexing is already in progress in a different session. Please wait for it to complete before starting a rebuild.";
1521
+ break;
1522
+ }
1520
1523
 
1521
- result = `🔄 Index rebuild started for: ${rebuildSources.join(", ")}.\n\nThis runs in the background and may take several minutes for large mailboxes. You can continue using other tools - searches will use the new index once complete.`;
1522
- break;
1524
+ // Start rebuild in background and return immediately
1525
+ indexingInProgress = true;
1526
+ sessionIndexComplete = false;
1527
+ const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
1528
+
1529
+ // Fire and forget - don't await
1530
+ rebuildIndex(rebuildSources).then((rebuildResult) => {
1531
+ applyCycleEnd(true);
1532
+ console.error("Index rebuild completed:", JSON.stringify({
1533
+ cleared: rebuildResult.cleared,
1534
+ indexed: Object.fromEntries(
1535
+ Object.entries(rebuildResult.indexed).map(([k, v]) => [k, v?.added || 0])
1536
+ ),
1537
+ errors: rebuildResult.errors.length
1538
+ }));
1539
+ }).catch(e => {
1540
+ console.error("Index rebuild error:", e.message);
1541
+ applyCycleEnd(false);
1542
+ });
1543
+
1544
+ result = `🔄 Index rebuild started for: ${rebuildSources.join(", ")}.\n\nThis runs in the background and may take several minutes for large mailboxes. You can continue using other tools - searches will use the new index once complete.`;
1545
+ break;
1523
1546
 
1524
- case "audit_index":
1525
- {
1526
- const auditSources = args?.sources || ["emails", "messages", "calendar"];
1527
- const maxItems = args?.max_items !== undefined ? args.max_items : 100;
1547
+ case "audit_index":
1548
+ {
1549
+ const auditSources = args?.sources || ["emails", "messages", "calendar"];
1550
+ const maxItems = args?.max_items !== undefined ? args.max_items : 100;
1528
1551
 
1529
- console.error(`Starting audit for: ${auditSources.join(", ")}`);
1530
- const auditResults = await auditAll({ sources: auditSources, maxItems });
1531
- result = formatAuditReport(auditResults);
1532
- }
1533
- break;
1552
+ console.error(`Starting audit for: ${auditSources.join(", ")}`);
1553
+ const auditResults = await auditAll({ sources: auditSources, maxItems });
1554
+ result = formatAuditReport(auditResults);
1555
+ }
1556
+ break;
1534
1557
 
1535
- // Messages tools
1536
- case "messages_contacts":
1537
- result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
1538
- break;
1558
+ // Messages tools
1559
+ case "messages_contacts":
1560
+ result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
1561
+ break;
1539
1562
 
1540
- // Calendar tools
1541
- case "calendar_upcoming":
1542
- result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
1543
- break;
1563
+ // Calendar tools
1564
+ case "calendar_upcoming":
1565
+ result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
1566
+ break;
1544
1567
 
1545
- // ============ NEW TOOLS - PHASE 2 ============
1568
+ // ============ NEW TOOLS - PHASE 2 ============
1546
1569
 
1547
- case "calendar_week":
1548
- result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
1549
- break;
1570
+ case "calendar_week":
1571
+ result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
1572
+ break;
1550
1573
 
1551
- // ============ NEW TOOLS - PHASE 3 ============
1574
+ // ============ NEW TOOLS - PHASE 3 ============
1552
1575
 
1553
- case "mail_thread":
1554
- {
1555
- const blocked = await requireIndex("emails");
1556
- if (blocked) {
1557
- result = blocked;
1558
- break;
1576
+ case "mail_thread":
1577
+ {
1578
+ const blocked = await requireIndex("emails");
1579
+ if (blocked) {
1580
+ result = blocked;
1581
+ break;
1582
+ }
1559
1583
  }
1560
- }
1561
- result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1562
- break;
1584
+ result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
1585
+ break;
1563
1586
 
1564
- case "calendar_recurring":
1565
- result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
1566
- break;
1587
+ case "calendar_recurring":
1588
+ result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
1589
+ break;
1567
1590
 
1568
- // ============ CONTACTS TOOLS ============
1591
+ // ============ CONTACTS TOOLS ============
1592
+
1593
+ case "contacts_search":
1594
+ result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
1595
+ break;
1569
1596
 
1570
- case "contacts_search":
1571
- result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
1572
- break;
1597
+ case "contacts_lookup":
1598
+ result = formatContactLookupResult(lookupContact(args.identifier));
1599
+ break;
1573
1600
 
1574
- case "contacts_lookup":
1575
- result = formatContactLookupResult(lookupContact(args.identifier));
1576
- break;
1601
+ case "person_search":
1602
+ result = await personSearch(args.name, validateLimit(args?.limit, 10));
1603
+ break;
1577
1604
 
1578
- case "person_search":
1579
- result = await personSearch(args.name, validateLimit(args?.limit, 10));
1580
- break;
1605
+ default:
1606
+ throw new Error(`Unknown tool: ${name}`);
1607
+ }
1581
1608
 
1582
- default:
1583
- throw new Error(`Unknown tool: ${name}`);
1609
+ return { content: [{ type: "text", text: result }] };
1610
+ } catch (error) {
1611
+ return {
1612
+ content: [{ type: "text", text: `Error: ${error.message}` }],
1613
+ isError: true,
1614
+ };
1584
1615
  }
1616
+ });
1617
+ return server;
1618
+ }
1585
1619
 
1586
- return { content: [{ type: "text", text: result }] };
1587
- } catch (error) {
1588
- return {
1589
- content: [{ type: "text", text: `Error: ${error.message}` }],
1590
- isError: true,
1591
- };
1592
- }
1593
- });
1620
+ const server = createServer();
1594
1621
 
1595
1622
  // Start the server
1596
1623
  async function main() {
@@ -1601,6 +1628,45 @@ async function main() {
1601
1628
  // fallback on this stdio process only if indexer.lock is free.
1602
1629
  }
1603
1630
 
1604
- if (!PERMISSIONS_MODE && shouldConnectMcpStdio(INDEXER_MODE)) {
1631
+ /**
1632
+ * Read-only-vs-write is unchanged from stdio mode — every tool the stdio
1633
+ * server exposes is exposed here too, including the write tools. What
1634
+ * changes is reachability (LAN / Tailscale instead of only this process's
1635
+ * parent) and, because of that, every request must carry a bearer token
1636
+ * (see lib/httpAuth.js) — stdio has no equivalent check because a locally
1637
+ * spawned child process is already trusted by whoever spawned it.
1638
+ *
1639
+ * A fresh Server + transport pair is created per request rather than
1640
+ * reusing the module-level `server`: the SDK's StreamableHTTPServerTransport
1641
+ * in stateless mode (sessionIdGenerator: undefined) is single-use — the
1642
+ * Protocol class throws "Already connected to a transport" on a second
1643
+ * connect() before the first has closed, which a shared instance would hit
1644
+ * under any concurrent access. This matches the SDK's own stateless example.
1645
+ */
1646
+ async function startHttpServer(host, port) {
1647
+ const { token } = loadOrCreateHttpAuthToken();
1648
+
1649
+ const httpServer = http.createServer(createHttpRequestHandler({
1650
+ token,
1651
+ verifyAuthHeader,
1652
+ createServer,
1653
+ StreamableHTTPServerTransport,
1654
+ packageVersion: PACKAGE_VERSION
1655
+ }));
1656
+
1657
+ await new Promise((resolve) => {
1658
+ httpServer.listen(port, host, resolve);
1659
+ });
1660
+ console.error(`Apple Tools MCP HTTP server (v${PACKAGE_VERSION}) listening on http://${host}:${port}/mcp`);
1661
+ return httpServer;
1662
+ }
1663
+
1664
+ if (HTTP_MODE) {
1665
+ const { host, port } = resolveHttpServerConfig();
1666
+ startHttpServer(host, port).catch((e) => {
1667
+ console.error(`HTTP server failed to start: ${e.message}`);
1668
+ process.exit(1);
1669
+ });
1670
+ } else if (!PERMISSIONS_MODE && !HTTP_TOKEN_MODE && shouldConnectMcpStdio(INDEXER_MODE)) {
1605
1671
  main().catch(console.error);
1606
1672
  }