mcp-icloud 2.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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Contacts module for iCloud MCP
3
+ * Provides contacts tools via CardDAV (cloud) or AppleScript (local)
4
+ */
5
+
6
+ const { z } = require('zod');
7
+ const cloudClient = require('./carddav-client');
8
+ const localClient = require('./local-client');
9
+ const { formatSuccess, formatError, withErrorHandler } = require('../utils/error-handler');
10
+ const { listOutput, listResult } = require('../utils/schemas');
11
+ const { isLocalMode } = require('../mode');
12
+ const config = require('../config');
13
+
14
+ /**
15
+ * Get the appropriate client based on current mode
16
+ */
17
+ function getClient() {
18
+ return isLocalMode() ? localClient : cloudClient;
19
+ }
20
+
21
+ /**
22
+ * Normalize contact from local format to common format
23
+ */
24
+ function normalizeLocalContact(contact) {
25
+ return {
26
+ displayName: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
27
+ firstName: contact.firstName,
28
+ lastName: contact.lastName,
29
+ emails: contact.email ? [{ value: contact.email, type: 'work' }] : (contact.emails || []),
30
+ phones: contact.phone ? [{ value: contact.phone, type: 'mobile' }] : (contact.phones || []),
31
+ organization: contact.organization,
32
+ title: contact.jobTitle,
33
+ notes: contact.note,
34
+ url: contact.id, // Use ID as URL for local contacts
35
+ uid: contact.id
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Normalize contact list based on mode
41
+ */
42
+ function normalizeContacts(contacts, isLocal) {
43
+ if (!isLocal) return contacts;
44
+ return contacts.map(normalizeLocalContact);
45
+ }
46
+
47
+ /**
48
+ * Handler: List contacts
49
+ */
50
+ async function handleListContacts(args) {
51
+ const count = Math.min(args.count || 25, config.DEFAULTS.MAX_RESULTS);
52
+ const local = isLocalMode();
53
+ const client = getClient();
54
+
55
+ const rawContacts = await client.listContacts(count);
56
+ const contacts = normalizeContacts(rawContacts, local);
57
+
58
+ if (contacts.length === 0) {
59
+ return formatSuccess('No contacts found.', listResult([]));
60
+ }
61
+
62
+ const lines = contacts.map((contact, i) => {
63
+ let line = `${i + 1}. ${contact.displayName}`;
64
+
65
+ if (contact.emails && contact.emails.length > 0) {
66
+ line += `\n Email: ${contact.emails[0].value}`;
67
+ }
68
+ if (contact.phones && contact.phones.length > 0) {
69
+ line += `\n Phone: ${contact.phones[0].value}`;
70
+ }
71
+ if (contact.organization) {
72
+ line += `\n Company: ${contact.organization}`;
73
+ }
74
+ line += `\n ${local ? 'ID' : 'URL'}: ${contact.url}`;
75
+
76
+ return line;
77
+ });
78
+
79
+ return formatSuccess(`Contacts (${contacts.length}):\n\n${lines.join('\n\n')}`, listResult(contacts));
80
+ }
81
+
82
+ /**
83
+ * Handler: Search contacts
84
+ */
85
+ async function handleSearchContacts(args) {
86
+ if (!args.query) {
87
+ return formatError(new Error('Search query is required'));
88
+ }
89
+
90
+ const count = Math.min(args.count || 25, config.DEFAULTS.MAX_RESULTS);
91
+ const local = isLocalMode();
92
+ const client = getClient();
93
+
94
+ const rawContacts = await client.searchContacts(args.query, count);
95
+ const contacts = normalizeContacts(rawContacts, local);
96
+
97
+ if (contacts.length === 0) {
98
+ return formatSuccess(`No contacts found matching "${args.query}".`, listResult([]));
99
+ }
100
+
101
+ const lines = contacts.map((contact, i) => {
102
+ let line = `${i + 1}. ${contact.displayName}`;
103
+
104
+ if (contact.emails && contact.emails.length > 0) {
105
+ line += `\n Email: ${contact.emails[0].value}`;
106
+ }
107
+ if (contact.phones && contact.phones.length > 0) {
108
+ line += `\n Phone: ${contact.phones[0].value}`;
109
+ }
110
+ if (contact.organization) {
111
+ line += `\n Company: ${contact.organization}`;
112
+ }
113
+ line += `\n ${local ? 'ID' : 'URL'}: ${contact.url}`;
114
+
115
+ return line;
116
+ });
117
+
118
+ return formatSuccess(`Search results for "${args.query}" (${contacts.length}):\n\n${lines.join('\n\n')}`, listResult(contacts));
119
+ }
120
+
121
+ /**
122
+ * Handler: Read contact
123
+ */
124
+ async function handleReadContact(args) {
125
+ if (!args.contactUrl) {
126
+ return formatError(new Error('Contact URL/ID is required'));
127
+ }
128
+
129
+ const local = isLocalMode();
130
+ const client = getClient();
131
+
132
+ // Local uses readContact, cloud uses getContact
133
+ const rawContact = local
134
+ ? await client.readContact(args.contactUrl)
135
+ : await client.getContact(args.contactUrl);
136
+
137
+ const contact = local ? normalizeLocalContact(rawContact) : rawContact;
138
+
139
+ const emailList = contact.emails && contact.emails.length > 0
140
+ ? contact.emails.map(e => ` - ${e.value} (${e.type || e.label || 'other'})`).join('\n')
141
+ : ' (none)';
142
+
143
+ const phoneList = contact.phones && contact.phones.length > 0
144
+ ? contact.phones.map(p => ` - ${p.value} (${p.type || p.label || 'other'})`).join('\n')
145
+ : ' (none)';
146
+
147
+ return formatSuccess(
148
+ `Contact Details:
149
+
150
+ Name: ${contact.displayName}
151
+ First Name: ${contact.firstName || '(not set)'}
152
+ Last Name: ${contact.lastName || '(not set)'}
153
+
154
+ Emails:
155
+ ${emailList}
156
+
157
+ Phones:
158
+ ${phoneList}
159
+
160
+ Organization: ${contact.organization || '(not set)'}
161
+ Title: ${contact.title || '(not set)'}
162
+
163
+ Notes: ${contact.notes || '(none)'}
164
+
165
+ ${local ? 'ID' : 'URL'}: ${contact.url}
166
+ UID: ${contact.uid}`
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Handler: Create contact
172
+ */
173
+ async function handleCreateContact(args) {
174
+ if (!args.displayName && !args.firstName && !args.lastName) {
175
+ return formatError(new Error('At least displayName or firstName/lastName is required'));
176
+ }
177
+
178
+ const local = isLocalMode();
179
+ const client = getClient();
180
+
181
+ const result = await client.createContact({
182
+ displayName: args.displayName,
183
+ firstName: args.firstName,
184
+ lastName: args.lastName,
185
+ email: args.email,
186
+ phone: args.phone,
187
+ organization: args.organization,
188
+ jobTitle: args.title, // Local uses jobTitle
189
+ title: args.title, // Cloud uses title
190
+ note: args.notes, // Local uses note
191
+ notes: args.notes // Cloud uses notes
192
+ });
193
+
194
+ const name = args.displayName || `${args.firstName || ''} ${args.lastName || ''}`.trim();
195
+
196
+ return formatSuccess(
197
+ `Contact created successfully!\n\nName: ${name}${args.email ? `\nEmail: ${args.email}` : ''}${args.phone ? `\nPhone: ${args.phone}` : ''}${args.organization ? `\nOrganization: ${args.organization}` : ''}\n${local ? 'ID' : 'UID'}: ${result.uid || result.id}`
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Handler: Delete contact
203
+ */
204
+ async function handleDeleteContact(args) {
205
+ if (!args.contactUrl) {
206
+ return formatError(new Error('Contact URL/ID is required'));
207
+ }
208
+
209
+ const client = getClient();
210
+ await client.deleteContact(args.contactUrl);
211
+
212
+ return formatSuccess('Contact deleted successfully.');
213
+ }
214
+
215
+ /**
216
+ * Handler: List contact accounts (LOCAL mode only)
217
+ */
218
+ async function handleListContactAccounts(args) {
219
+ if (!isLocalMode()) {
220
+ return formatError(new Error('list-contact-accounts is only available in LOCAL mode'));
221
+ }
222
+
223
+ const accounts = await localClient.listAccounts();
224
+
225
+ if (accounts.length === 0) {
226
+ return formatSuccess('No contact accounts found.', listResult([]));
227
+ }
228
+
229
+ const lines = accounts.map((account, i) => {
230
+ return `${i + 1}. ${account.name}\n Groups: ${account.groupCount}\n ID: ${account.id}`;
231
+ });
232
+
233
+ return formatSuccess(`Contact Accounts (${accounts.length}):\n\n${lines.join('\n\n')}`, listResult(accounts));
234
+ }
235
+
236
+ /**
237
+ * Handler: List contact groups (LOCAL mode only)
238
+ */
239
+ async function handleListContactGroups(args) {
240
+ if (!isLocalMode()) {
241
+ return formatError(new Error('list-contact-groups is only available in LOCAL mode'));
242
+ }
243
+
244
+ const groups = await localClient.listGroups(args.accountId || null);
245
+
246
+ if (groups.length === 0) {
247
+ return formatSuccess('No contact groups found.', listResult([]));
248
+ }
249
+
250
+ const lines = groups.map((group, i) => {
251
+ return `${i + 1}. ${group.name}\n Account: ${group.accountName}\n Contacts: ${group.contactCount}\n ID: ${group.id}`;
252
+ });
253
+
254
+ return formatSuccess(`Contact Groups (${groups.length}):\n\n${lines.join('\n\n')}`, listResult(groups));
255
+ }
256
+
257
+ // Tool definitions
258
+ const contactsTools = [
259
+ {
260
+ name: 'list-contacts',
261
+ outputSchema: listOutput('Contacts'),
262
+ title: 'List Contacts',
263
+ description: 'Lists contacts from your iCloud address book',
264
+ inputSchema: {
265
+ count: z.number().int().min(1).max(50).optional().describe('Number of contacts to retrieve (default: 25, max: 50)')
266
+ },
267
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
268
+ handler: withErrorHandler(handleListContacts, 'list-contacts')
269
+ },
270
+ {
271
+ name: 'search-contacts',
272
+ outputSchema: listOutput('Matching contacts'),
273
+ title: 'Search Contacts',
274
+ description: 'Search contacts by name, email, or phone',
275
+ inputSchema: {
276
+ query: z.string().describe('Search query (name, email, or phone)'),
277
+ count: z.number().int().min(1).max(50).optional().describe('Max results (default: 25, max: 50)')
278
+ },
279
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
280
+ handler: withErrorHandler(handleSearchContacts, 'search-contacts')
281
+ },
282
+ {
283
+ name: 'read-contact',
284
+ title: 'Read Contact',
285
+ description: 'Get detailed information about a specific contact',
286
+ inputSchema: {
287
+ contactUrl: z.string().describe('URL or ID of the contact (from list-contacts output)')
288
+ },
289
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
290
+ handler: withErrorHandler(handleReadContact, 'read-contact')
291
+ },
292
+ {
293
+ name: 'create-contact',
294
+ title: 'Create Contact',
295
+ description: 'Creates a new contact',
296
+ inputSchema: {
297
+ displayName: z.string().optional().describe('Full display name'),
298
+ firstName: z.string().optional().describe('First name'),
299
+ lastName: z.string().optional().describe('Last name'),
300
+ email: z.string().optional().describe('Email address'),
301
+ phone: z.string().optional().describe('Phone number'),
302
+ organization: z.string().optional().describe('Company/Organization'),
303
+ title: z.string().optional().describe('Job title'),
304
+ notes: z.string().optional().describe('Notes about the contact')
305
+ },
306
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},
307
+ handler: withErrorHandler(handleCreateContact, 'create-contact')
308
+ },
309
+ {
310
+ name: 'delete-contact',
311
+ title: 'Delete Contact',
312
+ description: 'Deletes a contact',
313
+ inputSchema: {
314
+ contactUrl: z.string().describe('URL or ID of the contact to delete (from list-contacts output)')
315
+ },
316
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
317
+ handler: withErrorHandler(handleDeleteContact, 'delete-contact')
318
+ },
319
+ {
320
+ name: 'list-contact-accounts',
321
+ outputSchema: listOutput('Contact accounts'),
322
+ title: 'List Contact Accounts',
323
+ description: 'Lists all contact accounts (iCloud, Google, Exchange, etc.) - LOCAL mode only',
324
+ inputSchema: {},
325
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
326
+ handler: withErrorHandler(handleListContactAccounts, 'list-contact-accounts')
327
+ },
328
+ {
329
+ name: 'list-contact-groups',
330
+ outputSchema: listOutput('Contact groups'),
331
+ title: 'List Contact Groups',
332
+ description: 'Lists contact groups from all accounts or a specific account - LOCAL mode only',
333
+ inputSchema: {
334
+ accountId: z.string().optional().describe('Optional account ID to filter groups by (from list-contact-accounts)')
335
+ },
336
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
337
+ handler: withErrorHandler(handleListContactGroups, 'list-contact-groups')
338
+ }
339
+ ];
340
+
341
+ module.exports = {
342
+ contactsTools,
343
+ handleListContacts,
344
+ handleSearchContacts,
345
+ handleReadContact,
346
+ handleCreateContact,
347
+ handleDeleteContact,
348
+ handleListContactAccounts,
349
+ handleListContactGroups
350
+ };