Initial local macOS MCP service
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.logs/
|
||||
.DS_Store
|
||||
coverage/
|
||||
@@ -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).
|
||||
Generated
+1160
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||
}
|
||||
Executable
+51
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
LABEL="com.local.macmini-mcp"
|
||||
DOMAIN="gui/$(id -u)"
|
||||
|
||||
launchctl print "$DOMAIN/$LABEL"
|
||||
Executable
+10
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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&D <today></h1><div>First<br>"second"</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/);
|
||||
});
|
||||
Reference in New Issue
Block a user