notes-poke 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # notes-poke
2
+
3
+ A local MCP server that lets Poke work with Apple Notes on macOS.
4
+
5
+ The server runs on your Mac, talks to Apple Notes through its documented AppleScript automation surface, and exposes a Streamable HTTP MCP endpoint at `/mcp` for Poke.
6
+
7
+ ## Quick Start
8
+
9
+ For normal users:
10
+
11
+ ```bash
12
+ npm install -g notes-poke && notes-poke install
13
+ ```
14
+
15
+ That installs the official Apple Notes Poke connector, checks Notes, asks macOS for Automation permission if needed, starts the local MCP server, and connects it to Poke.
16
+
17
+ Then ask Poke:
18
+
19
+ ```text
20
+ Use my Apple Notes integration and search my recent notes.
21
+ ```
22
+
23
+ For local development from this repository:
24
+
25
+ ```bash
26
+ npm install
27
+ npm run build
28
+ npm run start
29
+ ```
30
+
31
+ In another terminal:
32
+
33
+ ```bash
34
+ npm run connect
35
+ ```
36
+
37
+ Normal users should use `notes-poke install` instead of running tunnel commands manually.
38
+
39
+ ## How It Works
40
+
41
+ Apple Notes stores its data locally on each user's Mac, so every user runs their own connector and gets their own Poke tunnel.
42
+
43
+ ```text
44
+ User's Poke account
45
+ -> user's Poke tunnel
46
+ -> notes-poke running on the user's Mac
47
+ -> Apple Notes
48
+ ```
49
+
50
+ The recipe should tell users to run:
51
+
52
+ ```bash
53
+ npm install -g notes-poke && notes-poke install
54
+ ```
55
+
56
+ That command signs in to Poke if needed, starts the local MCP server, creates the user's own tunnel, and keeps both running with macOS LaunchAgents.
57
+
58
+ ## CLI
59
+
60
+ ```bash
61
+ notes-poke install
62
+ notes-poke setup
63
+ notes-poke start
64
+ notes-poke connect
65
+ notes-poke connect --recipe
66
+ notes-poke status
67
+ notes-poke uninstall
68
+ ```
69
+
70
+ `notes-poke install` creates user LaunchAgents so the server and tunnel keep running in the background after the terminal exits. Logs are written to `~/.notes-poke/logs`.
71
+
72
+ If you only want the local MCP server without starting a Poke tunnel:
73
+
74
+ ```bash
75
+ notes-poke install --no-tunnel
76
+ ```
77
+
78
+ To create a shareable Poke recipe while installing:
79
+
80
+ ```bash
81
+ notes-poke install --recipe
82
+ ```
83
+
84
+ ## Optional Auth
85
+
86
+ For localhost-only development, auth is off by default. To require a bearer token:
87
+
88
+ ```bash
89
+ export NOTES_POKE_API_TOKEN="your-secret"
90
+ notes-poke start
91
+ ```
92
+
93
+ ## Environment
94
+
95
+ | Variable | Default | Description |
96
+ | --- | --- | --- |
97
+ | `NOTES_POKE_HOST` | `127.0.0.1` | HTTP bind host |
98
+ | `NOTES_POKE_PORT` | `8766` | HTTP port |
99
+ | `NOTES_POKE_API_TOKEN` | empty | Optional bearer token |
100
+
101
+ ## Scope
102
+
103
+ This project exposes Apple Notes concepts available through AppleScript: accounts, folders, notes, attachments, selection, HTML body, plaintext search, create/update/append/delete/move, and reveal in Notes.
104
+
105
+ Apple Notes hashtags are not exposed as native scriptable tag objects. The connector can search/extract hashtag-like text, but cannot manage Apple Notes tags as first-class objects.
@@ -0,0 +1,43 @@
1
+ import { spawn } from "node:child_process";
2
+ export async function runAppleScript(script, options = {}) {
3
+ const timeoutMs = options.timeoutMs ?? 20_000;
4
+ return await new Promise((resolve, reject) => {
5
+ const child = spawn("osascript", ["-"], {
6
+ stdio: ["pipe", "pipe", "pipe"],
7
+ });
8
+ let stdout = "";
9
+ let stderr = "";
10
+ const timer = setTimeout(() => {
11
+ child.kill("SIGKILL");
12
+ reject(new Error(`AppleScript timed out after ${timeoutMs}ms`));
13
+ }, timeoutMs);
14
+ child.stdout.setEncoding("utf8");
15
+ child.stderr.setEncoding("utf8");
16
+ child.stdout.on("data", (chunk) => {
17
+ stdout += chunk;
18
+ });
19
+ child.stderr.on("data", (chunk) => {
20
+ stderr += chunk;
21
+ });
22
+ child.on("error", (error) => {
23
+ clearTimeout(timer);
24
+ reject(error);
25
+ });
26
+ child.on("close", (code) => {
27
+ clearTimeout(timer);
28
+ if (code === 0) {
29
+ resolve(stdout.trim());
30
+ return;
31
+ }
32
+ reject(new Error(stderr.trim() || `osascript exited with code ${code}`));
33
+ });
34
+ child.stdin.end(script);
35
+ });
36
+ }
37
+ export function asString(value) {
38
+ return `"${value
39
+ .replaceAll("\\", "\\\\")
40
+ .replaceAll("\"", "\\\"")
41
+ .replaceAll("\r", "\\r")
42
+ .replaceAll("\n", "\\n")}"`;
43
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { constants } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { startServer } from "./server.js";
10
+ import { getVersion } from "./notes.js";
11
+ const program = new Command();
12
+ const serverLabel = "com.notes-poke.server";
13
+ const tunnelLabel = "com.notes-poke.tunnel";
14
+ const cliPath = fileURLToPath(import.meta.url);
15
+ const stateDir = join(homedir(), ".notes-poke");
16
+ const logDir = join(stateDir, "logs");
17
+ const launchAgentsDir = join(homedir(), "Library", "LaunchAgents");
18
+ async function runSetup(options) {
19
+ await access("/usr/bin/osascript", constants.X_OK);
20
+ console.log("OK osascript is available");
21
+ await access("/System/Applications/Notes.app", constants.R_OK);
22
+ console.log("OK Notes.app found in /System/Applications");
23
+ if (options.touchNotes || options.touchThings) {
24
+ const version = await getVersion();
25
+ console.log(`OK Apple Notes responded: ${version}`);
26
+ }
27
+ else {
28
+ console.log("Tip: run `notes-poke setup --touch-notes` to request/check macOS Automation permission.");
29
+ }
30
+ }
31
+ function xmlEscape(value) {
32
+ return value
33
+ .replaceAll("&", "&")
34
+ .replaceAll("<", "&lt;")
35
+ .replaceAll(">", "&gt;")
36
+ .replaceAll("\"", "&quot;")
37
+ .replaceAll("'", "&apos;");
38
+ }
39
+ function userDomain() {
40
+ return `gui/${process.getuid?.() ?? ""}`;
41
+ }
42
+ function execFileAsync(file, args, options = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ execFile(file, args, (error, stdout, stderr) => {
45
+ const rawCode = error?.code;
46
+ const code = error ? (typeof rawCode === "number" ? rawCode : 1) : 0;
47
+ if (error && options.rejectOnError !== false) {
48
+ reject(new Error(stderr || stdout || error.message));
49
+ return;
50
+ }
51
+ resolve({ stdout, stderr, code });
52
+ });
53
+ });
54
+ }
55
+ function shellQuote(value) {
56
+ return `'${value.replaceAll("'", "'\\''")}'`;
57
+ }
58
+ async function runInteractive(command, args) {
59
+ await new Promise((resolve, reject) => {
60
+ const child = spawn(command, args, { stdio: "inherit" });
61
+ child.on("error", reject);
62
+ child.on("exit", (code) => {
63
+ if (code === 0) {
64
+ resolve();
65
+ return;
66
+ }
67
+ reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
68
+ });
69
+ });
70
+ }
71
+ async function ensurePokeLogin() {
72
+ const whoami = await execFileAsync("npx", ["poke@latest", "whoami"], { rejectOnError: false });
73
+ if (whoami.code === 0) {
74
+ console.log(`OK Poke logged in as ${whoami.stdout.trim()}`);
75
+ return;
76
+ }
77
+ console.log("Poke CLI is not logged in yet. Starting `npx poke@latest login`...");
78
+ await runInteractive("npx", ["poke@latest", "login"]);
79
+ }
80
+ async function bootout(label, plistPath) {
81
+ await execFileAsync("launchctl", ["bootout", userDomain(), plistPath], { rejectOnError: false });
82
+ await execFileAsync("launchctl", ["bootout", userDomain(), label], { rejectOnError: false });
83
+ }
84
+ async function bootstrap(label, plistPath) {
85
+ await bootout(label, plistPath);
86
+ await execFileAsync("launchctl", ["bootstrap", userDomain(), plistPath]);
87
+ await execFileAsync("launchctl", ["kickstart", "-k", `${userDomain()}/${label}`], { rejectOnError: false });
88
+ }
89
+ async function writeLaunchAgent(path, body) {
90
+ await writeFile(path, body, "utf8");
91
+ }
92
+ function plist(label, args, stdout, stderr, env = {}) {
93
+ const envEntries = Object.entries(env)
94
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
95
+ .join("\n");
96
+ return `<?xml version="1.0" encoding="UTF-8"?>
97
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
98
+ <plist version="1.0">
99
+ <dict>
100
+ <key>Label</key>
101
+ <string>${xmlEscape(label)}</string>
102
+ <key>ProgramArguments</key>
103
+ <array>
104
+ ${args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}
105
+ </array>
106
+ <key>WorkingDirectory</key>
107
+ <string>${xmlEscape(stateDir)}</string>
108
+ <key>EnvironmentVariables</key>
109
+ <dict>
110
+ <key>PATH</key>
111
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
112
+ ${envEntries ? `\n${envEntries}` : ""}
113
+ </dict>
114
+ <key>RunAtLoad</key>
115
+ <true/>
116
+ <key>KeepAlive</key>
117
+ <true/>
118
+ <key>StandardOutPath</key>
119
+ <string>${xmlEscape(stdout)}</string>
120
+ <key>StandardErrorPath</key>
121
+ <string>${xmlEscape(stderr)}</string>
122
+ </dict>
123
+ </plist>
124
+ `;
125
+ }
126
+ async function installServices(options) {
127
+ await runSetup({ touchNotes: options.touchNotes ?? true });
128
+ if (options.tunnel) {
129
+ await ensurePokeLogin();
130
+ }
131
+ await mkdir(logDir, { recursive: true });
132
+ await mkdir(launchAgentsDir, { recursive: true });
133
+ const serverPlistPath = join(launchAgentsDir, `${serverLabel}.plist`);
134
+ const tunnelPlistPath = join(launchAgentsDir, `${tunnelLabel}.plist`);
135
+ const url = `http://localhost:${options.port}/mcp`;
136
+ const tunnelCommand = [
137
+ "exec",
138
+ "npx",
139
+ "poke@latest",
140
+ "tunnel",
141
+ url,
142
+ "-n",
143
+ shellQuote(options.name),
144
+ ...(options.recipe ? ["--recipe"] : []),
145
+ ].join(" ");
146
+ await writeLaunchAgent(serverPlistPath, plist(serverLabel, [process.execPath, cliPath, "start", "--host", options.host, "--port", options.port], join(logDir, "server.log"), join(logDir, "server.error.log"), {
147
+ NOTES_POKE_HOST: options.host,
148
+ NOTES_POKE_PORT: options.port,
149
+ }));
150
+ await bootstrap(serverLabel, serverPlistPath);
151
+ console.log(`OK installed and started ${serverLabel}`);
152
+ if (options.tunnel) {
153
+ await writeLaunchAgent(tunnelPlistPath, plist(tunnelLabel, ["/bin/zsh", "-lc", tunnelCommand], join(logDir, "tunnel.log"), join(logDir, "tunnel.error.log")));
154
+ await bootstrap(tunnelLabel, tunnelPlistPath);
155
+ console.log(`OK installed and started ${tunnelLabel}`);
156
+ }
157
+ console.log("");
158
+ console.log("Notes Poke is installed.");
159
+ console.log(`Local MCP server: ${url}`);
160
+ console.log(`Logs: ${logDir}`);
161
+ console.log("");
162
+ console.log("Try this in Poke:");
163
+ console.log("Use my Apple Notes integration and search my recent notes.");
164
+ }
165
+ async function uninstallServices() {
166
+ const serverPlistPath = join(launchAgentsDir, `${serverLabel}.plist`);
167
+ const tunnelPlistPath = join(launchAgentsDir, `${tunnelLabel}.plist`);
168
+ await bootout(tunnelLabel, tunnelPlistPath);
169
+ await bootout(serverLabel, serverPlistPath);
170
+ console.log("Stopped Notes Poke launch services. Plist files are left in ~/Library/LaunchAgents for inspection.");
171
+ }
172
+ async function status() {
173
+ for (const label of [serverLabel, tunnelLabel]) {
174
+ const result = await execFileAsync("launchctl", ["print", `${userDomain()}/${label}`], { rejectOnError: false });
175
+ if (result.code === 0) {
176
+ const state = result.stdout.match(/state = ([^\n]+)/)?.[1] ?? "unknown";
177
+ const pid = result.stdout.match(/pid = ([^\n]+)/)?.[1] ?? "not running";
178
+ console.log(`${label}: ${state}, pid ${pid}`);
179
+ }
180
+ else {
181
+ console.log(`${label}: not installed or not loaded`);
182
+ }
183
+ }
184
+ try {
185
+ const serverLog = await readFile(join(logDir, "server.log"), "utf8");
186
+ console.log(`\nLast server log:\n${serverLog.trim().split("\n").slice(-4).join("\n")}`);
187
+ }
188
+ catch {
189
+ // No logs yet.
190
+ }
191
+ }
192
+ program
193
+ .name("notes-poke")
194
+ .description("Local Apple Notes MCP server for Poke")
195
+ .version("0.1.0");
196
+ program
197
+ .command("setup")
198
+ .description("Check local prerequisites for running the Apple Notes MCP server")
199
+ .option("--touch-notes", "Ask Apple Notes for its version, which may trigger macOS Automation permission")
200
+ .option("--touch-things", "Deprecated alias for --touch-notes")
201
+ .action(runSetup);
202
+ program
203
+ .command("start")
204
+ .description("Start the local Streamable HTTP MCP server")
205
+ .option("--host <host>", "Host to bind", process.env.NOTES_POKE_HOST ?? "127.0.0.1")
206
+ .option("--port <port>", "Port to bind", process.env.NOTES_POKE_PORT ?? "8766")
207
+ .action((options) => {
208
+ const server = startServer({
209
+ host: options.host,
210
+ port: Number.parseInt(options.port, 10),
211
+ });
212
+ const shutdown = () => {
213
+ server.close(() => {
214
+ process.exit(0);
215
+ });
216
+ };
217
+ process.on("SIGINT", shutdown);
218
+ process.on("SIGTERM", shutdown);
219
+ });
220
+ program
221
+ .command("connect")
222
+ .description("Connect the running local MCP server to Poke through the official Poke tunnel")
223
+ .option("--url <url>", "Local MCP server URL", "http://localhost:8766/mcp")
224
+ .option("-n, --name <name>", "Poke integration display name", "Apple Notes")
225
+ .option("--recipe", "Ask Poke CLI to create a shareable recipe link")
226
+ .action((options) => {
227
+ const args = ["poke@latest", "tunnel", options.url, "-n", options.name];
228
+ if (options.recipe) {
229
+ args.push("--recipe");
230
+ }
231
+ console.log(`Running: npx ${args.join(" ")}`);
232
+ const child = spawn("npx", args, {
233
+ stdio: "inherit",
234
+ });
235
+ child.on("exit", (code) => {
236
+ process.exit(code ?? 0);
237
+ });
238
+ });
239
+ program
240
+ .command("install")
241
+ .description("One-shot install: check Apple Notes, create launchd services, start the MCP server, and connect Poke")
242
+ .option("-n, --name <name>", "Poke integration display name", "Apple Notes")
243
+ .option("--host <host>", "Host to bind", process.env.NOTES_POKE_HOST ?? "127.0.0.1")
244
+ .option("--port <port>", "Port to bind", process.env.NOTES_POKE_PORT ?? "8766")
245
+ .option("--recipe", "Ask Poke CLI to create a shareable recipe link from the tunnel")
246
+ .option("--no-tunnel", "Only install the local MCP server; do not start the Poke tunnel")
247
+ .option("--no-touch-notes", "Do not touch Apple Notes during setup")
248
+ .action((options) => installServices(options));
249
+ program
250
+ .command("uninstall")
251
+ .description("Stop launchd services created by notes-poke install")
252
+ .action(uninstallServices);
253
+ program
254
+ .command("status")
255
+ .description("Show launchd service state and recent logs")
256
+ .action(status);
257
+ await program.parseAsync();
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { startServer } from "./server.js";
2
+ export { createNotesPokeMcpServer } from "./mcp.js";
package/dist/mcp.js ADDED
@@ -0,0 +1,150 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod/v4";
3
+ import { appendToNote, createFolder, createNote, deleteFolder, deleteNote, getAccounts, getAttachments, getFolders, getHashtags, getNote, getNotes, getSelection, getVersion, moveNote, searchNotes, showItem, updateNote, } from "./notes.js";
4
+ function asContent(value) {
5
+ return {
6
+ content: [
7
+ {
8
+ type: "text",
9
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ export function createNotesPokeMcpServer() {
15
+ const server = new McpServer({
16
+ name: "notes-poke",
17
+ version: "0.1.0",
18
+ }, {
19
+ capabilities: { logging: {} },
20
+ instructions: "Use these tools to operate the user's local Apple Notes app on macOS. Prefer search and small reads before broad reads. Use note and folder IDs for updates, moves, deletes, and reveals. Do not call destructive tools unless the user explicitly asks. Apple Notes exposes note bodies as HTML and plaintext; preserve user content carefully.",
21
+ });
22
+ server.registerTool("get_notes_info", {
23
+ title: "Get Notes Info",
24
+ description: "Check that Apple Notes is reachable and return its version.",
25
+ inputSchema: {},
26
+ }, async () => asContent({ version: await getVersion() }));
27
+ server.registerTool("get_accounts", {
28
+ title: "Get Accounts",
29
+ description: "List Apple Notes accounts and their default folders.",
30
+ inputSchema: {},
31
+ }, async () => asContent(await getAccounts()));
32
+ server.registerTool("get_folders", {
33
+ title: "Get Folders",
34
+ description: "List Apple Notes folders, optionally for a specific account.",
35
+ inputSchema: {
36
+ account: z.string().optional(),
37
+ limit: z.number().int().min(1).max(500).default(200),
38
+ },
39
+ }, async (args) => asContent(await getFolders(args)));
40
+ server.registerTool("get_notes", {
41
+ title: "Get Notes",
42
+ description: "List Apple Notes notes, optionally by folder or account. Use includeBody only when needed.",
43
+ inputSchema: {
44
+ folder: z.string().optional(),
45
+ account: z.string().optional(),
46
+ limit: z.number().int().min(1).max(200).default(30),
47
+ includeBody: z.boolean().default(false),
48
+ },
49
+ }, async (args) => asContent(await getNotes(args)));
50
+ server.registerTool("get_note", {
51
+ title: "Get Note",
52
+ description: "Read a single Apple Notes note by ID.",
53
+ inputSchema: {
54
+ id: z.string(),
55
+ includeBody: z.boolean().default(true),
56
+ },
57
+ }, async ({ id, includeBody }) => asContent(await getNote(id, includeBody)));
58
+ server.registerTool("search_notes", {
59
+ title: "Search Notes",
60
+ description: "Search Apple Notes by title and plaintext content.",
61
+ inputSchema: {
62
+ query: z.string(),
63
+ limit: z.number().int().min(1).max(200).default(30),
64
+ },
65
+ }, async ({ query, limit }) => asContent(await searchNotes(query, limit)));
66
+ server.registerTool("create_note", {
67
+ title: "Create Note",
68
+ description: "Create an Apple Notes note in the default folder, a named folder, or an account's default folder.",
69
+ inputSchema: {
70
+ title: z.string(),
71
+ body: z.string().optional(),
72
+ folder: z.string().optional(),
73
+ account: z.string().optional(),
74
+ reveal: z.boolean().default(false),
75
+ },
76
+ }, async (args) => asContent(await createNote(args)));
77
+ server.registerTool("update_note", {
78
+ title: "Update Note",
79
+ description: "Replace a note's body by ID. Provide title/body for simple plaintext-to-HTML content, or html for exact Apple Notes HTML.",
80
+ inputSchema: {
81
+ id: z.string(),
82
+ title: z.string().optional(),
83
+ body: z.string().optional(),
84
+ html: z.string().optional(),
85
+ replace: z.boolean().default(true),
86
+ },
87
+ }, async (args) => asContent(await updateNote(args)));
88
+ server.registerTool("append_to_note", {
89
+ title: "Append To Note",
90
+ description: "Append plaintext content to the end of an Apple Notes note by ID.",
91
+ inputSchema: {
92
+ id: z.string(),
93
+ text: z.string(),
94
+ },
95
+ }, async ({ id, text }) => asContent(await appendToNote(id, text)));
96
+ server.registerTool("create_folder", {
97
+ title: "Create Folder",
98
+ description: "Create an Apple Notes folder in the default account or a named account.",
99
+ inputSchema: {
100
+ name: z.string(),
101
+ account: z.string().optional(),
102
+ },
103
+ }, async ({ name, account }) => asContent(await createFolder(name, account)));
104
+ server.registerTool("move_note", {
105
+ title: "Move Note",
106
+ description: "Move a note by ID to a named Apple Notes folder.",
107
+ inputSchema: {
108
+ id: z.string(),
109
+ folder: z.string(),
110
+ },
111
+ }, async ({ id, folder }) => asContent(await moveNote(id, folder)));
112
+ server.registerTool("delete_note", {
113
+ title: "Delete Note",
114
+ description: "Delete an Apple Notes note by ID. Only use when the user explicitly asks.",
115
+ inputSchema: { id: z.string() },
116
+ }, async ({ id }) => asContent(await deleteNote(id)));
117
+ server.registerTool("delete_folder", {
118
+ title: "Delete Folder",
119
+ description: "Delete an Apple Notes folder by ID. Only use when the user explicitly asks.",
120
+ inputSchema: { id: z.string() },
121
+ }, async ({ id }) => asContent(await deleteFolder(id)));
122
+ server.registerTool("show_in_notes", {
123
+ title: "Show In Notes",
124
+ description: "Reveal a note, folder, or account in the Apple Notes app.",
125
+ inputSchema: {
126
+ type: z.enum(["note", "folder", "account"]),
127
+ id: z.string().optional(),
128
+ name: z.string().optional(),
129
+ separately: z.boolean().default(false),
130
+ },
131
+ }, async (args) => asContent(await showItem(args)));
132
+ server.registerTool("get_selection", {
133
+ title: "Get Selection",
134
+ description: "Return the currently selected note or notes in Apple Notes.",
135
+ inputSchema: {},
136
+ }, async () => asContent(await getSelection()));
137
+ server.registerTool("get_attachments", {
138
+ title: "Get Attachments",
139
+ description: "List attachments for an Apple Notes note by note ID.",
140
+ inputSchema: { noteId: z.string() },
141
+ }, async ({ noteId }) => asContent(await getAttachments(noteId)));
142
+ server.registerTool("get_hashtags", {
143
+ title: "Get Hashtags",
144
+ description: "Extract hashtag-like tokens from recent note plaintext. Apple Notes tags are not exposed as native scriptable objects.",
145
+ inputSchema: {
146
+ limit: z.number().int().min(1).max(500).default(100),
147
+ },
148
+ }, async ({ limit }) => asContent(await getHashtags(limit)));
149
+ return server;
150
+ }
package/dist/notes.js ADDED
@@ -0,0 +1,420 @@
1
+ import { asString, runAppleScript } from "./appleScript.js";
2
+ function parseJson(text) {
3
+ return JSON.parse(text);
4
+ }
5
+ function escapeHtml(value) {
6
+ return value
7
+ .replaceAll("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll("\"", "&quot;");
11
+ }
12
+ function paragraphs(value) {
13
+ return value
14
+ .split(/\n{2,}/)
15
+ .map((paragraph) => `<p>${escapeHtml(paragraph).replaceAll("\n", "<br>")}</p>`)
16
+ .join("");
17
+ }
18
+ function noteHtml(title, body = "") {
19
+ return `<h1>${escapeHtml(title)}</h1>${paragraphs(body)}`;
20
+ }
21
+ export const notesAppleScriptLibrary = String.raw `
22
+ on replaceText(findText, replaceText, sourceText)
23
+ set oldDelimiters to AppleScript's text item delimiters
24
+ set AppleScript's text item delimiters to findText
25
+ set textItems to text items of sourceText
26
+ set AppleScript's text item delimiters to replaceText
27
+ set joinedText to textItems as text
28
+ set AppleScript's text item delimiters to oldDelimiters
29
+ return joinedText
30
+ end replaceText
31
+
32
+ on jsonEscape(sourceValue)
33
+ if sourceValue is missing value then return ""
34
+ set sourceText to sourceValue as text
35
+ set sourceText to my replaceText("\\", "\\\\", sourceText)
36
+ set sourceText to my replaceText("\"", "\\\"", sourceText)
37
+ set sourceText to my replaceText(linefeed, "\\n", sourceText)
38
+ set sourceText to my replaceText(return, "\\n", sourceText)
39
+ set sourceText to my replaceText(tab, "\\t", sourceText)
40
+ return sourceText
41
+ end jsonEscape
42
+
43
+ on jsonString(sourceValue)
44
+ return "\"" & my jsonEscape(sourceValue) & "\""
45
+ end jsonString
46
+
47
+ on jsonBool(sourceValue)
48
+ if sourceValue then return "true"
49
+ return "false"
50
+ end jsonBool
51
+
52
+ on pad2(n)
53
+ set nText to n as integer as text
54
+ if length of nText is 1 then return "0" & nText
55
+ return nText
56
+ end pad2
57
+
58
+ on isoDate(d)
59
+ if d is missing value then return ""
60
+ try
61
+ set y to year of d as integer
62
+ set m to month of d as integer
63
+ set dayNumber to day of d as integer
64
+ set h to hours of d as integer
65
+ set minNumber to minutes of d as integer
66
+ set secNumber to seconds of d as integer
67
+ return (y as text) & "-" & my pad2(m) & "-" & my pad2(dayNumber) & "T" & my pad2(h) & ":" & my pad2(minNumber) & ":" & my pad2(secNumber)
68
+ on error
69
+ return d as text
70
+ end try
71
+ end isoDate
72
+
73
+ on findNoteById(noteId)
74
+ tell application "Notes"
75
+ repeat with n in notes
76
+ if id of n is noteId then return n
77
+ end repeat
78
+ end tell
79
+ error "No Apple Notes note found with id " & noteId
80
+ end findNoteById
81
+
82
+ on findFolderById(folderId)
83
+ tell application "Notes"
84
+ repeat with f in folders
85
+ if id of f is folderId then return f
86
+ end repeat
87
+ end tell
88
+ error "No Apple Notes folder found with id " & folderId
89
+ end findFolderById
90
+
91
+ on folderJson(f)
92
+ tell application "Notes"
93
+ set containerName to ""
94
+ set containerId to ""
95
+ set containerKind to ""
96
+ try
97
+ set containerName to name of container of f
98
+ set containerId to id of container of f
99
+ set containerKind to class of container of f as text
100
+ end try
101
+ return "{" & ¬
102
+ "\"id\":" & my jsonString(id of f) & "," & ¬
103
+ "\"name\":" & my jsonString(name of f) & "," & ¬
104
+ "\"shared\":" & my jsonBool(shared of f) & "," & ¬
105
+ "\"containerId\":" & my jsonString(containerId) & "," & ¬
106
+ "\"container\":" & my jsonString(containerName) & "," & ¬
107
+ "\"containerKind\":" & my jsonString(containerKind) & "," & ¬
108
+ "\"noteCount\":" & (count of notes of f as text) & ¬
109
+ "}"
110
+ end tell
111
+ end folderJson
112
+
113
+ on noteJson(n, includeBody)
114
+ tell application "Notes"
115
+ set folderName to ""
116
+ set folderId to ""
117
+ try
118
+ set folderName to name of container of n
119
+ set folderId to id of container of n
120
+ end try
121
+ set bodyText to ""
122
+ if includeBody then
123
+ try
124
+ set bodyText to body of n
125
+ end try
126
+ end if
127
+ return "{" & ¬
128
+ "\"id\":" & my jsonString(id of n) & "," & ¬
129
+ "\"title\":" & my jsonString(name of n) & "," & ¬
130
+ "\"plaintext\":" & my jsonString(plaintext of n) & "," & ¬
131
+ "\"body\":" & my jsonString(bodyText) & "," & ¬
132
+ "\"folderId\":" & my jsonString(folderId) & "," & ¬
133
+ "\"folder\":" & my jsonString(folderName) & "," & ¬
134
+ "\"createdAt\":" & my jsonString(my isoDate(creation date of n)) & "," & ¬
135
+ "\"modifiedAt\":" & my jsonString(my isoDate(modification date of n)) & "," & ¬
136
+ "\"passwordProtected\":" & my jsonBool(password protected of n) & "," & ¬
137
+ "\"shared\":" & my jsonBool(shared of n) & "," & ¬
138
+ "\"attachmentCount\":" & (count of attachments of n as text) & ¬
139
+ "}"
140
+ end tell
141
+ end noteJson
142
+ `;
143
+ export async function getVersion() {
144
+ return await runAppleScript(`
145
+ tell application "Notes"
146
+ return version
147
+ end tell
148
+ `);
149
+ }
150
+ export async function getAccounts() {
151
+ const output = await runAppleScript(`
152
+ ${notesAppleScriptLibrary}
153
+ set rows to {}
154
+ tell application "Notes"
155
+ repeat with a in accounts
156
+ set defaultFolderName to ""
157
+ set defaultFolderId to ""
158
+ try
159
+ set defaultFolderName to name of default folder of a
160
+ set defaultFolderId to id of default folder of a
161
+ end try
162
+ set end of rows to "{" & ¬
163
+ "\\"id\\":" & my jsonString(id of a) & "," & ¬
164
+ "\\"name\\":" & my jsonString(name of a) & "," & ¬
165
+ "\\"upgraded\\":" & my jsonBool(upgraded of a) & "," & ¬
166
+ "\\"defaultFolderId\\":" & my jsonString(defaultFolderId) & "," & ¬
167
+ "\\"defaultFolder\\":" & my jsonString(defaultFolderName) & "," & ¬
168
+ "\\"folderCount\\":" & (count of folders of a as text) & "," & ¬
169
+ "\\"noteCount\\":" & (count of notes of a as text) & ¬
170
+ "}"
171
+ end repeat
172
+ end tell
173
+ set oldDelimiters to AppleScript's text item delimiters
174
+ set AppleScript's text item delimiters to ","
175
+ set jsonText to rows as text
176
+ set AppleScript's text item delimiters to oldDelimiters
177
+ return "[" & jsonText & "]"
178
+ `);
179
+ return parseJson(output);
180
+ }
181
+ export async function getFolders(options = {}) {
182
+ const limit = Math.max(1, Math.min(options.limit ?? 200, 500));
183
+ const source = options.account ? `folders of account ${asString(options.account)}` : "folders";
184
+ const output = await runAppleScript(`
185
+ ${notesAppleScriptLibrary}
186
+ set rows to {}
187
+ tell application "Notes"
188
+ set itemCount to 0
189
+ repeat with f in ${source}
190
+ set itemCount to itemCount + 1
191
+ if itemCount is greater than ${limit} then exit repeat
192
+ set end of rows to my folderJson(f)
193
+ end repeat
194
+ end tell
195
+ set oldDelimiters to AppleScript's text item delimiters
196
+ set AppleScript's text item delimiters to ","
197
+ set jsonText to rows as text
198
+ set AppleScript's text item delimiters to oldDelimiters
199
+ return "[" & jsonText & "]"
200
+ `);
201
+ return parseJson(output);
202
+ }
203
+ export async function getNotes(options = {}) {
204
+ const limit = Math.max(1, Math.min(options.limit ?? 30, 200));
205
+ const source = options.folder ? `notes of folder ${asString(options.folder)}` :
206
+ options.account ? `notes of account ${asString(options.account)}` :
207
+ "notes";
208
+ const output = await runAppleScript(`
209
+ ${notesAppleScriptLibrary}
210
+ set rows to {}
211
+ tell application "Notes"
212
+ set itemCount to 0
213
+ repeat with n in ${source}
214
+ set itemCount to itemCount + 1
215
+ if itemCount is greater than ${limit} then exit repeat
216
+ set end of rows to my noteJson(n, ${options.includeBody ? "true" : "false"})
217
+ end repeat
218
+ end tell
219
+ set oldDelimiters to AppleScript's text item delimiters
220
+ set AppleScript's text item delimiters to ","
221
+ set jsonText to rows as text
222
+ set AppleScript's text item delimiters to oldDelimiters
223
+ return "[" & jsonText & "]"
224
+ `);
225
+ return parseJson(output);
226
+ }
227
+ export async function getNote(id, includeBody = true) {
228
+ const output = await runAppleScript(`
229
+ ${notesAppleScriptLibrary}
230
+ tell application "Notes"
231
+ set targetNote to my findNoteById(${asString(id)})
232
+ return my noteJson(targetNote, ${includeBody ? "true" : "false"})
233
+ end tell
234
+ `);
235
+ return parseJson(output);
236
+ }
237
+ export async function searchNotes(query, limit = 30) {
238
+ const safeLimit = Math.max(1, Math.min(limit, 200));
239
+ const output = await runAppleScript(`
240
+ ${notesAppleScriptLibrary}
241
+ set rows to {}
242
+ set needle to ${asString(query)}
243
+ tell application "Notes"
244
+ set itemCount to 0
245
+ repeat with n in notes
246
+ set haystack to (name of n & linefeed & plaintext of n)
247
+ if haystack contains needle then
248
+ set itemCount to itemCount + 1
249
+ if itemCount is greater than ${safeLimit} then exit repeat
250
+ set end of rows to my noteJson(n, false)
251
+ end if
252
+ end repeat
253
+ end tell
254
+ set oldDelimiters to AppleScript's text item delimiters
255
+ set AppleScript's text item delimiters to ","
256
+ set jsonText to rows as text
257
+ set AppleScript's text item delimiters to oldDelimiters
258
+ return "[" & jsonText & "]"
259
+ `);
260
+ return parseJson(output);
261
+ }
262
+ export async function createNote(input) {
263
+ const html = noteHtml(input.title, input.body);
264
+ const destination = input.folder ? ` at folder ${asString(input.folder)}` :
265
+ input.account ? ` at default folder of account ${asString(input.account)}` :
266
+ " at default folder of default account";
267
+ const output = await runAppleScript(`
268
+ ${notesAppleScriptLibrary}
269
+ tell application "Notes"
270
+ set newNote to make new note${destination} with properties {body:${asString(html)}}
271
+ if ${input.reveal ? "true" : "false"} then show newNote
272
+ return my noteJson(newNote, true)
273
+ end tell
274
+ `);
275
+ return parseJson(output);
276
+ }
277
+ export async function updateNote(input) {
278
+ const bodyHtml = input.html ?? (input.title !== undefined || input.body !== undefined ? noteHtml(input.title ?? "Untitled", input.body ?? "") : undefined);
279
+ const output = await runAppleScript(`
280
+ ${notesAppleScriptLibrary}
281
+ tell application "Notes"
282
+ set targetNote to my findNoteById(${asString(input.id)})
283
+ ${bodyHtml !== undefined ? `set body of targetNote to ${input.replace === false ? `(body of targetNote) & ${asString(bodyHtml)}` : asString(bodyHtml)}` : ""}
284
+ return my noteJson(targetNote, true)
285
+ end tell
286
+ `);
287
+ return parseJson(output);
288
+ }
289
+ export async function appendToNote(id, text) {
290
+ const output = await runAppleScript(`
291
+ ${notesAppleScriptLibrary}
292
+ tell application "Notes"
293
+ set targetNote to my findNoteById(${asString(id)})
294
+ set body of targetNote to (body of targetNote) & ${asString(paragraphs(text))}
295
+ return my noteJson(targetNote, true)
296
+ end tell
297
+ `);
298
+ return parseJson(output);
299
+ }
300
+ export async function createFolder(name, account) {
301
+ const destination = account ? ` at account ${asString(account)}` : " at default account";
302
+ const output = await runAppleScript(`
303
+ ${notesAppleScriptLibrary}
304
+ tell application "Notes"
305
+ set newFolder to make new folder${destination} with properties {name:${asString(name)}}
306
+ return my folderJson(newFolder)
307
+ end tell
308
+ `);
309
+ return parseJson(output);
310
+ }
311
+ export async function deleteNote(id) {
312
+ return await runAppleScript(`
313
+ ${notesAppleScriptLibrary}
314
+ tell application "Notes"
315
+ set targetNote to my findNoteById(${asString(id)})
316
+ delete targetNote
317
+ return "Deleted note: ${id}"
318
+ end tell
319
+ `);
320
+ }
321
+ export async function deleteFolder(id) {
322
+ return await runAppleScript(`
323
+ ${notesAppleScriptLibrary}
324
+ tell application "Notes"
325
+ set targetFolder to my findFolderById(${asString(id)})
326
+ delete targetFolder
327
+ return "Deleted folder: ${id}"
328
+ end tell
329
+ `);
330
+ }
331
+ export async function moveNote(id, folder) {
332
+ const output = await runAppleScript(`
333
+ ${notesAppleScriptLibrary}
334
+ tell application "Notes"
335
+ set targetNote to my findNoteById(${asString(id)})
336
+ move targetNote to folder ${asString(folder)}
337
+ return my noteJson(targetNote, false)
338
+ end tell
339
+ `);
340
+ return parseJson(output);
341
+ }
342
+ export async function showItem(input) {
343
+ const target = input.type === "note" && input.id ? `my findNoteById(${asString(input.id)})` :
344
+ input.type === "folder" && input.id ? `my findFolderById(${asString(input.id)})` :
345
+ input.type === "folder" && input.name ? `folder ${asString(input.name)}` :
346
+ input.type === "account" && input.name ? `account ${asString(input.name)}` :
347
+ undefined;
348
+ if (!target) {
349
+ throw new Error("Provide a valid id or name for the requested Apple Notes item type.");
350
+ }
351
+ return await runAppleScript(`
352
+ ${notesAppleScriptLibrary}
353
+ tell application "Notes"
354
+ show ${target}${input.separately ? " separately true" : ""}
355
+ return "Shown in Notes"
356
+ end tell
357
+ `);
358
+ }
359
+ export async function getSelection() {
360
+ const output = await runAppleScript(`
361
+ ${notesAppleScriptLibrary}
362
+ set rows to {}
363
+ tell application "Notes"
364
+ repeat with n in selection
365
+ set end of rows to my noteJson(n, false)
366
+ end repeat
367
+ end tell
368
+ set oldDelimiters to AppleScript's text item delimiters
369
+ set AppleScript's text item delimiters to ","
370
+ set jsonText to rows as text
371
+ set AppleScript's text item delimiters to oldDelimiters
372
+ return "[" & jsonText & "]"
373
+ `);
374
+ return parseJson(output);
375
+ }
376
+ export async function getAttachments(noteId) {
377
+ const output = await runAppleScript(`
378
+ ${notesAppleScriptLibrary}
379
+ set rows to {}
380
+ tell application "Notes"
381
+ set targetNote to my findNoteById(${asString(noteId)})
382
+ repeat with a in attachments of targetNote
383
+ set attachmentUrl to ""
384
+ try
385
+ set attachmentUrl to URL of a
386
+ end try
387
+ set end of rows to "{" & ¬
388
+ "\\"id\\":" & my jsonString(id of a) & "," & ¬
389
+ "\\"name\\":" & my jsonString(name of a) & "," & ¬
390
+ "\\"url\\":" & my jsonString(attachmentUrl) & "," & ¬
391
+ "\\"contentIdentifier\\":" & my jsonString(content identifier of a) & "," & ¬
392
+ "\\"createdAt\\":" & my jsonString(my isoDate(creation date of a)) & "," & ¬
393
+ "\\"modifiedAt\\":" & my jsonString(my isoDate(modification date of a)) & "," & ¬
394
+ "\\"shared\\":" & my jsonBool(shared of a) & ¬
395
+ "}"
396
+ end repeat
397
+ end tell
398
+ set oldDelimiters to AppleScript's text item delimiters
399
+ set AppleScript's text item delimiters to ","
400
+ set jsonText to rows as text
401
+ set AppleScript's text item delimiters to oldDelimiters
402
+ return "[" & jsonText & "]"
403
+ `);
404
+ return parseJson(output);
405
+ }
406
+ export async function getHashtags(limit = 100) {
407
+ const output = await runAppleScript(`
408
+ set allText to ""
409
+ tell application "Notes"
410
+ set itemCount to 0
411
+ repeat with n in notes
412
+ set itemCount to itemCount + 1
413
+ if itemCount is greater than ${Math.max(1, Math.min(limit, 500))} then exit repeat
414
+ set allText to allText & " " & plaintext of n
415
+ end repeat
416
+ end tell
417
+ do shell script "/usr/bin/python3 -c " & quoted form of "import re,sys,json; print(json.dumps(sorted(set(re.findall(r'(?<!\\\\w)#[-_A-Za-z0-9]+', sys.stdin.read())))))" with input allText
418
+ `);
419
+ return parseJson(output);
420
+ }
package/dist/server.js ADDED
@@ -0,0 +1,102 @@
1
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
3
+ import { createNotesPokeMcpServer } from "./mcp.js";
4
+ function bearerAuth(token) {
5
+ return (req, res, next) => {
6
+ if (!token) {
7
+ next();
8
+ return;
9
+ }
10
+ const authorization = req.header("Authorization") ?? "";
11
+ if (authorization === `Bearer ${token}`) {
12
+ next();
13
+ return;
14
+ }
15
+ res.status(401).json({
16
+ jsonrpc: "2.0",
17
+ error: {
18
+ code: -32001,
19
+ message: "Unauthorized",
20
+ },
21
+ id: null,
22
+ });
23
+ };
24
+ }
25
+ function isMcpPath(path) {
26
+ return path === "/mcp" || path.endsWith("/mcp");
27
+ }
28
+ const mcpRoutePattern = /^(?:\/[^/]+)*\/mcp$/;
29
+ export function startServer(options = {}) {
30
+ const host = options.host ?? process.env.NOTES_POKE_HOST ?? "127.0.0.1";
31
+ const port = options.port ?? Number.parseInt(process.env.NOTES_POKE_PORT ?? "8766", 10);
32
+ const apiToken = options.apiToken ?? process.env.NOTES_POKE_API_TOKEN;
33
+ const app = createMcpExpressApp({ host });
34
+ app.get("/", (_req, res) => {
35
+ res.json({
36
+ name: "notes-poke",
37
+ status: "ok",
38
+ mcp: "/mcp",
39
+ tunnelCompatibleMcp: "/*/mcp",
40
+ auth: apiToken ? "bearer" : "none",
41
+ });
42
+ });
43
+ app.use((req, res, next) => {
44
+ if (!isMcpPath(req.path)) {
45
+ next();
46
+ return;
47
+ }
48
+ bearerAuth(apiToken)(req, res, next);
49
+ });
50
+ app.post(mcpRoutePattern, async (req, res) => {
51
+ const server = createNotesPokeMcpServer();
52
+ try {
53
+ const transport = new StreamableHTTPServerTransport({
54
+ sessionIdGenerator: undefined,
55
+ });
56
+ await server.connect(transport);
57
+ await transport.handleRequest(req, res, req.body);
58
+ res.on("close", () => {
59
+ transport.close().catch(() => undefined);
60
+ server.close().catch(() => undefined);
61
+ });
62
+ }
63
+ catch (error) {
64
+ console.error("Error handling MCP request:", error);
65
+ if (!res.headersSent) {
66
+ res.status(500).json({
67
+ jsonrpc: "2.0",
68
+ error: {
69
+ code: -32603,
70
+ message: "Internal server error",
71
+ },
72
+ id: null,
73
+ });
74
+ }
75
+ }
76
+ });
77
+ app.get(mcpRoutePattern, (_req, res) => {
78
+ res.status(405).json({
79
+ jsonrpc: "2.0",
80
+ error: {
81
+ code: -32000,
82
+ message: "Method not allowed. POST JSON-RPC requests to /mcp or a tunnel-prefixed /*/mcp path.",
83
+ },
84
+ id: null,
85
+ });
86
+ });
87
+ app.delete(mcpRoutePattern, (_req, res) => {
88
+ res.status(405).json({
89
+ jsonrpc: "2.0",
90
+ error: {
91
+ code: -32000,
92
+ message: "Method not allowed.",
93
+ },
94
+ id: null,
95
+ });
96
+ });
97
+ const httpServer = app.listen(port, host, () => {
98
+ console.log(`notes-poke MCP server listening at http://${host}:${port}/mcp`);
99
+ console.log(apiToken ? "Bearer auth enabled via NOTES_POKE_API_TOKEN" : "Bearer auth disabled for localhost development");
100
+ });
101
+ return httpServer;
102
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "notes-poke",
3
+ "version": "0.1.0",
4
+ "description": "Local Apple Notes MCP server for Poke",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ysmaliak/notes-poke.git"
10
+ },
11
+ "homepage": "https://github.com/ysmaliak/notes-poke#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ysmaliak/notes-poke/issues"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "bin": {
20
+ "notes-poke": "dist/cli.js"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "start": "tsx src/cli.ts start",
25
+ "setup": "tsx src/cli.ts setup",
26
+ "connect": "tsx src/cli.ts connect",
27
+ "dev": "tsx src/cli.ts start",
28
+ "check": "tsc --noEmit",
29
+ "prepublishOnly": "npm run check && npm run build"
30
+ },
31
+ "keywords": [
32
+ "notes",
33
+ "apple-notes",
34
+ "poke",
35
+ "mcp",
36
+ "model-context-protocol"
37
+ ],
38
+ "author": "",
39
+ "license": "ISC",
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "commander": "^14.0.2",
43
+ "express": "^5.2.1",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/express": "^5.0.6",
48
+ "@types/node": "^24.10.1",
49
+ "tsx": "^4.21.0",
50
+ "typescript": "^5.9.3"
51
+ }
52
+ }