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