apple-notes-mcp 2.4.0 → 2.5.1

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 CHANGED
@@ -135,7 +135,7 @@ A few Notes UI features are not exposed to AppleScript and therefore cannot be
135
135
  supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
136
136
  for the investigation and verification behind each:
137
137
 
138
- - **Pinned notes** — Notes has no scriptable `pinned` property, so pin state can be neither read nor set.
138
+ - **Pinned notes** — Notes has no scriptable `pinned` property via AppleScript. Pin state can now be **read** with the BETA `get-note-metadata` tool (from the NoteStore database), but it still cannot be **set** programmatically.
139
139
  - **Note-to-note links** — there is no `applenotes://` deep link or link property; the only stable handle is the `x-coredata://` note id.
140
140
 
141
141
  ---
@@ -706,6 +706,22 @@ Checklist for "Shopping List" (2/4 done):
706
706
 
707
707
  ---
708
708
 
709
+ #### `get-note-metadata` (BETA)
710
+
711
+ Reads note metadata that AppleScript cannot expose, by querying the NoteStore SQLite database directly: pinned state, checklist flags, trash/recovery state, the preview snippet, and the password hint. The available fields vary by macOS version.
712
+
713
+ **Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access-for-checklist-features)).
714
+
715
+ **BETA:** the NoteStore schema changes between macOS releases, so some fields can be absent on older or newer systems. The database is only ever read, never written.
716
+
717
+ | Parameter | Type | Required | Description |
718
+ |-----------|------|----------|-------------|
719
+ | `id` | string | Yes | Note ID (use `search-notes` to find it first) |
720
+
721
+ **Returns:** A metadata object in `structuredContent` holding any of `pinned`, `hasChecklist`, `hasChecklistInProgress`, `recoveringFromTrash`, `passwordProtected`, `passwordHint`, `snippet`, `widgetSnippet`, and `smartFolderQuery`. Unlike most read tools, it also resolves trashed notes that AppleScript can no longer find.
722
+
723
+ ---
724
+
709
725
  #### `list-attachments`
710
726
 
711
727
  Lists attachments in a note.
package/build/index.js CHANGED
@@ -26,6 +26,7 @@ import { z } from "zod";
26
26
  import { AppleNotesManager } from "./services/appleNotesManager.js";
27
27
  import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
28
28
  import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
29
+ import { getNoteMetadata } from "./utils/noteMetadata.js";
29
30
  import { detectChecklistAttempt } from "./utils/contentWarnings.js";
30
31
  import { parseHashtags } from "./utils/hashtags.js";
31
32
  import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
@@ -1261,6 +1262,36 @@ server.registerTool("get-checklist-state", {
1261
1262
  const checked = result.items.filter((i) => i.done).length;
1262
1263
  return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`, { items: result.items, checked, total: result.items.length });
1263
1264
  }, "Error reading checklist state"));
1265
+ // --- get-note-metadata (BETA) ---
1266
+ server.registerTool("get-note-metadata", {
1267
+ description: "[BETA] Use when: reading note metadata AppleScript cannot expose — pinned state, checklist flags, trash/recovery state, preview snippet, password hint — by id.\nReturns: a metadata object; fields vary by macOS version and are omitted when unavailable.\nDo not use when: you need the body (get-note-content) or per-item checklist state (get-checklist-state).\nNote: reads the NoteStore SQLite database read-only and requires Full Disk Access. BETA — the database schema changes between macOS releases, so some fields may be absent. Works on trashed notes that AppleScript can no longer resolve.",
1268
+ inputSchema: {
1269
+ id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
1270
+ },
1271
+ outputSchema: {
1272
+ pinned: z.boolean().optional(),
1273
+ hasChecklist: z.boolean().optional(),
1274
+ hasChecklistInProgress: z.boolean().optional(),
1275
+ recoveringFromTrash: z.boolean().optional(),
1276
+ passwordProtected: z.boolean().optional(),
1277
+ passwordHint: z.string().optional(),
1278
+ snippet: z.string().optional(),
1279
+ widgetSnippet: z.string().optional(),
1280
+ smartFolderQuery: z.string().optional(),
1281
+ },
1282
+ }, withErrorHandling(({ id }) => {
1283
+ // No AppleScript existence pre-check: reading straight from the database lets
1284
+ // this resolve trashed/recovering notes that `note id ...` can no longer find.
1285
+ const { metadata, message } = getNoteMetadata(id);
1286
+ if (!metadata) {
1287
+ return errorResponse(message || `Failed to read metadata for note "${id}"`);
1288
+ }
1289
+ const keys = Object.keys(metadata);
1290
+ const summary = keys.length === 0
1291
+ ? `No additional metadata is available for note "${id}" on this macOS version.`
1292
+ : keys.map((k) => `${k}: ${String(metadata[k])}`).join("\n");
1293
+ return successResponse(summary, metadata);
1294
+ }, "Error reading note metadata"));
1264
1295
  // =============================================================================
1265
1296
  // Server Startup
1266
1297
  // =============================================================================
@@ -2056,10 +2056,11 @@ export class AppleNotesManager {
2056
2056
  */
2057
2057
  listAttachments(title, account) {
2058
2058
  const targetAccount = this.resolveAccount(account);
2059
+ const safeAccount = escapePlainStringForAppleScript(targetAccount);
2059
2060
  const safeTitle = escapePlainStringForAppleScript(title);
2060
2061
  const script = `
2061
2062
  tell application "Notes"
2062
- tell account "${targetAccount}"
2063
+ tell account "${safeAccount}"
2063
2064
  set theNote to note "${safeTitle}"
2064
2065
  set attachmentList to {}
2065
2066
  repeat with a in attachments of theNote
@@ -315,6 +315,17 @@ describe("AppleNotesManager", () => {
315
315
  manager = new AppleNotesManager();
316
316
  vi.clearAllMocks();
317
317
  });
318
+ describe("listAttachments — security", () => {
319
+ it("escapes the account name so it cannot break out of the AppleScript literal (injection regression)", () => {
320
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
321
+ manager.listAttachments("My Note", 'evil" injected');
322
+ const script = String(mockExecuteAppleScript.mock.calls.at(-1)?.[0]);
323
+ // The account's double-quote must be escaped (\\") — a raw quote would
324
+ // terminate the tell-account string literal and allow `do shell script` injection.
325
+ expect(script).toContain('tell account "evil\\" injected"');
326
+ expect(script).not.toContain('tell account "evil" injected"');
327
+ });
328
+ });
318
329
  // ---------------------------------------------------------------------------
319
330
  // Note Creation
320
331
  // ---------------------------------------------------------------------------
@@ -24,7 +24,7 @@
24
24
  * @module utils/checklistParser
25
25
  * @see https://github.com/sweetrb/apple-notes-mcp/issues/2
26
26
  */
27
- import { execSync } from "child_process";
27
+ import { execFileSync } from "child_process";
28
28
  import * as zlib from "zlib";
29
29
  import * as fs from "fs";
30
30
  import * as path from "path";
@@ -43,7 +43,7 @@ export function hasFullDiskAccess() {
43
43
  if (!fs.existsSync(NOTES_DB_PATH))
44
44
  return false;
45
45
  // Try to open the database with a simple query
46
- execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "SELECT 1;"`, {
46
+ execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, "SELECT 1;"], {
47
47
  encoding: "utf8",
48
48
  timeout: 3000,
49
49
  stdio: ["pipe", "pipe", "pipe"],
@@ -75,7 +75,7 @@ function queryNoteData(noteId) {
75
75
  // Query for the gzipped protobuf data, output as hex for safe transport
76
76
  const query = `SELECT hex(nd.ZDATA) FROM ZICNOTEDATA nd JOIN ZICCLOUDSYNCINGOBJECT n ON nd.ZNOTE = n.Z_PK WHERE n.Z_PK = ${pk};`;
77
77
  try {
78
- const result = execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "${query}"`, {
78
+ const result = execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
79
79
  encoding: "utf8",
80
80
  timeout: 5000,
81
81
  stdio: ["pipe", "pipe", "pipe"],
@@ -9,7 +9,7 @@ import * as zlib from "zlib";
9
9
  import { getChecklistItems, hasFullDiskAccess } from "./checklistParser.js";
10
10
  // Mock child_process to avoid actual database access
11
11
  vi.mock("child_process", () => ({
12
- execSync: vi.fn(),
12
+ execFileSync: vi.fn(),
13
13
  spawnSync: vi.fn(() => ({ error: null })),
14
14
  }));
15
15
  // Mock fs for database existence checks
@@ -17,9 +17,9 @@ vi.mock("fs", () => ({
17
17
  existsSync: vi.fn(() => true),
18
18
  statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
19
19
  }));
20
- import { execSync } from "child_process";
20
+ import { execFileSync } from "child_process";
21
21
  import { existsSync } from "fs";
22
- const mockExecSync = vi.mocked(execSync);
22
+ const mockExecSync = vi.mocked(execFileSync);
23
23
  const mockExistsSync = vi.mocked(existsSync);
24
24
  /**
25
25
  * Builds a minimal Apple Notes protobuf structure with checklist items.
@@ -225,6 +225,6 @@ describe("getChecklistItems", () => {
225
225
  it("extracts correct primary key from note ID", () => {
226
226
  mockExecSync.mockReturnValue("");
227
227
  getChecklistItems("x-coredata://12345-ABCDE/ICNote/p42");
228
- expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("Z_PK = 42"), expect.any(Object));
228
+ expect(mockExecSync).toHaveBeenCalledWith("sqlite3", expect.arrayContaining([expect.stringContaining("Z_PK = 42")]), expect.any(Object));
229
229
  });
230
230
  });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Apple Notes Read-Only Metadata Reader [BETA]
3
+ *
4
+ * Reads note metadata that the AppleScript dictionary does not expose (pinned
5
+ * state, checklist flags, trash/recovery, preview snippet, password hint) by
6
+ * querying the NoteStore SQLite database directly.
7
+ *
8
+ * Unlike the note body (a gzipped protobuf blob in ZICNOTEDATA.ZDATA), these are
9
+ * plain scalar columns on ZICCLOUDSYNCINGOBJECT, so no protobuf decoding is
10
+ * needed — an ordinary SELECT is enough.
11
+ *
12
+ * BETA / safety:
13
+ * - The database is opened READ-ONLY (`sqlite3 -readonly`). This code never
14
+ * writes to the live store; doing so would corrupt CloudKit sync state.
15
+ * - Requires Full Disk Access for the host process.
16
+ * - The schema changes between macOS releases, so the reader feature-detects
17
+ * which columns exist (PRAGMA table_info) and only selects those.
18
+ *
19
+ * @module utils/noteMetadata
20
+ * @see TECHNICAL_NOTES.md#read-only-metadata-columns-verified-macos-27--notes-413
21
+ */
22
+ import { execFileSync } from "child_process";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
27
+ const FDA_MESSAGE = "Full Disk Access is required to read note metadata. " +
28
+ "Grant access in System Settings > Privacy & Security > Full Disk Access, " +
29
+ "then add and restart this application.";
30
+ /**
31
+ * Friendly field name -> NoteStore column. This is a fixed allowlist; nothing
32
+ * here is built from user input, so the column names are never an injection
33
+ * vector. `bool` columns store 0/1; `text` columns store strings.
34
+ */
35
+ const COLUMN_MAP = [
36
+ { key: "pinned", column: "ZISPINNED", type: "bool" },
37
+ { key: "hasChecklist", column: "ZHASCHECKLIST", type: "bool" },
38
+ { key: "hasChecklistInProgress", column: "ZHASCHECKLISTINPROGRESS", type: "bool" },
39
+ { key: "recoveringFromTrash", column: "ZISRECOVERINGFROMTRASH", type: "bool" },
40
+ { key: "passwordProtected", column: "ZISPASSWORDPROTECTED", type: "bool" },
41
+ { key: "passwordHint", column: "ZPASSWORDHINT", type: "text" },
42
+ { key: "snippet", column: "ZSNIPPET", type: "text" },
43
+ { key: "widgetSnippet", column: "ZWIDGETSNIPPET", type: "text" },
44
+ { key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" },
45
+ ];
46
+ /**
47
+ * Runs a read-only sqlite3 query against the live NoteStore and returns trimmed
48
+ * stdout. Throws on failure (callers classify the error).
49
+ *
50
+ * Uses execFileSync with an argument array (no shell), so the database path's
51
+ * spaces and the query string are passed verbatim and shell metacharacters are
52
+ * never interpreted. The only dynamic value in any query is the note's primary
53
+ * key, which callers constrain to digits before it reaches here.
54
+ */
55
+ function runSqlite(query) {
56
+ return execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
57
+ encoding: "utf8",
58
+ timeout: 5000,
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ }).trim();
61
+ }
62
+ /**
63
+ * Returns the set of column names present on ZICCLOUDSYNCINGOBJECT, so the
64
+ * reader can skip columns that do not exist on this macOS version.
65
+ */
66
+ function presentColumns() {
67
+ const out = runSqlite("PRAGMA table_info(ZICCLOUDSYNCINGOBJECT);");
68
+ const cols = new Set();
69
+ for (const line of out.split("\n")) {
70
+ // Each row: cid|name|type|notnull|dflt_value|pk
71
+ const name = line.split("|")[1];
72
+ if (name)
73
+ cols.add(name);
74
+ }
75
+ return cols;
76
+ }
77
+ /**
78
+ * Reads read-only metadata for a note by its CoreData ID.
79
+ *
80
+ * @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
81
+ * @returns Structured result with the metadata, an error type, and a message
82
+ */
83
+ export function getNoteMetadata(noteId) {
84
+ const pkMatch = noteId.match(/\/p(\d+)$/);
85
+ if (!pkMatch) {
86
+ return {
87
+ metadata: null,
88
+ error: "invalid_id",
89
+ message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
90
+ };
91
+ }
92
+ const pk = pkMatch[1];
93
+ if (!fs.existsSync(NOTES_DB_PATH)) {
94
+ return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
95
+ }
96
+ try {
97
+ const available = presentColumns();
98
+ const selected = COLUMN_MAP.filter((c) => available.has(c.column));
99
+ if (selected.length === 0) {
100
+ // Schema has none of the known columns (very old or very new macOS).
101
+ return { metadata: {} };
102
+ }
103
+ const pairs = selected.map((c) => `'${c.key}', ${c.column}`).join(", ");
104
+ const row = runSqlite(`SELECT json_object(${pairs}) FROM ZICCLOUDSYNCINGOBJECT WHERE Z_PK = ${pk};`);
105
+ if (!row) {
106
+ return {
107
+ metadata: null,
108
+ error: "not_found",
109
+ message: `No note found in the database for ID "${noteId}".`,
110
+ };
111
+ }
112
+ const raw = JSON.parse(row);
113
+ const metadata = {};
114
+ for (const c of selected) {
115
+ const value = raw[c.key];
116
+ if (value === null || value === undefined)
117
+ continue;
118
+ if (c.type === "bool") {
119
+ metadata[c.key] = value === 1 || value === "1" || value === true;
120
+ }
121
+ else {
122
+ metadata[c.key] = String(value);
123
+ }
124
+ }
125
+ return { metadata: metadata };
126
+ }
127
+ catch (error) {
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ if (message.includes("authorization denied") || message.includes("unable to open database")) {
130
+ return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
131
+ }
132
+ console.error(`Failed to read note metadata: ${message}`);
133
+ return { metadata: null, error: "query_error", message: "Failed to read note metadata." };
134
+ }
135
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tests for the read-only note metadata reader.
3
+ *
4
+ * The NoteStore SQLite access is mocked, so these exercise the pk extraction,
5
+ * column feature-detection, JSON parsing, boolean coercion, and error
6
+ * classification without touching a real database.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+ vi.mock("child_process", () => ({
10
+ execFileSync: vi.fn(),
11
+ }));
12
+ vi.mock("fs", () => ({
13
+ existsSync: vi.fn(() => true),
14
+ }));
15
+ import { execFileSync } from "child_process";
16
+ import { existsSync } from "fs";
17
+ import { getNoteMetadata } from "./noteMetadata.js";
18
+ const mockExecFileSync = vi.mocked(execFileSync);
19
+ const mockExistsSync = vi.mocked(existsSync);
20
+ const NOTE_ID = "x-coredata://ABC/ICNote/p123";
21
+ /** Builds a PRAGMA table_info dump from a list of column names. */
22
+ function tableInfo(columns) {
23
+ return columns.map((name, i) => `${i}|${name}|INTEGER|0||0`).join("\n");
24
+ }
25
+ /** The query string is the third sqlite3 argument: [-readonly, dbPath, query]. */
26
+ function queryOf(call) {
27
+ const args = call[1];
28
+ return args[2];
29
+ }
30
+ describe("getNoteMetadata", () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ mockExistsSync.mockReturnValue(true);
34
+ });
35
+ it("rejects a malformed note id without touching the database", () => {
36
+ const result = getNoteMetadata("not-a-real-id");
37
+ expect(result.metadata).toBeNull();
38
+ expect(result.error).toBe("invalid_id");
39
+ expect(mockExecFileSync).not.toHaveBeenCalled();
40
+ });
41
+ it("reads pinned/checklist/snippet and coerces 0/1 to booleans", () => {
42
+ mockExecFileSync.mockImplementation((_cmd, args) => {
43
+ const query = args[2];
44
+ if (query.includes("table_info")) {
45
+ return tableInfo(["Z_PK", "ZISPINNED", "ZHASCHECKLIST", "ZSNIPPET", "ZPASSWORDHINT"]);
46
+ }
47
+ return JSON.stringify({
48
+ pinned: 1,
49
+ hasChecklist: 0,
50
+ snippet: "Hello world",
51
+ passwordHint: null,
52
+ });
53
+ });
54
+ const result = getNoteMetadata(NOTE_ID);
55
+ expect(result.error).toBeUndefined();
56
+ expect(result.metadata).toEqual({
57
+ pinned: true,
58
+ hasChecklist: false,
59
+ snippet: "Hello world",
60
+ });
61
+ // A NULL column (passwordHint) is omitted, not included as null.
62
+ expect(result.metadata).not.toHaveProperty("passwordHint");
63
+ });
64
+ it("only selects columns that exist on this schema", () => {
65
+ mockExecFileSync.mockImplementation((_cmd, args) => {
66
+ const query = args[2];
67
+ if (query.includes("table_info"))
68
+ return tableInfo(["Z_PK", "ZISPINNED"]);
69
+ return JSON.stringify({ pinned: 1 });
70
+ });
71
+ const result = getNoteMetadata(NOTE_ID);
72
+ expect(result.metadata).toEqual({ pinned: true });
73
+ // The SELECT must not reference a column the schema lacks.
74
+ const selectCall = mockExecFileSync.mock.calls.find((c) => queryOf(c).includes("json_object"));
75
+ expect(selectCall).toBeDefined();
76
+ expect(queryOf(selectCall)).toContain("ZISPINNED");
77
+ expect(queryOf(selectCall)).not.toContain("ZSNIPPET");
78
+ });
79
+ it("returns not_found when no row matches the primary key", () => {
80
+ mockExecFileSync.mockImplementation((_cmd, args) => {
81
+ const query = args[2];
82
+ if (query.includes("table_info"))
83
+ return tableInfo(["Z_PK", "ZISPINNED"]);
84
+ return "";
85
+ });
86
+ const result = getNoteMetadata(NOTE_ID);
87
+ expect(result.metadata).toBeNull();
88
+ expect(result.error).toBe("not_found");
89
+ });
90
+ it("classifies a Full Disk Access denial", () => {
91
+ mockExecFileSync.mockImplementation(() => {
92
+ throw new Error("Error: authorization denied");
93
+ });
94
+ const result = getNoteMetadata(NOTE_ID);
95
+ expect(result.metadata).toBeNull();
96
+ expect(result.error).toBe("no_fda");
97
+ expect(result.message).toContain("Full Disk Access");
98
+ });
99
+ it("returns no_fda when the database file is missing", () => {
100
+ mockExistsSync.mockReturnValue(false);
101
+ const result = getNoteMetadata(NOTE_ID);
102
+ expect(result.metadata).toBeNull();
103
+ expect(result.error).toBe("no_fda");
104
+ expect(mockExecFileSync).not.toHaveBeenCalled();
105
+ });
106
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -68,7 +68,7 @@
68
68
  "@types/turndown": "^5.0.6",
69
69
  "@typescript-eslint/eslint-plugin": "^8.0.0",
70
70
  "@typescript-eslint/parser": "^8.0.0",
71
- "@vitest/coverage-v8": "^2.1.9",
71
+ "@vitest/coverage-v8": "^3.2.6",
72
72
  "eslint": "^9.0.0",
73
73
  "globals": "^17.0.0",
74
74
  "husky": "^9.1.7",
@@ -78,7 +78,7 @@
78
78
  "tsconfig-paths": "^4.2.0",
79
79
  "typescript": "^5.0.0",
80
80
  "typescript-eslint": "^8.51.0",
81
- "vitest": "^2.0.0"
81
+ "vitest": "^3.2.6"
82
82
  },
83
83
  "volta": {
84
84
  "node": "24.17.0"