@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 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 (NEW!)
14
- - **Query Caching** - 60x faster repeated searches (NEW!)
15
- - **Knowledge Graph** - Tags, links, and related notes discovery (NEW!)
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.4
22
+ ## What's New in 1.5
23
23
 
24
- - **Smart Refresh** - Search auto-reindexes changed notes. No manual `index-notes` needed.
25
- - **Batch Operations** - Delete or move multiple notes by title or folder.
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
- Count indexed notes.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@disco_trooper/apple-notes-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "MCP server for Apple Notes with semantic search and CRUD operations",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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: "Count how many notes are indexed",
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 store = getVectorStore();
585
- const noteCount = await store.count();
586
-
587
- let message = `${noteCount} notes indexed.`;
588
-
589
- // Show chunk statistics if chunk index exists
590
- const hasChunks = await hasChunkIndex();
591
- if (hasChunks) {
592
- const chunkStore = getChunkStore();
593
- const chunkCount = await chunkStore.count();
594
- message += ` ${chunkCount} chunks indexed for semantic search.`;
595
- } else {
596
- message += " Run index-notes with mode='full' to enable chunk-based search.";
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
- return textResponse(message);
640
+ const notes = await listNotes(params);
641
+ return textResponse(JSON.stringify(notes, null, 2));
600
642
  }
601
643
 
602
644
  case "get-note": {
@@ -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
+ }