apple-notes-mcp 1.4.3 → 2.0.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for save-attachment / fetch-attachment manager methods (#27).
3
+ * AppleScript is mocked; the filesystem side runs for real in a temp dir, with
4
+ * the mock writing the file the way Notes.app's `save` would.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7
+ import { writeFileSync, mkdtempSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ vi.mock("@/utils/applescript.js", () => ({ executeAppleScript: vi.fn() }));
11
+ vi.mock("@/utils/checklistParser.js", () => ({
12
+ getChecklistItems: vi.fn().mockReturnValue({ items: null }),
13
+ }));
14
+ import { AppleNotesManager } from "../services/appleNotesManager.js";
15
+ import { executeAppleScript } from "../utils/applescript.js";
16
+ const mockExec = vi.mocked(executeAppleScript);
17
+ const F = "\x1f";
18
+ let manager;
19
+ const tmpDirs = [];
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ manager = new AppleNotesManager();
23
+ });
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+ describe("saveAttachmentById (#27)", () => {
28
+ it("saves to an allowed path and returns metadata", () => {
29
+ const dir = mkdtempSync(join(tmpdir(), "anatt-"));
30
+ tmpDirs.push(dir);
31
+ const dest = join(dir, "photo.png");
32
+ mockExec.mockImplementation((script) => {
33
+ const m = script.match(/POSIX file "([^"]+)"/);
34
+ if (m)
35
+ writeFileSync(m[1], Buffer.from("PNGDATA"));
36
+ return { success: true, output: ["OK", "photo.png", "public.png"].join(F) };
37
+ });
38
+ const r = manager.saveAttachmentById("x-coredata://A/ICNote/p1", "att-1", dest);
39
+ expect(r.success).toBe(true);
40
+ expect(r.savedPath).toBe(dest);
41
+ expect(r.name).toBe("photo.png");
42
+ expect(r.contentType).toBe("public.png");
43
+ });
44
+ it("rejects an unsafe destination before running AppleScript", () => {
45
+ const r = manager.saveAttachmentById("x-coredata://A/ICNote/p1", "att-1", "/etc/evil.png");
46
+ expect(r.success).toBe(false);
47
+ expect(r.error).toMatch(/outside allowed/);
48
+ expect(mockExec).not.toHaveBeenCalled();
49
+ });
50
+ it("surfaces 'attachment not found' from AppleScript", () => {
51
+ const dest = join(tmpdir(), "nope.png");
52
+ mockExec.mockReturnValue({ success: true, output: ["ERR", "attachment not found"].join(F) });
53
+ const r = manager.saveAttachmentById("x-coredata://A/ICNote/p1", "missing", dest);
54
+ expect(r.success).toBe(false);
55
+ expect(r.error).toMatch(/not found/);
56
+ });
57
+ it("fails when Notes reports OK but no file was written", () => {
58
+ const dest = join(tmpdir(), "ghost-" + Date.now() + ".png");
59
+ mockExec.mockReturnValue({ success: true, output: ["OK", "x.png", "public.png"].join(F) });
60
+ const r = manager.saveAttachmentById("x-coredata://A/ICNote/p1", "att-1", dest);
61
+ expect(r.success).toBe(false);
62
+ expect(r.error).toMatch(/no file was written/);
63
+ });
64
+ });
65
+ describe("getAttachmentBase64ById (#27)", () => {
66
+ it("exports to a temp file and returns base64, cleaning up", () => {
67
+ mockExec.mockImplementation((script) => {
68
+ const m = script.match(/POSIX file "([^"]+)"/);
69
+ if (m)
70
+ writeFileSync(m[1], Buffer.from("hello-bytes"));
71
+ return { success: true, output: ["OK", "doc.pdf", "com.adobe.pdf"].join(F) };
72
+ });
73
+ const r = manager.getAttachmentBase64ById("x-coredata://A/ICNote/p1", "att-1");
74
+ expect(r.success).toBe(true);
75
+ expect(r.name).toBe("doc.pdf");
76
+ expect(r.bytes).toBe("hello-bytes".length);
77
+ expect(Buffer.from(r.base64 ?? "", "base64").toString()).toBe("hello-bytes");
78
+ });
79
+ it("returns the error when the save step fails", () => {
80
+ mockExec.mockReturnValue({ success: false, output: "", error: "Notes not running" });
81
+ const r = manager.getAttachmentBase64ById("x-coredata://A/ICNote/p1", "att-1");
82
+ expect(r.success).toBe(false);
83
+ expect(r.error).toMatch(/Notes not running/);
84
+ });
85
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * File-based configuration loader (#24).
3
+ *
4
+ * Some host apps (e.g. Claude Desktop) spawn the MCP server with a scrubbed
5
+ * environment and ignore the `env` block in their server config, so there's no
6
+ * way to pass `APPLE_NOTES_MCP_*` settings in. This loads them from a JSON file
7
+ * the host doesn't manage, merging into `process.env` WITHOUT overriding
8
+ * anything already set (so an explicit env still wins).
9
+ *
10
+ * Only non-secret config belongs here (e.g. APPLE_NOTES_MCP_MAX_BUFFER, DEBUG).
11
+ *
12
+ * Path: `APPLE_NOTES_MCP_CONFIG_FILE`, else
13
+ * `~/Library/Application Support/apple-notes-mcp/config.json`.
14
+ *
15
+ * @module services/fileConfig
16
+ */
17
+ import { existsSync, readFileSync } from "fs";
18
+ import { join } from "path";
19
+ import { homedir } from "os";
20
+ export function fileConfigPath(env = process.env) {
21
+ const override = env.APPLE_NOTES_MCP_CONFIG_FILE;
22
+ if (override && override.trim())
23
+ return override.trim();
24
+ return join(homedir(), "Library", "Application Support", "apple-notes-mcp", "config.json");
25
+ }
26
+ /**
27
+ * Merge a JSON config file's string values into `env` for keys not already set.
28
+ * Returns the keys applied. Tolerates a missing/corrupt file.
29
+ */
30
+ export function loadFileConfig(env = process.env, path = fileConfigPath(env)) {
31
+ const applied = [];
32
+ try {
33
+ if (!existsSync(path))
34
+ return applied;
35
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
36
+ if (!parsed || typeof parsed !== "object")
37
+ return applied;
38
+ for (const [k, v] of Object.entries(parsed)) {
39
+ if (typeof v !== "string")
40
+ continue;
41
+ if (env[k] === undefined || env[k] === "") {
42
+ env[k] = v;
43
+ applied.push(k);
44
+ }
45
+ }
46
+ }
47
+ catch (e) {
48
+ console.error(`Failed to load apple-notes-mcp config file ${path}: ${String(e)}`);
49
+ }
50
+ return applied;
51
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { loadFileConfig, fileConfigPath } from "../services/fileConfig.js";
6
+ let dir;
7
+ let file;
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), "anmcp-cfg-"));
10
+ file = join(dir, "config.json");
11
+ });
12
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
13
+ describe("loadFileConfig (#24)", () => {
14
+ it("applies file values for keys not already in env", () => {
15
+ writeFileSync(file, JSON.stringify({ APPLE_NOTES_MCP_MAX_BUFFER: "1048576", DEBUG: "1" }));
16
+ const env = {};
17
+ const applied = loadFileConfig(env, file);
18
+ expect(env.APPLE_NOTES_MCP_MAX_BUFFER).toBe("1048576");
19
+ expect(env.DEBUG).toBe("1");
20
+ expect(applied.sort()).toEqual(["APPLE_NOTES_MCP_MAX_BUFFER", "DEBUG"]);
21
+ });
22
+ it("never overrides a value already set in the environment", () => {
23
+ writeFileSync(file, JSON.stringify({ APPLE_NOTES_MCP_MAX_BUFFER: "1" }));
24
+ const env = { APPLE_NOTES_MCP_MAX_BUFFER: "999" };
25
+ loadFileConfig(env, file);
26
+ expect(env.APPLE_NOTES_MCP_MAX_BUFFER).toBe("999");
27
+ });
28
+ it("treats empty-string env as unset and fills it", () => {
29
+ writeFileSync(file, JSON.stringify({ DEBUG: "1" }));
30
+ const env = { DEBUG: "" };
31
+ loadFileConfig(env, file);
32
+ expect(env.DEBUG).toBe("1");
33
+ });
34
+ it("ignores non-string values", () => {
35
+ writeFileSync(file, JSON.stringify({ A: "ok", B: 5, C: true }));
36
+ const env = {};
37
+ expect(loadFileConfig(env, file)).toEqual(["A"]);
38
+ });
39
+ it("tolerates a missing file and a corrupt file", () => {
40
+ expect(loadFileConfig({}, join(dir, "nope.json"))).toEqual([]);
41
+ writeFileSync(file, "{ not json");
42
+ expect(loadFileConfig({}, file)).toEqual([]);
43
+ });
44
+ it("defaults the path to the app-support dir, honoring the override", () => {
45
+ expect(fileConfigPath({})).toMatch(/apple-notes-mcp\/config\.json$/);
46
+ expect(fileConfigPath({ APPLE_NOTES_MCP_CONFIG_FILE: "/tmp/x.json" })).toBe("/tmp/x.json");
47
+ });
48
+ });
@@ -0,0 +1,50 @@
1
+ import { hasFullDiskAccess } from "../utils/checklistParser.js";
2
+ export function runDoctor(manager) {
3
+ const checks = [];
4
+ // 1. Notes.app reachability + Automation permission (existing health checks).
5
+ const hc = manager.healthCheck();
6
+ for (const c of hc.checks) {
7
+ checks.push({
8
+ name: `Notes.app: ${c.name}`,
9
+ status: c.passed ? "ok" : "fail",
10
+ detail: c.message,
11
+ });
12
+ }
13
+ // 2. Accounts.
14
+ try {
15
+ const accounts = manager.listAccounts();
16
+ checks.push({
17
+ name: "Accounts",
18
+ status: accounts.length > 0 ? "ok" : "warn",
19
+ detail: accounts.length > 0
20
+ ? `${accounts.length} account(s): ${accounts.map((a) => a.name).join(", ")}`
21
+ : "no Notes accounts found",
22
+ });
23
+ }
24
+ catch (e) {
25
+ checks.push({
26
+ name: "Accounts",
27
+ status: "fail",
28
+ detail: `could not list accounts: ${String(e)}`,
29
+ });
30
+ }
31
+ // 3. Full Disk Access — required for checklist state + checklist annotations.
32
+ const fda = hasFullDiskAccess();
33
+ checks.push({
34
+ name: "Full Disk Access",
35
+ status: fda ? "ok" : "warn",
36
+ detail: fda
37
+ ? "granted — checklist features available"
38
+ : "not granted — get-checklist-state and checklist annotations in get-note-markdown won't work. Grant in System Settings > Privacy & Security > Full Disk Access.",
39
+ });
40
+ const healthy = !checks.some((c) => c.status === "fail");
41
+ return { healthy, checks };
42
+ }
43
+ /** Render a DoctorReport as readable text. */
44
+ export function formatDoctorReport(r) {
45
+ const icon = (s) => (s === "ok" ? "✅" : s === "warn" ? "⚠️ " : "❌");
46
+ const lines = [`🩺 apple-notes-mcp doctor — ${r.healthy ? "healthy" : "ISSUES FOUND"}`, ""];
47
+ for (const c of r.checks)
48
+ lines.push(`${icon(c.status)} ${c.name}: ${c.detail}`);
49
+ return lines.join("\n");
50
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ vi.mock("@/utils/checklistParser.js", () => ({ hasFullDiskAccess: vi.fn(() => true) }));
3
+ import { runDoctor, formatDoctorReport } from "../tools/doctor.js";
4
+ import { hasFullDiskAccess } from "../utils/checklistParser.js";
5
+ function fakeMgr(over = {}) {
6
+ return {
7
+ healthCheck: () => ({
8
+ healthy: true,
9
+ checks: [{ name: "reachable", passed: true, message: "Notes.app responded" }],
10
+ }),
11
+ listAccounts: () => [{ name: "iCloud" }, { name: "Gmail" }],
12
+ ...over,
13
+ };
14
+ }
15
+ describe("runDoctor (#22)", () => {
16
+ it("reports accounts + Full Disk Access and stays healthy on warnings", () => {
17
+ const r = runDoctor(fakeMgr());
18
+ expect(r.healthy).toBe(true);
19
+ expect(r.checks.find((c) => c.name === "Accounts")?.status).toBe("ok");
20
+ expect(r.checks.find((c) => c.name === "Accounts")?.detail).toMatch(/iCloud, Gmail/);
21
+ expect(r.checks.find((c) => c.name === "Full Disk Access")?.status).toBe("ok");
22
+ });
23
+ it("warns (not fails) when Full Disk Access is not granted", () => {
24
+ vi.mocked(hasFullDiskAccess).mockReturnValueOnce(false);
25
+ const r = runDoctor(fakeMgr());
26
+ const fda = r.checks.find((c) => c.name === "Full Disk Access");
27
+ expect(fda?.status).toBe("warn");
28
+ expect(fda?.detail).toMatch(/Full Disk Access/);
29
+ expect(r.healthy).toBe(true);
30
+ });
31
+ it("is unhealthy when a Notes.app check fails", () => {
32
+ const r = runDoctor(fakeMgr({
33
+ healthCheck: () => ({
34
+ healthy: false,
35
+ checks: [{ name: "permission", passed: false, message: "not authorized" }],
36
+ }),
37
+ }));
38
+ expect(r.healthy).toBe(false);
39
+ expect(formatDoctorReport(r)).toMatch(/ISSUES FOUND/);
40
+ expect(formatDoctorReport(r)).toMatch(/❌ Notes\.app: permission/);
41
+ });
42
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * MCP resources & prompts for apple-notes (#23).
3
+ *
4
+ * Resources expose read-only views agents can attach as context without a tool
5
+ * round-trip (accounts, folders, stats, and a note-by-id template). Prompts are
6
+ * reusable starting points for common Notes workflows.
7
+ *
8
+ * @module tools/resourcesAndPrompts
9
+ */
10
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { z } from "zod";
12
+ const json = (uri, data) => ({
13
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(data, null, 2) }],
14
+ });
15
+ export function registerResourcesAndPrompts(server, manager) {
16
+ // --- Resources ---
17
+ server.resource("accounts", "notes://accounts", (uri) => json(uri, { accounts: manager.listAccounts() }));
18
+ server.resource("folders", "notes://folders", (uri) => {
19
+ const data = manager
20
+ .listAccounts()
21
+ .map((a) => ({ account: a.name, folders: manager.listFolders(a.name) }));
22
+ return json(uri, { accounts: data });
23
+ });
24
+ server.resource("stats", "notes://stats", (uri) => json(uri, manager.getNotesStats()));
25
+ server.resource("note", new ResourceTemplate("notes://note/{id}", { list: undefined }), (uri, variables) => {
26
+ const id = decodeURIComponent(String(variables.id));
27
+ const markdown = manager.getNoteMarkdownById(id);
28
+ return {
29
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: markdown || "(not found)" }],
30
+ };
31
+ });
32
+ // --- Prompts ---
33
+ server.prompt("find-note", "Search Apple Notes for a topic and summarize the best match", { topic: z.string().describe("What to search for") }, ({ topic }) => ({
34
+ messages: [
35
+ {
36
+ role: "user",
37
+ content: {
38
+ type: "text",
39
+ text: `Search my Apple Notes for "${topic}" with the search-notes tool (set searchContent: true). Open the most relevant result with get-note-content and give me a concise summary plus its note id.`,
40
+ },
41
+ },
42
+ ],
43
+ }));
44
+ server.prompt("weekly-review", "Review notes changed recently and surface follow-ups", () => ({
45
+ messages: [
46
+ {
47
+ role: "user",
48
+ content: {
49
+ type: "text",
50
+ text: "Use get-notes-stats to see how many notes changed in the last 7 days, then search-notes (searchContent: true, modifiedSince: the date 7 days ago) to list them. Summarize the themes and call out any open action items or checklists I should follow up on.",
51
+ },
52
+ },
53
+ ],
54
+ }));
55
+ server.prompt("new-meeting-note", "Draft and create a structured meeting note", {
56
+ subject: z.string().describe("Meeting subject"),
57
+ attendees: z.string().optional().describe("Comma-separated attendees"),
58
+ folder: z.string().optional().describe("Target folder"),
59
+ }, ({ subject, attendees, folder }) => ({
60
+ messages: [
61
+ {
62
+ role: "user",
63
+ content: {
64
+ type: "text",
65
+ text: `Create an Apple Note titled "${subject}" ${folder ? `in folder "${folder}" ` : ""}using create-note (format: html). Include sections for Attendees${attendees ? ` (${attendees})` : ""}, Agenda, Discussion, and Action Items. Render Action Items as a plain bulleted list and remind me I can convert it to a checklist in Notes with ⇧⌘L.`,
66
+ },
67
+ },
68
+ ],
69
+ }));
70
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { registerResourcesAndPrompts } from "../tools/resourcesAndPrompts.js";
3
+ class FakeServer {
4
+ resources = new Map();
5
+ prompts = new Map();
6
+ resource(name, uriOrTemplate, cb) {
7
+ this.resources.set(name, { uriOrTemplate, cb });
8
+ }
9
+ prompt(name, ...rest) {
10
+ this.prompts.set(name, rest[rest.length - 1]);
11
+ }
12
+ }
13
+ function fakeMgr() {
14
+ return {
15
+ listAccounts: () => [{ name: "iCloud" }],
16
+ listFolders: vi.fn(() => [{ name: "Notes" }]),
17
+ getNotesStats: () => ({
18
+ totalNotes: 1,
19
+ accounts: [],
20
+ recentlyModified: { last24h: 0, last7d: 0, last30d: 0 },
21
+ }),
22
+ getNoteMarkdownById: vi.fn(() => "# Hello"),
23
+ };
24
+ }
25
+ describe("registerResourcesAndPrompts (#23)", () => {
26
+ it("registers the expected resources and prompts", () => {
27
+ const s = new FakeServer();
28
+ registerResourcesAndPrompts(s, fakeMgr());
29
+ expect([...s.resources.keys()].sort()).toEqual(["accounts", "folders", "note", "stats"]);
30
+ expect([...s.prompts.keys()].sort()).toEqual([
31
+ "find-note",
32
+ "new-meeting-note",
33
+ "weekly-review",
34
+ ]);
35
+ });
36
+ it("accounts/folders/stats resources return JSON", () => {
37
+ const s = new FakeServer();
38
+ registerResourcesAndPrompts(s, fakeMgr());
39
+ const acc = s.resources.get("accounts").cb(new URL("notes://accounts"), {});
40
+ expect(JSON.parse(acc.contents[0].text)).toEqual({ accounts: [{ name: "iCloud" }] });
41
+ const fol = s.resources.get("folders").cb(new URL("notes://folders"), {});
42
+ expect(JSON.parse(fol.contents[0].text).accounts[0].account).toBe("iCloud");
43
+ const st = s.resources.get("stats").cb(new URL("notes://stats"), {});
44
+ expect(JSON.parse(st.contents[0].text).totalNotes).toBe(1);
45
+ });
46
+ it("note template resolves the id and returns markdown", () => {
47
+ const s = new FakeServer();
48
+ const mgr = fakeMgr();
49
+ registerResourcesAndPrompts(s, mgr);
50
+ const out = s.resources.get("note").cb(new URL("notes://note/abc%20def"), { id: "abc%20def" });
51
+ expect(mgr.getNoteMarkdownById).toHaveBeenCalledWith("abc def");
52
+ expect(out.contents[0].text).toBe("# Hello");
53
+ });
54
+ it("prompts produce user messages including their args", () => {
55
+ const s = new FakeServer();
56
+ registerResourcesAndPrompts(s, fakeMgr());
57
+ expect(s.prompts.get("find-note")({ topic: "taxes" }).messages[0].content.text).toMatch(/taxes/);
58
+ expect(s.prompts.get("weekly-review")({}).messages[0].content.text).toMatch(/last 7 days/);
59
+ const mtg = s.prompts.get("new-meeting-note")({ subject: "Q3 Plan", attendees: "Rob, Sam" });
60
+ expect(mtg.messages[0].content.text).toMatch(/Q3 Plan/);
61
+ expect(mtg.messages[0].content.text).toMatch(/Rob, Sam/);
62
+ });
63
+ });
@@ -13,6 +13,41 @@ import { execSync, spawnSync } from "child_process";
13
13
  * searches on large note collections. Can be overridden per-call.
14
14
  */
15
15
  const DEFAULT_TIMEOUT_MS = 30000;
16
+ /**
17
+ * Output cap for osascript. Node's execSync defaults to 1 MB, which a large
18
+ * Notes library (export-notes-json, full-library stat scans, long-note content)
19
+ * can blow past — execSync then throws ENOBUFS and the failure surfaces as an
20
+ * empty result. 64 MB headroom, overridable via APPLE_NOTES_MCP_MAX_BUFFER. (#16)
21
+ */
22
+ const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
23
+ function getMaxBuffer() {
24
+ const raw = process.env.APPLE_NOTES_MCP_MAX_BUFFER;
25
+ if (raw !== undefined) {
26
+ const n = Number(raw);
27
+ if (Number.isFinite(n) && n > 0)
28
+ return n;
29
+ }
30
+ return DEFAULT_MAX_BUFFER_BYTES;
31
+ }
32
+ /**
33
+ * Headroom (ms) between the in-AppleScript `with timeout` and the outer
34
+ * osascript process timeout. The script-level timeout must fire first so
35
+ * Notes.app aborts from inside its own AppleScript dispatch — releasing the
36
+ * event queue — before Node SIGKILLs osascript. Killing osascript alone does
37
+ * not stop work already dispatched into Notes.app, which is what wedges it for
38
+ * subsequent calls. (#17)
39
+ */
40
+ const SCRIPT_TIMEOUT_HEADROOM_MS = 5000;
41
+ /**
42
+ * Wrap a script body in an AppleScript `with timeout` block so an Apple Event
43
+ * that honors timeouts aborts cleanly rather than holding Notes.app's
44
+ * single-threaded dispatch open. Set below the process timeout so the in-app
45
+ * abort wins the race against the outer SIGKILL. (#17)
46
+ */
47
+ function wrapWithTimeout(script, processTimeoutMs) {
48
+ const seconds = Math.max(1, Math.ceil((processTimeoutMs - SCRIPT_TIMEOUT_HEADROOM_MS) / 1000));
49
+ return `with timeout of ${seconds} seconds\n${script}\nend timeout`;
50
+ }
16
51
  /**
17
52
  * Default retry configuration.
18
53
  * - 1 attempt means no retries (default behavior)
@@ -274,9 +309,11 @@ export function executeAppleScript(script, options = {}) {
274
309
  }
275
310
  // Prepare the script:
276
311
  // 1. Trim leading/trailing whitespace (cosmetic)
277
- // 2. Preserve internal newlines (required for AppleScript syntax)
278
- // 3. Escape for shell execution
279
- const preparedScript = escapeForShell(script.trim());
312
+ // 2. Wrap in `with timeout` so Notes.app aborts cleanly from inside its own
313
+ // dispatch before the outer process SIGKILL (#17)
314
+ // 3. Preserve internal newlines (required for AppleScript syntax)
315
+ // 4. Escape for shell execution
316
+ const preparedScript = escapeForShell(wrapWithTimeout(script.trim(), timeoutMs));
280
317
  // Build the osascript command
281
318
  // We use single quotes to wrap the script, which is why we escape
282
319
  // single quotes within the script itself
@@ -297,6 +334,13 @@ export function executeAppleScript(script, options = {}) {
297
334
  const output = execSync(command, {
298
335
  encoding: "utf8",
299
336
  timeout: timeoutMs,
337
+ // SIGKILL (not the default SIGTERM): a wedged osascript blocked on an
338
+ // unresponsive Notes.app can ignore SIGTERM and leak, piling up and
339
+ // worsening contention. SIGKILL guarantees reaping on timeout. (#17)
340
+ killSignal: "SIGKILL",
341
+ // Raise the output cap above Node's 1 MB default so large exports /
342
+ // long notes aren't truncated into an ENOBUFS failure. (#16)
343
+ maxBuffer: getMaxBuffer(),
300
344
  // Capture stderr separately to get error details
301
345
  stdio: ["pipe", "pipe", "pipe"],
302
346
  });
@@ -4,7 +4,7 @@
4
4
  * These tests mock the child_process.execSync function to avoid
5
5
  * requiring actual AppleScript execution during testing.
6
6
  */
7
- import { describe, it, expect, vi, beforeEach } from "vitest";
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
8
  import { execSync } from "child_process";
9
9
  import { executeAppleScript } from "./applescript.js";
10
10
  // Mock the child_process module
@@ -53,6 +53,34 @@ describe("executeAppleScript", () => {
53
53
  expect(calledCommand).toContain("Rob'\\''s");
54
54
  });
55
55
  });
56
+ describe("hardened executor (#16/#17)", () => {
57
+ afterEach(() => {
58
+ delete process.env.APPLE_NOTES_MCP_MAX_BUFFER;
59
+ });
60
+ it("wraps the script in `with timeout` so Notes.app aborts cleanly", () => {
61
+ mockExecSync.mockReturnValue("ok");
62
+ executeAppleScript("get name of notes", { timeoutMs: 30000 });
63
+ const cmd = mockExecSync.mock.calls[0][0];
64
+ expect(cmd).toContain("with timeout of");
65
+ expect(cmd).toContain("end timeout");
66
+ // 30s process timeout − 5s headroom = 25s script timeout
67
+ expect(cmd).toContain("with timeout of 25 seconds");
68
+ });
69
+ it("passes SIGKILL and a large maxBuffer to execSync", () => {
70
+ mockExecSync.mockReturnValue("ok");
71
+ executeAppleScript("get name of notes");
72
+ const opts = mockExecSync.mock.calls[0][1];
73
+ expect(opts.killSignal).toBe("SIGKILL");
74
+ expect(opts.maxBuffer).toBe(64 * 1024 * 1024);
75
+ });
76
+ it("honors APPLE_NOTES_MCP_MAX_BUFFER override", () => {
77
+ process.env.APPLE_NOTES_MCP_MAX_BUFFER = "1048576";
78
+ mockExecSync.mockReturnValue("ok");
79
+ executeAppleScript("get name of notes");
80
+ const opts = mockExecSync.mock.calls[0][1];
81
+ expect(opts.maxBuffer).toBe(1048576);
82
+ });
83
+ });
56
84
  describe("error handling", () => {
57
85
  it("returns error result when execution fails", () => {
58
86
  // Arrange: Mock an AppleScript execution failure
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Filesystem helpers for saving / fetching note attachments (#27).
3
+ *
4
+ * Notes.app exports an attachment to a path via AppleScript `save`. These helpers
5
+ * keep that safe (no writing outside sensible roots, no path traversal) and
6
+ * provide a base64 read for the fetch-attachment tool.
7
+ *
8
+ * @module utils/attachmentFs
9
+ */
10
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
11
+ import { isAbsolute, resolve, sep } from "path";
12
+ import { homedir, tmpdir } from "os";
13
+ /** Roots an attachment may be written to. */
14
+ export function allowedSaveRoots() {
15
+ return [resolve(homedir()), resolve(tmpdir()), "/Volumes", "/private/var/folders", "/tmp"];
16
+ }
17
+ /**
18
+ * Validate a user-supplied destination path. Returns the resolved absolute path,
19
+ * or throws if it is relative or escapes the allowed roots.
20
+ */
21
+ export function assertSafeSavePath(p, roots = allowedSaveRoots()) {
22
+ if (!p || !p.trim())
23
+ throw new Error("A destination path is required.");
24
+ if (!isAbsolute(p))
25
+ throw new Error(`Destination path must be absolute: "${p}"`);
26
+ const abs = resolve(p);
27
+ const ok = roots.some((r) => abs === r || abs.startsWith(r.endsWith(sep) ? r : r + sep));
28
+ if (!ok) {
29
+ throw new Error(`Refusing to write outside allowed locations (home, temp, /Volumes): "${abs}"`);
30
+ }
31
+ return abs;
32
+ }
33
+ /** Read a file as base64. */
34
+ export function readFileBase64(p) {
35
+ return readFileSync(p).toString("base64");
36
+ }
37
+ /** Byte size of a file (0 if missing). */
38
+ export function fileSize(p) {
39
+ try {
40
+ return statSync(p).size;
41
+ }
42
+ catch {
43
+ return 0;
44
+ }
45
+ }
46
+ /** Make a private temp dir for a one-shot attachment export; caller cleans up. */
47
+ export function makeTempDir() {
48
+ return mkdtempSync(resolve(tmpdir(), "apple-notes-att-"));
49
+ }
50
+ /** Remove a temp dir tree, ignoring errors. */
51
+ export function cleanupTempDir(dir) {
52
+ try {
53
+ if (existsSync(dir))
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ catch {
57
+ /* best-effort */
58
+ }
59
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { writeFileSync, existsSync, mkdtempSync } from "fs";
3
+ import { homedir, tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { assertSafeSavePath, readFileBase64, fileSize, makeTempDir, cleanupTempDir, allowedSaveRoots, } from "../utils/attachmentFs.js";
6
+ const dirs = [];
7
+ afterEach(() => dirs.splice(0).forEach(cleanupTempDir));
8
+ describe("assertSafeSavePath (#27)", () => {
9
+ it("accepts absolute paths under the temp dir and home dir", () => {
10
+ const p = join(tmpdir(), "x.png");
11
+ expect(assertSafeSavePath(p)).toBe(p);
12
+ const h = join(homedir(), "Downloads", "x.png");
13
+ expect(assertSafeSavePath(h)).toBe(h);
14
+ });
15
+ it("rejects empty, relative, and out-of-root paths", () => {
16
+ expect(() => assertSafeSavePath("")).toThrow(/required/);
17
+ expect(() => assertSafeSavePath("relative/x.png")).toThrow(/absolute/);
18
+ expect(() => assertSafeSavePath("/etc/passwd")).toThrow(/outside allowed/);
19
+ });
20
+ it("blocks traversal that escapes an allowed root", () => {
21
+ expect(() => assertSafeSavePath(join(tmpdir(), "..", "..", "etc", "x"))).toThrow(/outside allowed/);
22
+ });
23
+ it("exposes the allowed roots", () => {
24
+ expect(allowedSaveRoots()).toEqual(expect.arrayContaining(["/Volumes"]));
25
+ });
26
+ });
27
+ describe("base64 / size / temp helpers (#27)", () => {
28
+ it("reads a file as base64 and reports its size", () => {
29
+ const dir = mkdtempSync(join(tmpdir(), "anatt-"));
30
+ dirs.push(dir);
31
+ const f = join(dir, "f.bin");
32
+ writeFileSync(f, Buffer.from("hello"));
33
+ expect(readFileBase64(f)).toBe(Buffer.from("hello").toString("base64"));
34
+ expect(fileSize(f)).toBe(5);
35
+ });
36
+ it("fileSize returns 0 for a missing file", () => {
37
+ expect(fileSize(join(tmpdir(), "definitely-missing-xyz.bin"))).toBe(0);
38
+ });
39
+ it("makeTempDir creates a dir and cleanupTempDir removes it (idempotent)", () => {
40
+ const dir = makeTempDir();
41
+ expect(existsSync(dir)).toBe(true);
42
+ cleanupTempDir(dir);
43
+ expect(existsSync(dir)).toBe(false);
44
+ expect(() => cleanupTempDir(dir)).not.toThrow();
45
+ });
46
+ });
@@ -14,6 +14,21 @@
14
14
  * @module utils/jxa
15
15
  */
16
16
  import { execSync } from "child_process";
17
+ /**
18
+ * Output cap for osascript (JXA). Mirrors the AppleScript executor — Node's 1 MB
19
+ * default truncates large JXA output into an ENOBUFS failure. 64 MB default,
20
+ * overridable via APPLE_NOTES_MCP_MAX_BUFFER. (#16)
21
+ */
22
+ const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
23
+ function getMaxBuffer() {
24
+ const raw = process.env.APPLE_NOTES_MCP_MAX_BUFFER;
25
+ if (raw !== undefined) {
26
+ const n = Number(raw);
27
+ if (Number.isFinite(n) && n > 0)
28
+ return n;
29
+ }
30
+ return DEFAULT_MAX_BUFFER_BYTES;
31
+ }
17
32
  const DEFAULT_TIMEOUT_MS = 30000;
18
33
  /**
19
34
  * Escapes a string for safe inclusion in a JXA script.
@@ -79,6 +94,8 @@ export function executeJXA(script, options = {}) {
79
94
  const output = execSync(command, {
80
95
  encoding: "utf8",
81
96
  timeout: timeoutMs,
97
+ killSignal: "SIGKILL", // reap a wedged osascript reliably (#17)
98
+ maxBuffer: getMaxBuffer(), // avoid ENOBUFS truncation on large output (#16)
82
99
  stdio: ["pipe", "pipe", "pipe"],
83
100
  });
84
101
  return {