@disco_trooper/apple-notes-mcp 1.0.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/src/server.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Smithery-compatible server export.
3
+ * This file exports a createServer function for Smithery deployment.
4
+ */
5
+
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+ import { z } from "zod";
12
+ import "dotenv/config";
13
+
14
+ // Import constants
15
+ import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "./config/constants.js";
16
+ import { validateEnv } from "./config/env.js";
17
+
18
+ // Import implementations
19
+ import { getVectorStore } from "./db/lancedb.js";
20
+ import { getNoteByTitle, getAllFolders } from "./notes/read.js";
21
+ import { createNote, updateNote, deleteNote, moveNote } from "./notes/crud.js";
22
+ import { searchNotes } from "./search/index.js";
23
+ import { indexNotes, reindexNote } from "./search/indexer.js";
24
+
25
+ // Debug logging and error handling
26
+ import { createDebugLogger } from "./utils/debug.js";
27
+ import { sanitizeErrorMessage } from "./utils/errors.js";
28
+
29
+ const debug = createDebugLogger("MCP");
30
+
31
+ // Tool parameter schemas
32
+ const SearchNotesSchema = z.object({
33
+ query: z.string().min(1, "Query cannot be empty"),
34
+ folder: z.string().optional(),
35
+ limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
36
+ mode: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
37
+ include_content: z.boolean().default(false),
38
+ });
39
+
40
+ const IndexNotesSchema = z.object({
41
+ mode: z.enum(["full", "incremental"]).default("incremental"),
42
+ force: z.boolean().default(false),
43
+ });
44
+
45
+ const ReindexNoteSchema = z.object({
46
+ title: z.string(),
47
+ });
48
+
49
+ const GetNoteSchema = z.object({
50
+ title: z.string(),
51
+ });
52
+
53
+ const CreateNoteSchema = z.object({
54
+ title: z.string(),
55
+ content: z.string(),
56
+ folder: z.string().optional(),
57
+ });
58
+
59
+ const UpdateNoteSchema = z.object({
60
+ title: z.string(),
61
+ content: z.string(),
62
+ reindex: z.boolean().default(true),
63
+ });
64
+
65
+ const DeleteNoteSchema = z.object({
66
+ title: z.string(),
67
+ confirm: z.boolean(),
68
+ });
69
+
70
+ const MoveNoteSchema = z.object({
71
+ title: z.string(),
72
+ folder: z.string(),
73
+ });
74
+
75
+ // Helper to create text responses
76
+ function textResponse(text: string) {
77
+ return {
78
+ content: [{ type: "text" as const, text }],
79
+ };
80
+ }
81
+
82
+ function errorResponse(message: string) {
83
+ return {
84
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
85
+ isError: true,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Create and configure the MCP server.
91
+ * Exported for Smithery compatibility.
92
+ */
93
+ export default function createServer() {
94
+ // Validate environment variables
95
+ validateEnv();
96
+
97
+ debug("Creating apple-notes-mcp server...");
98
+
99
+ const server = new Server(
100
+ {
101
+ name: "apple-notes-mcp",
102
+ version: "1.0.0",
103
+ },
104
+ {
105
+ capabilities: {
106
+ tools: {},
107
+ },
108
+ }
109
+ );
110
+
111
+ // Register tool list
112
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
113
+ return {
114
+ tools: [
115
+ // Read tools
116
+ {
117
+ name: "search-notes",
118
+ description: "Search notes using hybrid vector + fulltext search",
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ query: { type: "string", description: "Search query" },
123
+ folder: { type: "string", description: "Filter by folder (optional)" },
124
+ limit: { type: "number", description: "Max results (default: 20)" },
125
+ mode: {
126
+ type: "string",
127
+ enum: ["hybrid", "keyword", "semantic"],
128
+ description: "Search mode (default: hybrid)"
129
+ },
130
+ include_content: {
131
+ type: "boolean",
132
+ description: "Include full content instead of preview (default: false)"
133
+ },
134
+ },
135
+ required: ["query"],
136
+ },
137
+ },
138
+ {
139
+ name: "index-notes",
140
+ description: "Index all notes for semantic search. Use mode='incremental' (default) to only process changed notes.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ mode: {
145
+ type: "string",
146
+ enum: ["full", "incremental"],
147
+ description: "full = reindex everything, incremental = only changes (default)"
148
+ },
149
+ force: {
150
+ type: "boolean",
151
+ description: "Force reindex even if TTL hasn't expired (default: false)"
152
+ },
153
+ },
154
+ required: [],
155
+ },
156
+ },
157
+ {
158
+ name: "reindex-note",
159
+ description: "Re-index a single note after manual edits",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
164
+ },
165
+ required: ["title"],
166
+ },
167
+ },
168
+ {
169
+ name: "list-notes",
170
+ description: "Count how many notes are indexed",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {},
174
+ required: [],
175
+ },
176
+ },
177
+ {
178
+ name: "get-note",
179
+ description: "Get full content of a note by title",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
184
+ },
185
+ required: ["title"],
186
+ },
187
+ },
188
+ {
189
+ name: "list-folders",
190
+ description: "List all folders in Apple Notes",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {},
194
+ required: [],
195
+ },
196
+ },
197
+ // Write tools
198
+ {
199
+ name: "create-note",
200
+ description: "Create a new note in Apple Notes",
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ title: { type: "string", description: "Note title" },
205
+ content: { type: "string", description: "Note content (Markdown)" },
206
+ folder: { type: "string", description: "Target folder (optional, defaults to Notes)" },
207
+ },
208
+ required: ["title", "content"],
209
+ },
210
+ },
211
+ {
212
+ name: "update-note",
213
+ description: "Update an existing note",
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
218
+ content: { type: "string", description: "New content (Markdown)" },
219
+ reindex: { type: "boolean", description: "Re-embed after update (default: true)" },
220
+ },
221
+ required: ["title", "content"],
222
+ },
223
+ },
224
+ {
225
+ name: "delete-note",
226
+ description: "Delete a note (requires confirm: true for safety)",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
231
+ confirm: { type: "boolean", description: "Must be true to confirm deletion" },
232
+ },
233
+ required: ["title", "confirm"],
234
+ },
235
+ },
236
+ {
237
+ name: "move-note",
238
+ description: "Move a note to a different folder",
239
+ inputSchema: {
240
+ type: "object",
241
+ properties: {
242
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
243
+ folder: { type: "string", description: "Target folder" },
244
+ },
245
+ required: ["title", "folder"],
246
+ },
247
+ },
248
+ ],
249
+ };
250
+ });
251
+
252
+ // Handle tool calls
253
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
254
+ const { name, arguments: args } = request.params;
255
+ debug(`Tool called: ${name}`, args);
256
+
257
+ try {
258
+ switch (name) {
259
+ // Read tools
260
+ case "search-notes": {
261
+ const params = SearchNotesSchema.parse(args);
262
+ const results = await searchNotes(params.query, {
263
+ folder: params.folder,
264
+ limit: params.limit,
265
+ mode: params.mode,
266
+ include_content: params.include_content,
267
+ });
268
+
269
+ if (results.length === 0) {
270
+ return textResponse("No notes found matching your query.");
271
+ }
272
+
273
+ return textResponse(JSON.stringify(results, null, 2));
274
+ }
275
+
276
+ case "index-notes": {
277
+ const params = IndexNotesSchema.parse(args);
278
+ const result = await indexNotes(params.mode);
279
+
280
+ let message = `Indexed ${result.indexed} notes in ${(result.timeMs / 1000).toFixed(1)}s`;
281
+
282
+ if (result.breakdown) {
283
+ message += ` (added: ${result.breakdown.added}, updated: ${result.breakdown.updated}, deleted: ${result.breakdown.deleted}, skipped: ${result.breakdown.skipped})`;
284
+ }
285
+
286
+ if (result.errors > 0) {
287
+ message += `\n${result.errors} errors occurred.`;
288
+ if (result.failedNotes && result.failedNotes.length > 0) {
289
+ message += `\nFailed notes:\n${result.failedNotes.map(n => ` - ${n}`).join("\n")}`;
290
+ }
291
+ }
292
+
293
+ return textResponse(message);
294
+ }
295
+
296
+ case "reindex-note": {
297
+ const params = ReindexNoteSchema.parse(args);
298
+ await reindexNote(params.title);
299
+ return textResponse(`Reindexed note: "${params.title}"`);
300
+ }
301
+
302
+ case "list-notes": {
303
+ const store = getVectorStore();
304
+ const count = await store.count();
305
+ return textResponse(`${count} notes indexed. Run index-notes to update the index.`);
306
+ }
307
+
308
+ case "get-note": {
309
+ const params = GetNoteSchema.parse(args);
310
+ const note = await getNoteByTitle(params.title);
311
+
312
+ if (!note) {
313
+ return errorResponse(`Note not found: "${params.title}"`);
314
+ }
315
+
316
+ return textResponse(JSON.stringify({
317
+ title: note.title,
318
+ folder: note.folder,
319
+ content: note.content,
320
+ created: note.created,
321
+ modified: note.modified,
322
+ }, null, 2));
323
+ }
324
+
325
+ case "list-folders": {
326
+ const folders = await getAllFolders();
327
+ return textResponse(JSON.stringify(folders, null, 2));
328
+ }
329
+
330
+ // Write tools
331
+ case "create-note": {
332
+ const params = CreateNoteSchema.parse(args);
333
+ await createNote(params.title, params.content, params.folder);
334
+ const location = params.folder ? `${params.folder}/${params.title}` : params.title;
335
+ return textResponse(`Created note: "${location}"`);
336
+ }
337
+
338
+ case "update-note": {
339
+ const params = UpdateNoteSchema.parse(args);
340
+ await updateNote(params.title, params.content);
341
+
342
+ if (params.reindex) {
343
+ try {
344
+ await reindexNote(params.title);
345
+ return textResponse(`Updated and reindexed note: "${params.title}"`);
346
+ } catch (reindexError) {
347
+ debug("Reindex after update failed:", reindexError);
348
+ return textResponse(`Updated note: "${params.title}" (reindexing failed, run index-notes to update)`);
349
+ }
350
+ }
351
+
352
+ return textResponse(`Updated note: "${params.title}"`);
353
+ }
354
+
355
+ case "delete-note": {
356
+ const params = DeleteNoteSchema.parse(args);
357
+ if (!params.confirm) {
358
+ return errorResponse("Add confirm: true to delete the note");
359
+ }
360
+ await deleteNote(params.title);
361
+ return textResponse(`Deleted note: "${params.title}"`);
362
+ }
363
+
364
+ case "move-note": {
365
+ const params = MoveNoteSchema.parse(args);
366
+ await moveNote(params.title, params.folder);
367
+ return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
368
+ }
369
+
370
+ default:
371
+ return errorResponse(`Unknown tool: ${name}`);
372
+ }
373
+ } catch (error) {
374
+ if (error instanceof z.ZodError) {
375
+ const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
376
+ return errorResponse(`Invalid arguments: ${issues}`);
377
+ }
378
+
379
+ const rawMessage = error instanceof Error ? error.message : String(error);
380
+ debug("Tool error:", error);
381
+ return errorResponse(sanitizeErrorMessage(rawMessage));
382
+ }
383
+ });
384
+
385
+ return server;
386
+ }