Initial local macOS MCP service
This commit is contained in:
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
const paragraphs = body
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<div>${escape(paragraph).replaceAll("\n", "<br>")}</div>`)
|
||||
.join("");
|
||||
|
||||
return `<h1>${escape(title)}</h1>${paragraphs}`;
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user