apple-notes-mcp 2.4.0 → 2.5.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 +17 -1
- package/build/index.js +31 -0
- package/build/utils/noteMetadata.js +135 -0
- package/build/utils/noteMetadata.test.js +106 -0
- package/package.json +1 -1
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
|
|
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
|
// =============================================================================
|
|
@@ -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