@disco_trooper/apple-notes-mcp 1.0.1 → 1.2.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/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "@disco_trooper/apple-notes-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.2.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",
7
- "module": "src/server.ts",
8
7
  "scripts": {
9
8
  "start": "bun run src/index.ts",
10
9
  "setup": "bun run src/setup.ts",
@@ -25,7 +24,6 @@
25
24
  "dotenv": "^16.4.0"
26
25
  },
27
26
  "devDependencies": {
28
- "@smithery/cli": "^3.0.3",
29
27
  "@types/bun": "^1.1.0",
30
28
  "@types/turndown": "^5.0.0",
31
29
  "typescript": "^5.7.0",
@@ -45,8 +43,7 @@
45
43
  },
46
44
  "files": [
47
45
  "src",
48
- "README.md",
49
- "CLAUDE.md"
46
+ "README.md"
50
47
  ],
51
48
  "engines": {
52
49
  "node": ">=18"
@@ -20,6 +20,7 @@ describe("LanceDBStore", () => {
20
20
  });
21
21
 
22
22
  const createTestRecord = (title: string): NoteRecord => ({
23
+ id: `test-id-${title.toLowerCase().replace(/\s+/g, "-")}`,
23
24
  title,
24
25
  folder: "Test",
25
26
  content: `Content of ${title}`,
@@ -138,4 +139,48 @@ describe("LanceDBStore", () => {
138
139
  expect(results[0]).toHaveProperty("score");
139
140
  });
140
141
  });
142
+
143
+ describe("rebuildFtsIndex", () => {
144
+ it("rebuilds FTS index without error", async () => {
145
+ await store.index([
146
+ createTestRecord("FTS Note 1"),
147
+ createTestRecord("FTS Note 2"),
148
+ ]);
149
+
150
+ await expect(store.rebuildFtsIndex()).resolves.not.toThrow();
151
+ });
152
+
153
+ it("works after indexing records", async () => {
154
+ await store.index([createTestRecord("Note A")]);
155
+
156
+ // Rebuild should work on existing table
157
+ await expect(store.rebuildFtsIndex()).resolves.not.toThrow();
158
+
159
+ // Index more records and rebuild again
160
+ await store.index([createTestRecord("Note B")]);
161
+ await expect(store.rebuildFtsIndex()).resolves.not.toThrow();
162
+ });
163
+ });
164
+
165
+ describe("searchFTS", () => {
166
+ it("returns results matching query text", async () => {
167
+ await store.index([
168
+ createTestRecord("Meeting notes"),
169
+ createTestRecord("Shopping list"),
170
+ ]);
171
+ await store.rebuildFtsIndex();
172
+
173
+ const results = await store.searchFTS("Meeting", 10);
174
+ expect(results.length).toBeGreaterThanOrEqual(1);
175
+ expect(results[0].title).toBe("Meeting notes");
176
+ });
177
+
178
+ it("returns empty array for no matches", async () => {
179
+ await store.index([createTestRecord("Test note")]);
180
+ await store.rebuildFtsIndex();
181
+
182
+ const results = await store.searchFTS("nonexistentquery12345", 10);
183
+ expect(results).toHaveLength(0);
184
+ });
185
+ });
141
186
  });
package/src/db/lancedb.ts CHANGED
@@ -7,6 +7,7 @@ import { createDebugLogger } from "../utils/debug.js";
7
7
 
8
8
  // Schema for stored notes
9
9
  export interface NoteRecord {
10
+ id: string; // Apple Notes unique identifier
10
11
  title: string;
11
12
  content: string;
12
13
  vector: number[];
@@ -32,11 +33,26 @@ export interface VectorStore {
32
33
  getAll(): Promise<NoteRecord[]>;
33
34
  count(): Promise<number>;
34
35
  clear(): Promise<void>;
36
+ rebuildFtsIndex(): Promise<void>;
35
37
  }
36
38
 
37
39
  // Debug logging
38
40
  const debug = createDebugLogger("DB");
39
41
 
42
+ /**
43
+ * Convert a database row to a SearchResult with rank-based score.
44
+ */
45
+ function rowToSearchResult(row: Record<string, unknown>, index: number): SearchResult {
46
+ return {
47
+ id: row.id as string | undefined,
48
+ title: row.title as string,
49
+ folder: row.folder as string,
50
+ content: row.content as string,
51
+ modified: row.modified as string,
52
+ score: 1 / (1 + index),
53
+ };
54
+ }
55
+
40
56
  // LanceDB implementation
41
57
  export class LanceDBStore implements VectorStore {
42
58
  private db: lancedb.Connection | null = null;
@@ -83,8 +99,8 @@ export class LanceDBStore implements VectorStore {
83
99
  try {
84
100
  await db.dropTable(this.tableName);
85
101
  debug(`Dropped existing table: ${this.tableName}`);
86
- } catch {
87
- // Table didn't exist, that's fine
102
+ } catch (error) {
103
+ debug("Table drop skipped (table may not exist):", error);
88
104
  }
89
105
 
90
106
  // Create new table with records
@@ -106,13 +122,8 @@ export class LanceDBStore implements VectorStore {
106
122
 
107
123
  // Add new record first (LanceDB allows duplicates with same title)
108
124
  // This ensures we never lose data - if add fails, old record still exists
109
- try {
110
- await table.add([record]);
111
- debug(`Added new version of record: ${record.title}`);
112
- } catch (addError) {
113
- // If add fails, old record still exists, throw original error
114
- throw addError;
115
- }
125
+ await table.add([record]);
126
+ debug(`Added new version of record: ${record.title}`);
116
127
 
117
128
  // Now delete old record(s) - use indexed_at to identify which is old
118
129
  const validTitle = validateTitle(record.title);
@@ -165,35 +176,21 @@ export class LanceDBStore implements VectorStore {
165
176
  .limit(limit)
166
177
  .toArray();
167
178
 
168
- return results.map((row, index) => ({
169
- title: row.title as string,
170
- folder: row.folder as string,
171
- content: row.content as string,
172
- modified: row.modified as string,
173
- score: 1 / (1 + index), // Simple rank-based score
174
- }));
179
+ return results.map(rowToSearchResult);
175
180
  }
176
181
 
177
182
  async searchFTS(query: string, limit: number): Promise<SearchResult[]> {
178
183
  const table = await this.ensureTable();
179
184
 
180
185
  try {
181
- // LanceDB FTS search - use queryType option
182
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
- const results = await (table as any)
184
- .search(query, { queryType: "fts" })
186
+ const results = await table
187
+ .query()
188
+ .fullTextSearch(query)
185
189
  .limit(limit)
186
190
  .toArray();
187
191
 
188
- return results.map((row: Record<string, unknown>, index: number) => ({
189
- title: row.title as string,
190
- folder: row.folder as string,
191
- content: row.content as string,
192
- modified: row.modified as string,
193
- score: 1 / (1 + index),
194
- }));
192
+ return results.map(rowToSearchResult);
195
193
  } catch (error) {
196
- // FTS might fail if no index or no matches
197
194
  debug("FTS search failed, returning empty results. Error:", error);
198
195
  return [];
199
196
  }
@@ -221,6 +218,7 @@ export class LanceDBStore implements VectorStore {
221
218
  const results = await table.query().toArray();
222
219
 
223
220
  return results.map((row) => ({
221
+ id: (row.id as string) ?? "",
224
222
  title: row.title as string,
225
223
  content: row.content as string,
226
224
  vector: row.vector as number[],
@@ -235,7 +233,8 @@ export class LanceDBStore implements VectorStore {
235
233
  try {
236
234
  const table = await this.ensureTable();
237
235
  return await table.countRows();
238
- } catch {
236
+ } catch (error) {
237
+ debug("Count failed (table may not exist):", error);
239
238
  return 0;
240
239
  }
241
240
  }
@@ -246,10 +245,20 @@ export class LanceDBStore implements VectorStore {
246
245
  await db.dropTable(this.tableName);
247
246
  this.table = null;
248
247
  debug("Cleared table");
249
- } catch {
250
- // Table didn't exist
248
+ } catch (error) {
249
+ debug("Clear skipped (table may not exist):", error);
251
250
  }
252
251
  }
252
+
253
+ async rebuildFtsIndex(): Promise<void> {
254
+ const table = await this.ensureTable();
255
+ debug("Rebuilding FTS index on content");
256
+ await table.createIndex("content", {
257
+ config: lancedb.Index.fts(),
258
+ replace: true,
259
+ });
260
+ debug("FTS index rebuilt");
261
+ }
253
262
  }
254
263
 
255
264
  // Singleton instance
@@ -41,10 +41,9 @@ export function detectProvider(): EmbeddingProvider {
41
41
 
42
42
  /**
43
43
  * Get the current embedding provider.
44
- * Call detectProvider() first to ensure detection has occurred.
45
44
  */
46
45
  export function getProvider(): EmbeddingProvider {
47
- return detectedProvider || detectProvider();
46
+ return detectedProvider ?? detectProvider();
48
47
  }
49
48
 
50
49
  /**
@@ -31,7 +31,6 @@ type FeatureExtractionPipeline = (
31
31
 
32
32
  let pipelineInstance: FeatureExtractionPipeline | null = null;
33
33
  let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
34
- let resolvedModel: string | null = null;
35
34
 
36
35
  /**
37
36
  * Get the configured model name.
@@ -82,8 +81,6 @@ async function getPipeline(): Promise<FeatureExtractionPipeline> {
82
81
  debug(`Model loaded in ${loadTime}ms`);
83
82
 
84
83
  pipelineInstance = pipe;
85
- resolvedModel = modelName;
86
-
87
84
  return pipe;
88
85
  } catch (error) {
89
86
  // Reset promise so next call retries
@@ -181,11 +178,3 @@ export function getLocalModelName(): string {
181
178
  export function isModelLoaded(): boolean {
182
179
  return pipelineInstance !== null;
183
180
  }
184
-
185
- /**
186
- * Get the name of the actually loaded model.
187
- * Returns null if no model has been loaded yet.
188
- */
189
- export function getLoadedModelName(): string | null {
190
- return resolvedModel;
191
- }
@@ -0,0 +1,64 @@
1
+ // src/errors/index.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import {
4
+ NoteNotFoundError,
5
+ ReadOnlyModeError,
6
+ DuplicateNoteError,
7
+ FolderNotFoundError,
8
+ TableOutOfBoundsError,
9
+ } from "./index.js";
10
+
11
+ describe("Error Classes", () => {
12
+ describe("NoteNotFoundError", () => {
13
+ it("should have correct name and message", () => {
14
+ const error = new NoteNotFoundError("My Note");
15
+ expect(error.name).toBe("NoteNotFoundError");
16
+ expect(error.message).toBe('Note not found: "My Note"');
17
+ expect(error.title).toBe("My Note");
18
+ expect(error instanceof Error).toBe(true);
19
+ });
20
+ });
21
+
22
+ describe("ReadOnlyModeError", () => {
23
+ it("should have correct name and message", () => {
24
+ const error = new ReadOnlyModeError();
25
+ expect(error.name).toBe("ReadOnlyModeError");
26
+ expect(error.message).toBe("Operation disabled in read-only mode");
27
+ expect(error instanceof Error).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe("DuplicateNoteError", () => {
32
+ it("should have correct name and suggestions", () => {
33
+ const suggestions = [
34
+ { id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
35
+ { id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
36
+ ];
37
+ const error = new DuplicateNoteError("Note", suggestions);
38
+ expect(error.name).toBe("DuplicateNoteError");
39
+ expect(error.title).toBe("Note");
40
+ expect(error.suggestions).toEqual(suggestions);
41
+ expect(error.message).toContain("Multiple notes found");
42
+ expect(error.message).toContain("id:id-1");
43
+ expect(error.message).toContain("id:id-2");
44
+ });
45
+ });
46
+
47
+ describe("FolderNotFoundError", () => {
48
+ it("should have correct name and message", () => {
49
+ const error = new FolderNotFoundError("Work");
50
+ expect(error.name).toBe("FolderNotFoundError");
51
+ expect(error.folder).toBe("Work");
52
+ expect(error.message).toBe('Folder not found: "Work"');
53
+ });
54
+ });
55
+
56
+ describe("TableOutOfBoundsError", () => {
57
+ it("should have correct name and message", () => {
58
+ const error = new TableOutOfBoundsError("Row 5 out of bounds (table has 3 rows)");
59
+ expect(error.name).toBe("TableOutOfBoundsError");
60
+ expect(error.message).toBe("Row 5 out of bounds (table has 3 rows)");
61
+ expect(error instanceof Error).toBe(true);
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Typed error classes for better error handling.
3
+ */
4
+
5
+ export class NoteNotFoundError extends Error {
6
+ readonly title: string;
7
+
8
+ constructor(title: string) {
9
+ super(`Note not found: "${title}"`);
10
+ this.name = "NoteNotFoundError";
11
+ this.title = title;
12
+ }
13
+ }
14
+
15
+ export class ReadOnlyModeError extends Error {
16
+ constructor() {
17
+ super("Operation disabled in read-only mode");
18
+ this.name = "ReadOnlyModeError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Rich suggestion for duplicate note disambiguation.
24
+ */
25
+ export interface NoteSuggestion {
26
+ id: string;
27
+ folder: string;
28
+ title: string;
29
+ created: string;
30
+ }
31
+
32
+ export class DuplicateNoteError extends Error {
33
+ readonly title: string;
34
+ readonly suggestions: NoteSuggestion[];
35
+
36
+ constructor(title: string, suggestions: NoteSuggestion[]) {
37
+ const suggestionList = suggestions
38
+ .map(s => `id:${s.id} (${s.folder}, created: ${s.created.split("T")[0]})`)
39
+ .join("\n - ");
40
+ super(`Multiple notes found with title "${title}". Use ID prefix:\n - ${suggestionList}`);
41
+ this.name = "DuplicateNoteError";
42
+ this.title = title;
43
+ this.suggestions = suggestions;
44
+ }
45
+ }
46
+
47
+ export class FolderNotFoundError extends Error {
48
+ readonly folder: string;
49
+
50
+ constructor(folder: string) {
51
+ super(`Folder not found: "${folder}"`);
52
+ this.name = "FolderNotFoundError";
53
+ this.folder = folder;
54
+ }
55
+ }
56
+
57
+ export class TableOutOfBoundsError extends Error {
58
+ constructor(message: string) {
59
+ super(message);
60
+ this.name = "TableOutOfBoundsError";
61
+ }
62
+ }
package/src/index.ts CHANGED
@@ -8,13 +8,18 @@ import { z } from "zod";
8
8
  import "dotenv/config";
9
9
 
10
10
  // Import constants
11
- import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "./config/constants.js";
11
+ import {
12
+ DEFAULT_SEARCH_LIMIT,
13
+ MAX_SEARCH_LIMIT,
14
+ MAX_INPUT_LENGTH,
15
+ MAX_TITLE_LENGTH
16
+ } from "./config/constants.js";
12
17
  import { validateEnv } from "./config/env.js";
13
18
 
14
19
  // Import implementations
15
20
  import { getVectorStore } from "./db/lancedb.js";
16
21
  import { getNoteByTitle, getAllFolders } from "./notes/read.js";
17
- import { createNote, updateNote, deleteNote, moveNote } from "./notes/crud.js";
22
+ import { createNote, updateNote, deleteNote, moveNote, editTable } from "./notes/crud.js";
18
23
  import { searchNotes } from "./search/index.js";
19
24
  import { indexNotes, reindexNote } from "./search/indexer.js";
20
25
 
@@ -25,8 +30,8 @@ const debug = createDebugLogger("MCP");
25
30
 
26
31
  // Tool parameter schemas
27
32
  const SearchNotesSchema = z.object({
28
- query: z.string().min(1, "Query cannot be empty"),
29
- folder: z.string().optional(),
33
+ query: z.string().min(1, "Query cannot be empty").max(MAX_INPUT_LENGTH),
34
+ folder: z.string().max(200).optional(),
30
35
  limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
31
36
  mode: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
32
37
  include_content: z.boolean().default(false),
@@ -38,33 +43,43 @@ const IndexNotesSchema = z.object({
38
43
  });
39
44
 
40
45
  const ReindexNoteSchema = z.object({
41
- title: z.string(),
46
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
42
47
  });
43
48
 
44
49
  const GetNoteSchema = z.object({
45
- title: z.string(),
50
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
46
51
  });
47
52
 
48
53
  const CreateNoteSchema = z.object({
49
- title: z.string(),
50
- content: z.string(),
51
- folder: z.string().optional(),
54
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
55
+ content: z.string().min(1).max(MAX_INPUT_LENGTH),
56
+ folder: z.string().max(200).optional(),
52
57
  });
53
58
 
54
59
  const UpdateNoteSchema = z.object({
55
- title: z.string(),
56
- content: z.string(),
60
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
61
+ content: z.string().min(1).max(MAX_INPUT_LENGTH),
57
62
  reindex: z.boolean().default(true),
58
63
  });
59
64
 
60
65
  const DeleteNoteSchema = z.object({
61
- title: z.string(),
66
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
62
67
  confirm: z.boolean(),
63
68
  });
64
69
 
65
70
  const MoveNoteSchema = z.object({
66
- title: z.string(),
67
- folder: z.string(),
71
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
72
+ folder: z.string().min(1).max(200),
73
+ });
74
+
75
+ const EditTableSchema = z.object({
76
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
77
+ table_index: z.number().min(0).default(0),
78
+ edits: z.array(z.object({
79
+ row: z.number().min(0),
80
+ column: z.number().min(0),
81
+ value: z.string().max(10000),
82
+ })).min(1).max(100),
68
83
  });
69
84
 
70
85
  // Create MCP server
@@ -231,6 +246,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
231
246
  required: ["title", "folder"],
232
247
  },
233
248
  },
249
+ {
250
+ name: "edit-table",
251
+ description: "Edit cells in a table within a note. Use for updating table data without rewriting the entire note.",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
256
+ table_index: { type: "number", description: "Which table to edit (0 = first table, default: 0)" },
257
+ edits: {
258
+ type: "array",
259
+ description: "Array of cell edits",
260
+ items: {
261
+ type: "object",
262
+ properties: {
263
+ row: { type: "number", description: "Row index (0 = header row)" },
264
+ column: { type: "number", description: "Column index (0 = first column)" },
265
+ value: { type: "string", description: "New cell value" },
266
+ },
267
+ required: ["row", "column", "value"],
268
+ },
269
+ },
270
+ },
271
+ required: ["title", "edits"],
272
+ },
273
+ },
234
274
  ],
235
275
  };
236
276
  });
@@ -353,6 +393,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
353
393
  return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
354
394
  }
355
395
 
396
+ case "edit-table": {
397
+ const params = EditTableSchema.parse(args);
398
+ await editTable(params.title, params.table_index, params.edits);
399
+ return textResponse(`Updated ${params.edits.length} cell(s) in table ${params.table_index}`);
400
+ }
401
+
356
402
  default:
357
403
  return errorResponse(`Unknown tool: ${name}`);
358
404
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * HTML to Markdown conversion for Apple Notes content.
3
+ */
4
+
5
+ import TurndownService from "turndown";
6
+
7
+ // Initialize Turndown for HTML to Markdown conversion
8
+ const turndownService = new TurndownService({
9
+ headingStyle: "atx",
10
+ codeBlockStyle: "fenced",
11
+ bulletListMarker: "-",
12
+ });
13
+
14
+ // Add custom rule to handle Apple Notes attachment placeholders
15
+ turndownService.addRule("attachments", {
16
+ filter: (node) => {
17
+ // Apple Notes uses object tags for attachments
18
+ return node.nodeName === "OBJECT" || node.nodeName === "IMG";
19
+ },
20
+ replacement: (_content, node) => {
21
+ // Turndown uses its own Node type, cast to access attributes
22
+ const element = node as unknown as {
23
+ getAttribute: (name: string) => string | null;
24
+ };
25
+ const filename =
26
+ element.getAttribute("data-filename") ||
27
+ element.getAttribute("alt") ||
28
+ element.getAttribute("src")?.split("/").pop() ||
29
+ "unknown";
30
+ return `[Attachment: ${filename}]`;
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Convert HTML content to Markdown, handling Apple Notes specifics.
36
+ */
37
+ export function htmlToMarkdown(html: string): string {
38
+ if (!html) return "";
39
+
40
+ // Pre-process: handle Apple Notes specific markup
41
+ let processed = html;
42
+
43
+ // Replace attachment objects with placeholder text before Turndown
44
+ processed = processed.replace(
45
+ /<object[^>]*data-filename="([^"]*)"[^>]*>.*?<\/object>/gi,
46
+ "[Attachment: $1]"
47
+ );
48
+
49
+ // Handle inline images
50
+ processed = processed.replace(
51
+ /<img[^>]*(?:alt="([^"]*)")?[^>]*>/gi,
52
+ (_match, alt) => {
53
+ const filename = alt || "image";
54
+ return `[Attachment: ${filename}]`;
55
+ }
56
+ );
57
+
58
+ // Convert to Markdown
59
+ const markdown = turndownService.turndown(processed);
60
+
61
+ return markdown.trim();
62
+ }