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.
- package/README.md +97 -6
- package/build/index.js +96 -42
- package/build/services/appleNotesManager.js +272 -142
- package/build/services/appleNotesManager.test.js +196 -68
- package/build/services/attachmentSave.test.js +85 -0
- package/build/services/fileConfig.js +51 -0
- package/build/services/fileConfig.test.js +48 -0
- package/build/tools/doctor.js +50 -0
- package/build/tools/doctor.test.js +42 -0
- package/build/tools/resourcesAndPrompts.js +70 -0
- package/build/tools/resourcesAndPrompts.test.js +63 -0
- package/build/utils/applescript.js +47 -3
- package/build/utils/applescript.test.js +29 -1
- package/build/utils/attachmentFs.js +59 -0
- package/build/utils/attachmentFs.test.js +46 -0
- package/build/utils/jxa.js +17 -0
- package/build/utils/jxa.test.js +20 -1
- package/package.json +1 -1
|
@@ -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.
|
|
278
|
-
//
|
|
279
|
-
|
|
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
|
+
});
|
package/build/utils/jxa.js
CHANGED
|
@@ -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 {
|