@disco_trooper/apple-notes-mcp 1.4.0 → 1.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 CHANGED
@@ -113,7 +113,19 @@ include_content: false # include full content vs preview
113
113
  ```
114
114
 
115
115
  #### `list-notes`
116
- Count indexed notes.
116
+ List notes with sorting and filtering. Without parameters, shows index statistics.
117
+
118
+ ```
119
+ sort_by: "modified" # created, modified, or title (default: modified)
120
+ order: "desc" # asc or desc (default: desc)
121
+ limit: 10 # max notes to return (1-100)
122
+ folder: "Work" # filter by folder (case-insensitive)
123
+ ```
124
+
125
+ **Examples:**
126
+ - Get 5 newest notes: `{ sort_by: "created", order: "desc", limit: 5 }`
127
+ - Recently modified: `{ sort_by: "modified", limit: 10 }`
128
+ - Alphabetical in folder: `{ sort_by: "title", order: "asc", folder: "Projects" }`
117
129
 
118
130
  #### `list-folders`
119
131
  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.0",
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
+ }