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 +105 -0
- package/dist/appleScript.js +43 -0
- package/dist/cli.js +257 -0
- package/dist/index.js +2 -0
- package/dist/mcp.js +150 -0
- package/dist/notes.js +420 -0
- package/dist/server.js +102 -0
- package/package.json +52 -0
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("<", "<")
|
|
35
|
+
.replaceAll(">", ">")
|
|
36
|
+
.replaceAll("\"", """)
|
|
37
|
+
.replaceAll("'", "'");
|
|
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
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("&", "&")
|
|
8
|
+
.replaceAll("<", "<")
|
|
9
|
+
.replaceAll(">", ">")
|
|
10
|
+
.replaceAll("\"", """);
|
|
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
|
+
}
|