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
+4
View File
@@ -0,0 +1,4 @@
# The service binds to localhost only. Set a token if your harness supports bearer auth.
MACMINI_MCP_HOST=127.0.0.1
MACMINI_MCP_PORT=7331
# MACMINI_MCP_TOKEN=replace-with-a-long-random-token
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.env
*.log
.logs/
.DS_Store
coverage/
+102
View File
@@ -0,0 +1,102 @@
# MacMini MCP
A local Model Context Protocol server that exposes selected macOS app actions to
an AI harness. It uses Apple's scripting interfaces through `/usr/bin/osascript`
and stays on the local machine.
## Available tools
| Tool | Action |
| --- | --- |
| `notes_list` | Search note titles and plaintext previews |
| `notes_read` | Read a note by the ID returned by `notes_list` |
| `notes_create` | Create a plaintext-backed note |
| `calendar_list_calendars` | List calendars and write capability |
| `calendar_list_events` | List events in an ISO-8601 time window |
| `calendar_create_event` | Create an event on a named writable calendar |
| `reminders_list_lists` | List reminder lists |
| `reminders_list` | List reminders |
| `reminders_create` | Create a reminder |
There are no destructive tools in the initial server.
## Setup
Requires macOS and Node.js 20 or newer.
```sh
npm install
npm run check
npm run service:install
```
The service endpoint is:
```text
http://127.0.0.1:7331/mcp
```
Health check:
```sh
curl -s http://127.0.0.1:7331/health
```
`launchd` runs `node --watch src/http.js`, so edits to the server or imported
modules cause it to restart automatically while the agent remains installed.
After changing installed dependencies or service configuration, run:
```sh
npm install
npm run service:install
```
Operational commands:
```sh
npm run service:status
npm run service:restart
npm run service:uninstall
```
Service logs are stored in `.logs/`.
## Harness configuration
For a harness that supports Streamable HTTP, configure the local MCP URL as
`http://127.0.0.1:7331/mcp`.
For a harness that launches stdio servers, use:
```json
{
"mcpServers": {
"macmini": {
"command": "/Users/adolforeyna/.nvm/versions/node/v22.22.0/bin/node",
"args": ["/Users/adolforeyna/Projects/MacMiniMCP/src/stdio.js"]
}
}
}
```
## Permissions and security
On first use of a Notes, Calendar, or Reminders 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**.
The HTTP service binds to `127.0.0.1` by default. To require bearer
authentication even locally, create `.env` from `.env.example` and set
`MACMINI_MCP_TOKEN`; clients must then send `Authorization: Bearer <token>`.
Do not bind to a non-loopback address without enabling authentication and
reviewing the tools first.
## Development
```sh
npm run check
npm run dev
```
The MCP transport follows the official TypeScript SDK Streamable HTTP server
approach: [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
+1160
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "macmini-mcp",
"version": "0.1.0",
"private": true,
"description": "Local MCP server for approved macOS app automation.",
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"start": "node src/http.js",
"start:stdio": "node src/stdio.js",
"dev": "node --watch src/http.js",
"check": "node --check src/*.js && node --check src/integrations/*.js && node --test",
"service:install": "./scripts/install-service.sh",
"service:restart": "./scripts/restart-service.sh",
"service:status": "./scripts/status-service.sh",
"service:uninstall": "./scripts/uninstall-service.sh"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.25.0"
},
"devDependencies": {}
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/zsh
set -euo pipefail
LABEL="com.local.macmini-mcp"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="$(command -v node)"
AGENT_DIR="$HOME/Library/LaunchAgents"
PLIST="$AGENT_DIR/$LABEL.plist"
LOG_DIR="$PROJECT_DIR/.logs"
DOMAIN="gui/$(id -u)"
mkdir -p "$AGENT_DIR" "$LOG_DIR"
cat > "$PLIST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>--watch</string>
<string>src/http.js</string>
</array>
<key>WorkingDirectory</key>
<string>$PROJECT_DIR</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>2</integer>
<key>StandardOutPath</key>
<string>$LOG_DIR/service.out.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/service.err.log</string>
</dict>
</plist>
PLIST
plutil -lint "$PLIST"
launchctl bootout "$DOMAIN" "$PLIST" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$PLIST"
launchctl enable "$DOMAIN/$LABEL"
launchctl kickstart -k "$DOMAIN/$LABEL"
echo "Installed $LABEL"
echo "Endpoint: http://127.0.0.1:7331/mcp"
echo "Logs: $LOG_DIR"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/zsh
set -euo pipefail
LABEL="com.local.macmini-mcp"
DOMAIN="gui/$(id -u)"
launchctl kickstart -k "$DOMAIN/$LABEL"
echo "Restarted $LABEL"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/zsh
set -euo pipefail
LABEL="com.local.macmini-mcp"
DOMAIN="gui/$(id -u)"
launchctl print "$DOMAIN/$LABEL"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/zsh
set -euo pipefail
LABEL="com.local.macmini-mcp"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
launchctl bootout "$DOMAIN" "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "Uninstalled $LABEL"
+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("&", "&amp;")
.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);
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { dateFromInput, plainTextToNoteHtml } from "../src/apple-events.js";
test("plainTextToNoteHtml escapes input and preserves line breaks", () => {
assert.equal(
plainTextToNoteHtml("R&D <today>", 'First\n"second"'),
"<h1>R&amp;D &lt;today&gt;</h1><div>First<br>&quot;second&quot;</div>",
);
});
test("dateFromInput accepts valid datetimes and rejects invalid input", () => {
assert.equal(dateFromInput("2026-05-26T10:00:00-04:00", "start").toISOString(), "2026-05-26T14:00:00.000Z");
assert.throws(() => dateFromInput("not-a-date", "start"), /valid ISO-8601/);
});