Initial local macOS MCP service

This commit is contained in:
Adolfo Reyna
2026-05-26 21:54:57 -04:00
commit ad3bac91ae
18 changed files with 1937 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export async function runJxa(script, input = {}) {
try {
const { stdout } = await execFileAsync(
"/usr/bin/osascript",
["-l", "JavaScript", "-e", script, "--", JSON.stringify(input)],
{
timeout: 30_000,
maxBuffer: 2 * 1024 * 1024,
},
);
const text = stdout.trim();
return text ? JSON.parse(text) : null;
} catch (error) {
const detail = error.stderr?.trim() || error.message;
throw new Error(
`macOS automation failed. Grant Automation access to the service if prompted. ${detail}`,
);
}
}
export function dateFromInput(value, fieldName) {
const date = new Date(value);
if (Number.isNaN(date.valueOf())) {
throw new Error(`${fieldName} must be a valid ISO-8601 date and time.`);
}
return date;
}
export function plainTextToNoteHtml(title, body) {
const escape = (text) =>
text
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
const paragraphs = body
.split(/\n{2,}/)
.map((paragraph) => `<div>${escape(paragraph).replaceAll("\n", "<br>")}</div>`)
.join("");
return `<h1>${escape(title)}</h1>${paragraphs}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { loadEnvFile } from "node:process";
try {
loadEnvFile();
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
export function getConfig() {
const port = Number.parseInt(process.env.MACMINI_MCP_PORT || "7331", 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("MACMINI_MCP_PORT must be an integer between 1 and 65535.");
}
return {
host: process.env.MACMINI_MCP_HOST || "127.0.0.1",
port,
token: process.env.MACMINI_MCP_TOKEN || "",
};
}
+57
View File
@@ -0,0 +1,57 @@
import http from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { getConfig } from "./config.js";
import { createMacMiniMcpServer } from "./server.js";
const config = getConfig();
function authorized(request) {
if (!config.token) {
return true;
}
return request.headers.authorization === `Bearer ${config.token}`;
}
const service = http.createServer(async (request, response) => {
const pathname = new URL(request.url, `http://${request.headers.host || "localhost"}`).pathname;
if (pathname === "/health" && request.method === "GET") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, service: "macmini-mcp" }));
return;
}
if (pathname !== "/mcp") {
response.writeHead(404).end("Not found");
return;
}
if (!authorized(request)) {
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Unauthorized");
return;
}
const server = createMacMiniMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
response.on("close", () => {
transport.close();
server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(request, response);
} catch (error) {
console.error("MCP HTTP request failed:", error);
if (!response.headersSent) {
response.writeHead(500).end("MCP request failed");
}
}
});
service.listen(config.port, config.host, () => {
console.error(`macmini-mcp listening on http://${config.host}:${config.port}/mcp`);
});
+98
View File
@@ -0,0 +1,98 @@
import { dateFromInput, runJxa } from "../apple-events.js";
const CALENDAR_LIST_SCRIPT = String.raw`
function run() {
const app = Application("Calendar");
return JSON.stringify(app.calendars().map(function (calendar) {
return { name: String(calendar.name()), writable: Boolean(calendar.writable()) };
}));
}`;
const EVENTS_LIST_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Calendar");
const start = new Date(input.start);
const end = new Date(input.end);
const calendars = app.calendars();
const found = [];
for (let c = 0; c < calendars.length; c++) {
const calendar = calendars[c];
const calendarName = String(calendar.name());
if (input.calendar && calendarName !== input.calendar) continue;
const events = calendar.events();
for (let e = 0; e < events.length; e++) {
const event = events[e];
const eventStart = event.startDate();
const eventEnd = event.endDate();
if (eventEnd < start || eventStart > end) continue;
found.push({
id: String(event.uid()),
calendar: calendarName,
title: String(event.summary()),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: Boolean(event.alldayEvent()),
location: String(event.location() || "")
});
}
}
found.sort(function (a, b) { return a.start.localeCompare(b.start); });
return JSON.stringify(found.slice(0, input.limit));
}`;
const EVENT_CREATE_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Calendar");
const calendars = app.calendars();
let destination = null;
for (let c = 0; c < calendars.length; c++) {
if (String(calendars[c].name()) === input.calendar) {
destination = calendars[c];
break;
}
}
if (!destination) throw new Error("Calendar not found");
if (!destination.writable()) throw new Error("Calendar is read-only");
const event = app.Event({
summary: input.title,
startDate: new Date(input.start),
endDate: new Date(input.end),
alldayEvent: input.allDay,
description: input.notes || "",
location: input.location || ""
});
destination.events.push(event);
return JSON.stringify({
id: String(event.uid()),
calendar: String(destination.name()),
title: String(event.summary()),
start: event.startDate().toISOString(),
end: event.endDate().toISOString()
});
}`;
export function listCalendars() {
return runJxa(CALENDAR_LIST_SCRIPT);
}
export function listEvents({ start, end, calendar, limit }) {
dateFromInput(start, "start");
dateFromInput(end, "end");
if (new Date(start) > new Date(end)) {
throw new Error("start must occur before end.");
}
return runJxa(EVENTS_LIST_SCRIPT, { start, end, calendar, limit });
}
export function createEvent(input) {
const start = dateFromInput(input.start, "start");
const end = dateFromInput(input.end, "end");
if (start >= end) {
throw new Error("start must occur before end.");
}
return runJxa(EVENT_CREATE_SCRIPT, input);
}
+109
View File
@@ -0,0 +1,109 @@
import { plainTextToNoteHtml, runJxa } from "../apple-events.js";
const NOTES_LIST_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Notes");
const query = (input.query || "").toLowerCase();
const folderName = input.folder || "";
const limit = input.limit;
const found = [];
const accounts = app.accounts();
for (let a = 0; a < accounts.length && found.length < limit; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length && found.length < limit; f++) {
const folder = folders[f];
const currentFolder = String(folder.name());
if (folderName && currentFolder !== folderName) continue;
const notes = folder.notes();
for (let n = 0; n < notes.length && found.length < limit; n++) {
const note = notes[n];
let title = "";
let text = "";
try { title = String(note.name()); } catch (_) {}
try { text = String(note.plaintext()); } catch (_) {}
if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue;
found.push({
id: String(note.id()),
title: title,
folder: currentFolder,
modifiedAt: note.modificationDate().toISOString(),
preview: text.slice(0, 180)
});
}
}
}
return JSON.stringify(found);
}`;
const NOTES_READ_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Notes");
const accounts = app.accounts();
for (let a = 0; a < accounts.length; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length; f++) {
const notes = folders[f].notes();
for (let n = 0; n < notes.length; n++) {
const note = notes[n];
if (String(note.id()) === input.id) {
return JSON.stringify({
id: String(note.id()),
title: String(note.name()),
folder: String(folders[f].name()),
bodyHtml: String(note.body()),
plaintext: String(note.plaintext()),
createdAt: note.creationDate().toISOString(),
modifiedAt: note.modificationDate().toISOString()
});
}
}
}
}
throw new Error("Note not found");
}`;
const NOTES_CREATE_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Notes");
const accounts = app.accounts();
let destination = null;
for (let a = 0; a < accounts.length && !destination; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length; f++) {
if (!input.folder || String(folders[f].name()) === input.folder) {
destination = folders[f];
break;
}
}
}
if (!destination) throw new Error("Notes destination folder not found");
const note = app.Note({body: input.html});
destination.notes.push(note);
return JSON.stringify({
id: String(note.id()),
title: String(note.name()),
folder: String(destination.name())
});
}`;
export function listNotes(input) {
return runJxa(NOTES_LIST_SCRIPT, input);
}
export function readNote(id) {
return runJxa(NOTES_READ_SCRIPT, { id });
}
export function createNote({ title, body, folder }) {
return runJxa(NOTES_CREATE_SCRIPT, {
folder,
html: plainTextToNoteHtml(title, body),
});
}
+81
View File
@@ -0,0 +1,81 @@
import { dateFromInput, runJxa } from "../apple-events.js";
const LISTS_SCRIPT = String.raw`
function run() {
const app = Application("Reminders");
return JSON.stringify(app.lists().map(function (list) {
return { id: String(list.id()), name: String(list.name()) };
}));
}`;
const REMINDERS_LIST_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Reminders");
const lists = app.lists();
const found = [];
for (let l = 0; l < lists.length && found.length < input.limit; l++) {
const list = lists[l];
const name = String(list.name());
if (input.list && name !== input.list) continue;
const reminders = list.reminders();
for (let r = 0; r < reminders.length && found.length < input.limit; r++) {
const reminder = reminders[r];
const completed = Boolean(reminder.completed());
if (input.completed !== null && completed !== input.completed) continue;
let due = null;
try {
const value = reminder.dueDate();
due = value ? value.toISOString() : null;
} catch (_) {}
found.push({
id: String(reminder.id()),
list: name,
title: String(reminder.name()),
completed: completed,
due: due,
notes: String(reminder.body() || "")
});
}
}
return JSON.stringify(found);
}`;
const REMINDER_CREATE_SCRIPT = String.raw`
function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("Reminders");
const lists = app.lists();
let destination = null;
for (let l = 0; l < lists.length; l++) {
if (!input.list || String(lists[l].name()) === input.list) {
destination = lists[l];
break;
}
}
if (!destination) throw new Error("Reminders list not found");
const properties = {name: input.title, body: input.notes || ""};
if (input.due) properties.dueDate = new Date(input.due);
const reminder = app.Reminder(properties);
destination.reminders.push(reminder);
return JSON.stringify({
id: String(reminder.id()),
list: String(destination.name()),
title: String(reminder.name())
});
}`;
export function listReminderLists() {
return runJxa(LISTS_SCRIPT);
}
export function listReminders(input) {
return runJxa(REMINDERS_LIST_SCRIPT, input);
}
export function createReminder(input) {
if (input.due) {
dateFromInput(input.due, "due");
}
return runJxa(REMINDER_CREATE_SCRIPT, input);
}
+126
View File
@@ -0,0 +1,126 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createEvent, listCalendars, listEvents } from "./integrations/calendar.js";
import { createNote, listNotes, readNote } from "./integrations/notes.js";
import {
createReminder,
listReminderLists,
listReminders,
} from "./integrations/reminders.js";
const jsonResult = (value) => ({
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
});
const handled = (operation) => async (input) => {
try {
return jsonResult(await operation(input));
} catch (error) {
return {
isError: true,
content: [{ type: "text", text: error.message }],
};
}
};
export function createMacMiniMcpServer() {
const server = new McpServer({
name: "macmini-mcp",
version: "0.1.0",
});
server.tool(
"notes_list",
"Find notes by optional text or folder filter. Returns previews, not full note bodies.",
{
query: z.string().optional().describe("Text to search in note title and plaintext body."),
folder: z.string().optional().describe("Exact Notes folder name."),
limit: z.number().int().positive().max(100).default(20),
},
handled(listNotes),
);
server.tool(
"notes_read",
"Read one Apple Note after locating its ID with notes_list.",
{ id: z.string().min(1).describe("Notes unique ID.") },
handled(({ id }) => readNote(id)),
);
server.tool(
"notes_create",
"Create a new Apple Note from plaintext content.",
{
title: z.string().min(1).max(300),
body: z.string().default(""),
folder: z.string().optional().describe("Exact destination folder; defaults to the first available folder."),
},
handled(createNote),
);
server.tool(
"calendar_list_calendars",
"List macOS Calendar calendars and indicate which accept new events.",
{},
handled(listCalendars),
);
server.tool(
"calendar_list_events",
"List Calendar events intersecting a required ISO-8601 time range.",
{
start: z.string().describe("Range start as an ISO-8601 datetime."),
end: z.string().describe("Range end as an ISO-8601 datetime."),
calendar: z.string().optional().describe("Exact calendar name."),
limit: z.number().int().positive().max(250).default(50),
},
handled(listEvents),
);
server.tool(
"calendar_create_event",
"Create an event on a named writable macOS calendar.",
{
calendar: z.string().min(1).describe("Exact destination calendar name."),
title: z.string().min(1).max(500),
start: z.string().describe("Event start as an ISO-8601 datetime."),
end: z.string().describe("Event end as an ISO-8601 datetime."),
allDay: z.boolean().default(false),
notes: z.string().optional(),
location: z.string().optional(),
},
handled(createEvent),
);
server.tool(
"reminders_list_lists",
"List Reminders lists.",
{},
handled(listReminderLists),
);
server.tool(
"reminders_list",
"List reminders with optional list and completion filters.",
{
list: z.string().optional().describe("Exact Reminders list name."),
completed: z.boolean().nullable().default(false).describe("Use null to return both complete and incomplete items."),
limit: z.number().int().positive().max(100).default(25),
},
handled(listReminders),
);
server.tool(
"reminders_create",
"Create a reminder in an optional named Reminders list.",
{
title: z.string().min(1).max(500),
list: z.string().optional().describe("Exact destination list; defaults to the first available list."),
notes: z.string().optional(),
due: z.string().optional().describe("Optional due date as an ISO-8601 datetime."),
},
handled(createReminder),
);
return server;
}
+7
View File
@@ -0,0 +1,7 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMacMiniMcpServer } from "./server.js";
const server = createMacMiniMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);