From 5b0c711f452c26350f7c1ff18e543c74650ad71d Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Tue, 26 May 2026 22:32:00 -0400 Subject: [PATCH] Add Contacts MCP tools --- README.md | 5 +- src/integrations/contacts.js | 102 +++++++++++++++++++++++++++++++++++ src/server.js | 50 +++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/integrations/contacts.js diff --git a/README.md b/README.md index 57fe97e..149a33b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ and stays on the local machine. | `reminders_list_lists` | List reminder lists | | `reminders_list` | List reminders | | `reminders_create` | Create a reminder | +| `contacts_search` | Search contact names and organizations without disclosing contact methods | +| `contacts_read` | Read phone and email details for one selected contact | +| `contacts_create` | Create a contact with optional email and phone details | There are no destructive tools in the initial server. @@ -101,7 +104,7 @@ For a harness that launches stdio servers, use: ## Permissions and security -On first use of a Notes, Calendar, or Reminders tool, macOS may ask for +On first use of a Notes, Calendar, Reminders, or Contacts tool, macOS may ask for Automation access for Node. Permit only the applications you want the server to control under **System Settings > Privacy & Security > Automation**. diff --git a/src/integrations/contacts.js b/src/integrations/contacts.js new file mode 100644 index 0000000..3c09806 --- /dev/null +++ b/src/integrations/contacts.js @@ -0,0 +1,102 @@ +import { runJxa } from "../apple-events.js"; + +const CONTACTS_SEARCH_SCRIPT = String.raw` +function text(value) { + try { return value ? String(value) : ""; } catch (_) { return ""; } +} + +function run(argv) { + const input = JSON.parse(argv[0]); + const app = Application("Contacts"); + const query = (input.query || "").toLowerCase(); + const people = app.people(); + const found = []; + + for (let i = 0; i < people.length && found.length < input.limit; i++) { + const person = people[i]; + const name = text(person.name()); + const organization = text(person.organization()); + if (query && (name + "\n" + organization).toLowerCase().indexOf(query) === -1) continue; + found.push({ + id: String(person.id()), + name: name, + organization: organization, + modifiedAt: person.modificationDate().toISOString() + }); + } + + return JSON.stringify(found); +}`; + +const CONTACTS_READ_SCRIPT = String.raw` +function text(value) { + try { return value ? String(value) : ""; } catch (_) { return ""; } +} + +function items(values) { + return values.map(function (value) { + return { label: text(value.label()), value: text(value.value()) }; + }); +} + +function run(argv) { + const input = JSON.parse(argv[0]); + const app = Application("Contacts"); + const people = app.people(); + for (let i = 0; i < people.length; i++) { + const person = people[i]; + if (String(person.id()) !== input.id) continue; + return JSON.stringify({ + id: String(person.id()), + name: text(person.name()), + firstName: text(person.firstName()), + lastName: text(person.lastName()), + organization: text(person.organization()), + jobTitle: text(person.jobTitle()), + emails: items(person.emails()), + phones: items(person.phones()), + modifiedAt: person.modificationDate().toISOString() + }); + } + throw new Error("Contact not found"); +}`; + +const CONTACTS_CREATE_SCRIPT = String.raw` +function run(argv) { + const input = JSON.parse(argv[0]); + const app = Application("Contacts"); + const person = app.Person({ + firstName: input.firstName || "", + lastName: input.lastName || "", + organization: input.organization || "", + jobTitle: input.jobTitle || "", + note: input.note || "" + }); + + app.people.push(person); + if (input.email) { + person.emails.push(app.Email({label: input.email.label, value: input.email.value})); + } + if (input.phone) { + person.phones.push(app.Phone({label: input.phone.label, value: input.phone.value})); + } + app.save(); + + return JSON.stringify({ + id: String(person.id()), + name: String(person.name()), + organization: input.organization || "" + }); +}`; + +export function searchContacts(input) { + return runJxa(CONTACTS_SEARCH_SCRIPT, input); +} + +export function readContact(id) { + return runJxa(CONTACTS_READ_SCRIPT, { id }); +} + +export function createContact(input) { + return runJxa(CONTACTS_CREATE_SCRIPT, input); +} diff --git a/src/server.js b/src/server.js index 1550ff9..f8da921 100644 --- a/src/server.js +++ b/src/server.js @@ -6,6 +6,11 @@ import { listCalendars, listEvents, } from "./integrations/calendar.js"; +import { + createContact, + readContact, + searchContacts, +} from "./integrations/contacts.js"; import { createNote, listNotes, readNote } from "./integrations/notes.js"; import { createReminder, @@ -140,5 +145,50 @@ export function createMacMiniMcpServer() { handled(createReminder), ); + server.tool( + "contacts_search", + "Search Contacts by name or organization. Returns metadata only; use contacts_read for contact methods.", + { + query: z.string().optional().describe("Text to search in display name or organization."), + limit: z.number().int().positive().max(100).default(20), + }, + handled(searchContacts), + ); + + server.tool( + "contacts_read", + "Read contact details, including email addresses and phone numbers, after selecting an ID with contacts_search.", + { + id: z.string().min(1).describe("Persistent Contacts person ID."), + }, + handled(({ id }) => readContact(id)), + ); + + server.tool( + "contacts_create", + "Create a new contact with optional email address and phone number.", + { + firstName: z.string().max(200).optional(), + lastName: z.string().max(200).optional(), + organization: z.string().max(300).optional(), + jobTitle: z.string().max(300).optional(), + note: z.string().max(2000).optional(), + email: z.object({ + label: z.string().max(100).default("work"), + value: z.string().email(), + }).optional(), + phone: z.object({ + label: z.string().max(100).default("mobile"), + value: z.string().min(1).max(100), + }).optional(), + }, + handled((input) => { + if (!input.firstName && !input.lastName && !input.organization) { + throw new Error("firstName, lastName, or organization is required."); + } + return createContact(input); + }), + ); + return server; }