@disco_trooper/apple-notes-mcp 1.4.0 → 1.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 +19 -11
- package/package.json +1 -1
- package/src/index.ts +59 -17
- package/src/notes/read.test.ts +162 -1
- package/src/notes/read.ts +49 -0
package/README.md
CHANGED
|
@@ -10,23 +10,19 @@ MCP server for Apple Notes with semantic search and CRUD operations. Claude sear
|
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- **Chunk-Based Search** - Long notes split into chunks for accurate matching
|
|
14
|
-
- **Query Caching** - 60x faster repeated searches
|
|
15
|
-
- **Knowledge Graph** - Tags, links, and related notes discovery
|
|
13
|
+
- **Chunk-Based Search** - Long notes split into chunks for accurate matching
|
|
14
|
+
- **Query Caching** - 60x faster repeated searches
|
|
15
|
+
- **Knowledge Graph** - Tags, links, and related notes discovery
|
|
16
16
|
- **Hybrid Search** - Vector + keyword search with Reciprocal Rank Fusion
|
|
17
17
|
- **Semantic Search** - Find notes by meaning, not keywords
|
|
18
18
|
- **Full CRUD** - Create, read, update, delete, and move notes
|
|
19
19
|
- **Incremental Indexing** - Re-embed only changed notes
|
|
20
20
|
- **Dual Embedding** - Local HuggingFace or OpenRouter API
|
|
21
21
|
|
|
22
|
-
## What's New in 1.
|
|
22
|
+
## What's New in 1.5
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **Purge Index** - Clear all indexed data when switching models or fixing corruption.
|
|
27
|
-
- **Parent Document Retriever** - Splits long notes into 500-char chunks with 100-char overlap.
|
|
28
|
-
- **60x faster cached queries** - Query embedding cache eliminates redundant API calls.
|
|
29
|
-
- **4-6x faster indexing** - Parallel processing and batch embeddings.
|
|
24
|
+
- **List Notes with Sorting** - Sort by created, modified, or title; filter by folder; limit results.
|
|
25
|
+
- **Case-Insensitive Folders** - Folder filtering now matches regardless of case.
|
|
30
26
|
|
|
31
27
|
## Installation
|
|
32
28
|
|
|
@@ -113,7 +109,19 @@ include_content: false # include full content vs preview
|
|
|
113
109
|
```
|
|
114
110
|
|
|
115
111
|
#### `list-notes`
|
|
116
|
-
|
|
112
|
+
List notes with sorting and filtering. Without parameters, shows index statistics.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
sort_by: "modified" # created, modified, or title (default: modified)
|
|
116
|
+
order: "desc" # asc or desc (default: desc)
|
|
117
|
+
limit: 10 # max notes to return (1-100)
|
|
118
|
+
folder: "Work" # filter by folder (case-insensitive)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Examples:**
|
|
122
|
+
- Get 5 newest notes: `{ sort_by: "created", order: "desc", limit: 5 }`
|
|
123
|
+
- Recently modified: `{ sort_by: "modified", limit: 10 }`
|
|
124
|
+
- Alphabetical in folder: `{ sort_by: "title", order: "asc", folder: "Projects" }`
|
|
117
125
|
|
|
118
126
|
#### `list-folders`
|
|
119
127
|
List all Apple Notes folders.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { validateEnv } from "./config/env.js";
|
|
|
25
25
|
|
|
26
26
|
// Import implementations
|
|
27
27
|
import { getVectorStore, getChunkStore } from "./db/lancedb.js";
|
|
28
|
-
import { getNoteByTitle, getAllFolders } from "./notes/read.js";
|
|
28
|
+
import { getNoteByTitle, getAllFolders, listNotes } from "./notes/read.js";
|
|
29
29
|
import { createNote, updateNote, deleteNote, moveNote, editTable, batchDelete, batchMove } from "./notes/crud.js";
|
|
30
30
|
import { searchNotes } from "./search/index.js";
|
|
31
31
|
import { indexNotes, reindexNote } from "./search/indexer.js";
|
|
@@ -148,6 +148,17 @@ const PurgeIndexSchema = z.object({
|
|
|
148
148
|
confirm: z.literal(true),
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
const ListNotesSchema = z.object({
|
|
152
|
+
sort_by: z.enum(["created", "modified", "title"]).default("modified"),
|
|
153
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
154
|
+
limit: z.number().min(1).max(100).optional(),
|
|
155
|
+
folder: z.string().max(200).optional(),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/** Exported type for listNotes options - derived from Zod schema (single source of truth)
|
|
159
|
+
* Using z.input to get the input type (with optionals) rather than z.infer (output with defaults applied) */
|
|
160
|
+
export type ListNotesOptions = z.input<typeof ListNotesSchema>;
|
|
161
|
+
|
|
151
162
|
// Knowledge Graph tool schemas
|
|
152
163
|
const ListTagsSchema = z.object({});
|
|
153
164
|
|
|
@@ -265,10 +276,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
265
276
|
},
|
|
266
277
|
{
|
|
267
278
|
name: "list-notes",
|
|
268
|
-
description: "
|
|
279
|
+
description: "List notes from Apple Notes with sorting and filtering. Without parameters, shows index statistics.",
|
|
269
280
|
inputSchema: {
|
|
270
281
|
type: "object",
|
|
271
|
-
properties: {
|
|
282
|
+
properties: {
|
|
283
|
+
sort_by: {
|
|
284
|
+
type: "string",
|
|
285
|
+
enum: ["created", "modified", "title"],
|
|
286
|
+
description: "Sort by date or title (default: modified)"
|
|
287
|
+
},
|
|
288
|
+
order: {
|
|
289
|
+
type: "string",
|
|
290
|
+
enum: ["asc", "desc"],
|
|
291
|
+
description: "Sort order (default: desc)"
|
|
292
|
+
},
|
|
293
|
+
limit: {
|
|
294
|
+
type: "number",
|
|
295
|
+
description: "Max notes to return (1-100)"
|
|
296
|
+
},
|
|
297
|
+
folder: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description: "Filter by folder"
|
|
300
|
+
},
|
|
301
|
+
},
|
|
272
302
|
required: [],
|
|
273
303
|
},
|
|
274
304
|
},
|
|
@@ -581,22 +611,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
581
611
|
}
|
|
582
612
|
|
|
583
613
|
case "list-notes": {
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
614
|
+
const params = ListNotesSchema.parse(args);
|
|
615
|
+
|
|
616
|
+
// No parameters provided: show index statistics (backwards compatible)
|
|
617
|
+
const hasFilteringParams =
|
|
618
|
+
params.limit !== undefined ||
|
|
619
|
+
params.folder !== undefined ||
|
|
620
|
+
(args && ("sort_by" in args || "order" in args));
|
|
621
|
+
|
|
622
|
+
if (!hasFilteringParams) {
|
|
623
|
+
const store = getVectorStore();
|
|
624
|
+
const noteCount = await store.count();
|
|
625
|
+
const hasChunks = await hasChunkIndex();
|
|
626
|
+
|
|
627
|
+
if (hasChunks) {
|
|
628
|
+
const chunkStore = getChunkStore();
|
|
629
|
+
const chunkCount = await chunkStore.count();
|
|
630
|
+
return textResponse(
|
|
631
|
+
`${noteCount} notes indexed. ${chunkCount} chunks indexed for semantic search.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return textResponse(
|
|
636
|
+
`${noteCount} notes indexed. Run index-notes with mode='full' to enable chunk-based search.`
|
|
637
|
+
);
|
|
597
638
|
}
|
|
598
639
|
|
|
599
|
-
|
|
640
|
+
const notes = await listNotes(params);
|
|
641
|
+
return textResponse(JSON.stringify(notes, null, 2));
|
|
600
642
|
}
|
|
601
643
|
|
|
602
644
|
case "get-note": {
|
package/src/notes/read.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ vi.mock("run-jxa", () => ({
|
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
8
|
import { runJxa } from "run-jxa";
|
|
9
|
-
import { getAllNotes, getNoteByTitle, getAllFolders, resolveNoteTitle } from "./read.js";
|
|
9
|
+
import { getAllNotes, getNoteByTitle, getAllFolders, resolveNoteTitle, listNotes } from "./read.js";
|
|
10
10
|
|
|
11
11
|
describe("getAllNotes", () => {
|
|
12
12
|
beforeEach(() => {
|
|
@@ -184,3 +184,164 @@ describe("getNoteByTitle with ID prefix", () => {
|
|
|
184
184
|
expect(note).toBeNull();
|
|
185
185
|
});
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
describe("listNotes", () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
vi.clearAllMocks();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const mockNotes = [
|
|
194
|
+
{ title: "Alpha", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-10T00:00:00Z" },
|
|
195
|
+
{ title: "Beta", folder: "Personal", created: "2024-01-03T00:00:00Z", modified: "2024-01-05T00:00:00Z" },
|
|
196
|
+
{ title: "Gamma", folder: "Work", created: "2024-01-02T00:00:00Z", modified: "2024-01-15T00:00:00Z" },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
it("should return all notes with default sorting (modified desc)", async () => {
|
|
200
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
201
|
+
|
|
202
|
+
const notes = await listNotes();
|
|
203
|
+
expect(notes).toHaveLength(3);
|
|
204
|
+
// Most recently modified first
|
|
205
|
+
expect(notes[0].title).toBe("Gamma");
|
|
206
|
+
expect(notes[1].title).toBe("Alpha");
|
|
207
|
+
expect(notes[2].title).toBe("Beta");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should sort by created date ascending", async () => {
|
|
211
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
212
|
+
|
|
213
|
+
const notes = await listNotes({ sort_by: "created", order: "asc" });
|
|
214
|
+
expect(notes[0].title).toBe("Alpha");
|
|
215
|
+
expect(notes[1].title).toBe("Gamma");
|
|
216
|
+
expect(notes[2].title).toBe("Beta");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should sort by created date descending", async () => {
|
|
220
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
221
|
+
|
|
222
|
+
const notes = await listNotes({ sort_by: "created", order: "desc" });
|
|
223
|
+
expect(notes[0].title).toBe("Beta");
|
|
224
|
+
expect(notes[1].title).toBe("Gamma");
|
|
225
|
+
expect(notes[2].title).toBe("Alpha");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should sort by title alphabetically", async () => {
|
|
229
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
230
|
+
|
|
231
|
+
const notes = await listNotes({ sort_by: "title", order: "asc" });
|
|
232
|
+
expect(notes[0].title).toBe("Alpha");
|
|
233
|
+
expect(notes[1].title).toBe("Beta");
|
|
234
|
+
expect(notes[2].title).toBe("Gamma");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should sort by title descending", async () => {
|
|
238
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
239
|
+
|
|
240
|
+
const notes = await listNotes({ sort_by: "title", order: "desc" });
|
|
241
|
+
expect(notes[0].title).toBe("Gamma");
|
|
242
|
+
expect(notes[1].title).toBe("Beta");
|
|
243
|
+
expect(notes[2].title).toBe("Alpha");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should filter by folder", async () => {
|
|
247
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
248
|
+
|
|
249
|
+
const notes = await listNotes({ folder: "Work" });
|
|
250
|
+
expect(notes).toHaveLength(2);
|
|
251
|
+
expect(notes.every(n => n.folder === "Work")).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should apply limit", async () => {
|
|
255
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
256
|
+
|
|
257
|
+
const notes = await listNotes({ limit: 2 });
|
|
258
|
+
expect(notes).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should combine folder filter and limit", async () => {
|
|
262
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
263
|
+
|
|
264
|
+
const notes = await listNotes({ folder: "Work", limit: 1 });
|
|
265
|
+
expect(notes).toHaveLength(1);
|
|
266
|
+
expect(notes[0].folder).toBe("Work");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should return empty array when folder has no notes", async () => {
|
|
270
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
271
|
+
|
|
272
|
+
const notes = await listNotes({ folder: "NonExistent" });
|
|
273
|
+
expect(notes).toHaveLength(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should handle empty notes array", async () => {
|
|
277
|
+
vi.mocked(runJxa).mockResolvedValueOnce("[]");
|
|
278
|
+
|
|
279
|
+
const notes = await listNotes();
|
|
280
|
+
expect(notes).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should handle notes with empty date strings without crashing", async () => {
|
|
284
|
+
const notesWithEmptyDates = [
|
|
285
|
+
{ title: "Valid", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-10T00:00:00Z" },
|
|
286
|
+
{ title: "Empty", folder: "Work", created: "", modified: "" },
|
|
287
|
+
];
|
|
288
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(notesWithEmptyDates));
|
|
289
|
+
|
|
290
|
+
const notes = await listNotes();
|
|
291
|
+
expect(notes).toHaveLength(2);
|
|
292
|
+
expect(notes[0].title).toBe("Valid");
|
|
293
|
+
expect(notes[1].title).toBe("Empty");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should sort notes with empty dates to the end (oldest)", async () => {
|
|
297
|
+
const notesWithEmptyDates = [
|
|
298
|
+
{ title: "Empty", folder: "Work", created: "", modified: "" },
|
|
299
|
+
{ title: "Valid", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-10T00:00:00Z" },
|
|
300
|
+
];
|
|
301
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(notesWithEmptyDates));
|
|
302
|
+
|
|
303
|
+
const notes = await listNotes({ sort_by: "modified", order: "desc" });
|
|
304
|
+
expect(notes).toHaveLength(2);
|
|
305
|
+
// Valid note should come first (most recent)
|
|
306
|
+
expect(notes[0].title).toBe("Valid");
|
|
307
|
+
// Empty date should be last (treated as oldest)
|
|
308
|
+
expect(notes[1].title).toBe("Empty");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should handle mixing valid and empty dates correctly", async () => {
|
|
312
|
+
const mixedDates = [
|
|
313
|
+
{ title: "Recent", folder: "Work", created: "2024-03-01T00:00:00Z", modified: "2024-03-15T00:00:00Z" },
|
|
314
|
+
{ title: "Empty1", folder: "Work", created: "", modified: "" },
|
|
315
|
+
{ title: "Old", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-10T00:00:00Z" },
|
|
316
|
+
{ title: "Empty2", folder: "Work", created: "", modified: "" },
|
|
317
|
+
{ title: "Middle", folder: "Work", created: "2024-02-01T00:00:00Z", modified: "2024-02-15T00:00:00Z" },
|
|
318
|
+
];
|
|
319
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mixedDates));
|
|
320
|
+
|
|
321
|
+
const notes = await listNotes({ sort_by: "modified", order: "desc" });
|
|
322
|
+
expect(notes).toHaveLength(5);
|
|
323
|
+
// Order should be: Recent, Middle, Old, Empty1, Empty2
|
|
324
|
+
expect(notes[0].title).toBe("Recent");
|
|
325
|
+
expect(notes[1].title).toBe("Middle");
|
|
326
|
+
expect(notes[2].title).toBe("Old");
|
|
327
|
+
// Empty dates at the end (treated as epoch)
|
|
328
|
+
expect(notes[3].title).toBe("Empty1");
|
|
329
|
+
expect(notes[4].title).toBe("Empty2");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("should sort empty dates to the beginning when sorting ascending", async () => {
|
|
333
|
+
const mixedDates = [
|
|
334
|
+
{ title: "Recent", folder: "Work", created: "2024-03-01T00:00:00Z", modified: "2024-03-15T00:00:00Z" },
|
|
335
|
+
{ title: "Empty", folder: "Work", created: "", modified: "" },
|
|
336
|
+
{ title: "Old", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-10T00:00:00Z" },
|
|
337
|
+
];
|
|
338
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mixedDates));
|
|
339
|
+
|
|
340
|
+
const notes = await listNotes({ sort_by: "modified", order: "asc" });
|
|
341
|
+
expect(notes).toHaveLength(3);
|
|
342
|
+
// Empty date should be first (oldest when ascending)
|
|
343
|
+
expect(notes[0].title).toBe("Empty");
|
|
344
|
+
expect(notes[1].title).toBe("Old");
|
|
345
|
+
expect(notes[2].title).toBe("Recent");
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/notes/read.ts
CHANGED
|
@@ -434,3 +434,52 @@ export async function getAllFolders(): Promise<string[]> {
|
|
|
434
434
|
debug(`Found ${folders.length} folders`);
|
|
435
435
|
return folders;
|
|
436
436
|
}
|
|
437
|
+
|
|
438
|
+
// -----------------------------------------------------------------------------
|
|
439
|
+
// List Notes with Sorting and Filtering
|
|
440
|
+
// -----------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
// Re-export ListNotesOptions from index.ts (derived from Zod schema - single source of truth)
|
|
443
|
+
export type { ListNotesOptions } from "../index.js";
|
|
444
|
+
|
|
445
|
+
// Import the type for internal use
|
|
446
|
+
import type { ListNotesOptions } from "../index.js";
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* List notes with sorting and filtering.
|
|
450
|
+
*
|
|
451
|
+
* @param options - Sorting and filtering options
|
|
452
|
+
* @returns Array of note metadata sorted and filtered as specified
|
|
453
|
+
*/
|
|
454
|
+
export async function listNotes(options: ListNotesOptions = {}): Promise<NoteInfo[]> {
|
|
455
|
+
const { sort_by = "modified", order = "desc", limit, folder } = options;
|
|
456
|
+
|
|
457
|
+
debug(`Listing notes: sort_by=${sort_by}, order=${order}, limit=${limit}, folder=${folder}`);
|
|
458
|
+
|
|
459
|
+
const allNotes = await getAllNotes();
|
|
460
|
+
|
|
461
|
+
// Filter by folder (case-insensitive for better UX)
|
|
462
|
+
const filtered = folder
|
|
463
|
+
? allNotes.filter((n) => n.folder.toLowerCase() === folder.toLowerCase())
|
|
464
|
+
: allNotes;
|
|
465
|
+
|
|
466
|
+
filtered.sort((a, b) => {
|
|
467
|
+
let comparison: number;
|
|
468
|
+
|
|
469
|
+
if (sort_by === "title") {
|
|
470
|
+
comparison = a.title.localeCompare(b.title);
|
|
471
|
+
} else {
|
|
472
|
+
// Handle empty dates by treating them as epoch (0)
|
|
473
|
+
const aTime = a[sort_by] ? new Date(a[sort_by]).getTime() : 0;
|
|
474
|
+
const bTime = b[sort_by] ? new Date(b[sort_by]).getTime() : 0;
|
|
475
|
+
comparison = aTime - bTime;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return order === "desc" ? -comparison : comparison;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const result = limit ? filtered.slice(0, limit) : filtered;
|
|
482
|
+
|
|
483
|
+
debug(`Returning ${result.length} notes`);
|
|
484
|
+
return result;
|
|
485
|
+
}
|