@disco_trooper/apple-notes-mcp 1.1.0 → 1.3.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.
Files changed (49) hide show
  1. package/README.md +104 -24
  2. package/package.json +11 -12
  3. package/src/config/claude.test.ts +47 -0
  4. package/src/config/claude.ts +106 -0
  5. package/src/config/constants.ts +11 -2
  6. package/src/config/paths.test.ts +40 -0
  7. package/src/config/paths.ts +86 -0
  8. package/src/db/arrow-fix.test.ts +101 -0
  9. package/src/db/lancedb.test.ts +254 -2
  10. package/src/db/lancedb.ts +385 -38
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +22 -4
  14. package/src/embeddings/local.ts +57 -17
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/errors/index.test.ts +64 -0
  17. package/src/errors/index.ts +62 -0
  18. package/src/graph/export.test.ts +81 -0
  19. package/src/graph/export.ts +163 -0
  20. package/src/graph/extract.test.ts +90 -0
  21. package/src/graph/extract.ts +52 -0
  22. package/src/graph/queries.test.ts +156 -0
  23. package/src/graph/queries.ts +224 -0
  24. package/src/index.ts +309 -23
  25. package/src/notes/conversion.ts +62 -0
  26. package/src/notes/crud.test.ts +41 -8
  27. package/src/notes/crud.ts +75 -64
  28. package/src/notes/read.test.ts +58 -3
  29. package/src/notes/read.ts +142 -210
  30. package/src/notes/resolve.ts +174 -0
  31. package/src/notes/tables.ts +69 -40
  32. package/src/search/chunk-indexer.test.ts +353 -0
  33. package/src/search/chunk-indexer.ts +207 -0
  34. package/src/search/chunk-search.test.ts +327 -0
  35. package/src/search/chunk-search.ts +298 -0
  36. package/src/search/index.ts +4 -6
  37. package/src/search/indexer.ts +164 -109
  38. package/src/setup.ts +46 -67
  39. package/src/types/index.ts +4 -0
  40. package/src/utils/chunker.test.ts +182 -0
  41. package/src/utils/chunker.ts +170 -0
  42. package/src/utils/content-filter.test.ts +225 -0
  43. package/src/utils/content-filter.ts +275 -0
  44. package/src/utils/debug.ts +0 -2
  45. package/src/utils/runtime.test.ts +70 -0
  46. package/src/utils/runtime.ts +40 -0
  47. package/src/utils/text.test.ts +32 -0
  48. package/CLAUDE.md +0 -56
  49. package/src/server.ts +0 -427
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Knowledge graph query operations.
3
+ */
4
+
5
+ import { getVectorStore } from "../db/lancedb.js";
6
+ import { createDebugLogger } from "../utils/debug.js";
7
+ import { NoteNotFoundError } from "../errors/index.js";
8
+ import { generatePreview } from "../search/index.js";
9
+ import {
10
+ DEFAULT_SEARCH_LIMIT,
11
+ DEFAULT_RELATED_NOTES_LIMIT,
12
+ GRAPH_TAG_WEIGHT,
13
+ GRAPH_LINK_WEIGHT,
14
+ GRAPH_SIMILAR_WEIGHT,
15
+ } from "../config/constants.js";
16
+ import type { SearchResult } from "../types/index.js";
17
+
18
+ const debug = createDebugLogger("GRAPH");
19
+
20
+ export interface TagCount {
21
+ tag: string;
22
+ count: number;
23
+ }
24
+
25
+ /**
26
+ * List all tags with occurrence counts.
27
+ * Sorted by count descending.
28
+ */
29
+ export async function listTags(): Promise<TagCount[]> {
30
+ debug("Listing all tags");
31
+
32
+ const store = getVectorStore();
33
+ const records = await store.getAll();
34
+
35
+ // Aggregate tag counts
36
+ const counts = new Map<string, number>();
37
+ for (const record of records) {
38
+ for (const tag of record.tags ?? []) {
39
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
40
+ }
41
+ }
42
+
43
+ // Sort by count descending
44
+ const result = Array.from(counts.entries())
45
+ .map(([tag, count]) => ({ tag, count }))
46
+ .sort((a, b) => b.count - a.count);
47
+
48
+ debug(`Found ${result.length} unique tags`);
49
+ return result;
50
+ }
51
+
52
+ export interface SearchByTagOptions {
53
+ folder?: string;
54
+ limit?: number;
55
+ }
56
+
57
+ /**
58
+ * Find notes with a specific tag.
59
+ */
60
+ export async function searchByTag(
61
+ tag: string,
62
+ options: SearchByTagOptions = {}
63
+ ): Promise<SearchResult[]> {
64
+ const { folder, limit = DEFAULT_SEARCH_LIMIT } = options;
65
+
66
+ debug(`Searching for tag: ${tag}`);
67
+
68
+ const store = getVectorStore();
69
+ const records = await store.getAll();
70
+
71
+ // Filter by tag (case-insensitive)
72
+ let matches = records.filter((r) =>
73
+ (r.tags ?? []).includes(tag.toLowerCase())
74
+ );
75
+
76
+ // Filter by folder if specified
77
+ if (folder) {
78
+ const normalizedFolder = folder.toLowerCase();
79
+ matches = matches.filter(
80
+ (r) => r.folder.toLowerCase() === normalizedFolder
81
+ );
82
+ }
83
+
84
+ // Transform to SearchResult and limit
85
+ const results: SearchResult[] = matches.slice(0, limit).map((r, i) => ({
86
+ id: r.id,
87
+ title: r.title,
88
+ folder: r.folder,
89
+ preview: generatePreview(r.content),
90
+ modified: r.modified,
91
+ score: 1 / (1 + i),
92
+ }));
93
+
94
+ debug(`Found ${results.length} notes with tag: ${tag}`);
95
+ return results;
96
+ }
97
+
98
+ export type RelationshipType = "tag" | "link" | "similar";
99
+
100
+ export interface RelatedNote {
101
+ id: string;
102
+ title: string;
103
+ folder: string;
104
+ relationship: RelationshipType;
105
+ score: number;
106
+ sharedTags?: string[];
107
+ direction?: "outgoing" | "incoming";
108
+ }
109
+
110
+ export interface FindRelatedOptions {
111
+ types?: RelationshipType[];
112
+ limit?: number;
113
+ }
114
+
115
+ /**
116
+ * Find notes related to a source note by tags, links, or semantic similarity.
117
+ */
118
+ export async function findRelatedNotes(
119
+ sourceId: string,
120
+ options: FindRelatedOptions = {}
121
+ ): Promise<RelatedNote[]> {
122
+ const { types = ["tag", "link", "similar"], limit = DEFAULT_RELATED_NOTES_LIMIT } = options;
123
+
124
+ debug(`Finding related notes for: ${sourceId}`);
125
+
126
+ const store = getVectorStore();
127
+ const allRecords = await store.getAll();
128
+
129
+ // Find source note
130
+ const source = allRecords.find((r) => r.id === sourceId);
131
+ if (!source) {
132
+ throw new NoteNotFoundError(sourceId);
133
+ }
134
+
135
+ const results: RelatedNote[] = [];
136
+ const seen = new Set<string>();
137
+
138
+ // Find by shared tags
139
+ if (types.includes("tag") && source.tags?.length > 0) {
140
+ for (const record of allRecords) {
141
+ if (record.id === sourceId || seen.has(record.id)) continue;
142
+
143
+ const shared = (record.tags ?? []).filter((t) => source.tags.includes(t));
144
+ if (shared.length > 0) {
145
+ results.push({
146
+ id: record.id,
147
+ title: record.title,
148
+ folder: record.folder,
149
+ relationship: "tag",
150
+ score: GRAPH_TAG_WEIGHT * (shared.length / source.tags.length),
151
+ sharedTags: shared,
152
+ });
153
+ seen.add(record.id);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Find by outlinks (notes this note links to)
159
+ if (types.includes("link")) {
160
+ for (const linkTitle of source.outlinks ?? []) {
161
+ const linked = allRecords.find(
162
+ (r) =>
163
+ r.title.toLowerCase() === linkTitle.toLowerCase() &&
164
+ r.id !== sourceId &&
165
+ !seen.has(r.id)
166
+ );
167
+ if (linked) {
168
+ results.push({
169
+ id: linked.id,
170
+ title: linked.title,
171
+ folder: linked.folder,
172
+ relationship: "link",
173
+ score: GRAPH_LINK_WEIGHT,
174
+ direction: "outgoing",
175
+ });
176
+ seen.add(linked.id);
177
+ }
178
+ }
179
+
180
+ // Find backlinks (notes that link to this note)
181
+ for (const record of allRecords) {
182
+ if (record.id === sourceId || seen.has(record.id)) continue;
183
+
184
+ const linksToSource = (record.outlinks ?? []).some(
185
+ (l) => l.toLowerCase() === source.title.toLowerCase()
186
+ );
187
+ if (linksToSource) {
188
+ results.push({
189
+ id: record.id,
190
+ title: record.title,
191
+ folder: record.folder,
192
+ relationship: "link",
193
+ score: GRAPH_LINK_WEIGHT,
194
+ direction: "incoming",
195
+ });
196
+ seen.add(record.id);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Find by semantic similarity
202
+ if (types.includes("similar") && source.vector?.length > 0) {
203
+ const similarResults = await store.search(source.vector, limit + 1);
204
+
205
+ for (const similar of similarResults) {
206
+ if (similar.id === sourceId || seen.has(similar.id ?? "")) continue;
207
+
208
+ results.push({
209
+ id: similar.id ?? "",
210
+ title: similar.title,
211
+ folder: similar.folder,
212
+ relationship: "similar",
213
+ score: GRAPH_SIMILAR_WEIGHT * similar.score,
214
+ });
215
+ seen.add(similar.id ?? "");
216
+ }
217
+ }
218
+
219
+ // Sort by score and limit
220
+ results.sort((a, b) => b.score - a.score);
221
+
222
+ debug(`Found ${results.length} related notes`);
223
+ return results.slice(0, limit);
224
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { hasConfig, getEnvPath } from "./config/paths.js";
3
+ import { checkBunRuntime, isTTY } from "./utils/runtime.js";
4
+ import * as dotenv from "dotenv";
5
+
6
+ // Load config from unified location
7
+ dotenv.config({ path: getEnvPath() });
8
+
1
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
11
  import {
@@ -5,28 +13,68 @@ import {
5
13
  ListToolsRequestSchema,
6
14
  } from "@modelcontextprotocol/sdk/types.js";
7
15
  import { z } from "zod";
8
- import "dotenv/config";
9
16
 
10
17
  // Import constants
11
- import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "./config/constants.js";
18
+ import {
19
+ DEFAULT_SEARCH_LIMIT,
20
+ MAX_SEARCH_LIMIT,
21
+ MAX_INPUT_LENGTH,
22
+ MAX_TITLE_LENGTH
23
+ } from "./config/constants.js";
12
24
  import { validateEnv } from "./config/env.js";
13
25
 
14
26
  // Import implementations
15
- import { getVectorStore } from "./db/lancedb.js";
27
+ import { getVectorStore, getChunkStore } from "./db/lancedb.js";
16
28
  import { getNoteByTitle, getAllFolders } from "./notes/read.js";
17
- import { createNote, updateNote, deleteNote, moveNote } from "./notes/crud.js";
29
+ import { createNote, updateNote, deleteNote, moveNote, editTable } from "./notes/crud.js";
18
30
  import { searchNotes } from "./search/index.js";
19
31
  import { indexNotes, reindexNote } from "./search/indexer.js";
32
+ import { fullChunkIndex, hasChunkIndex } from "./search/chunk-indexer.js";
33
+ import { searchChunks } from "./search/chunk-search.js";
34
+ import { listTags, searchByTag, findRelatedNotes } from "./graph/queries.js";
35
+ import { exportGraph } from "./graph/export.js";
20
36
 
21
37
  // Debug logging and error handling
22
38
  import { createDebugLogger } from "./utils/debug.js";
23
39
  import { sanitizeErrorMessage } from "./utils/errors.js";
24
40
  const debug = createDebugLogger("MCP");
25
41
 
42
+ // Runtime and config checks
43
+ checkBunRuntime();
44
+
45
+ if (!hasConfig()) {
46
+ if (isTTY()) {
47
+ // Interactive terminal - run setup wizard
48
+ console.log("No configuration found. Starting setup wizard...\n");
49
+ const { spawn } = await import("node:child_process");
50
+ const setupPath = new URL("./setup.ts", import.meta.url).pathname;
51
+ const child = spawn("bun", ["run", setupPath], {
52
+ stdio: "inherit",
53
+ });
54
+ child.on("exit", (code) => process.exit(code ?? 0));
55
+ // Wait for setup to complete
56
+ await new Promise(() => {}); // Setup will exit the process
57
+ } else {
58
+ // Non-interactive (MCP server mode) - show error
59
+ console.error(`
60
+ ╭─────────────────────────────────────────────────────────────╮
61
+ │ apple-notes-mcp: Configuration required │
62
+ │ │
63
+ │ Run this command in your terminal first: │
64
+ │ │
65
+ │ apple-notes-mcp │
66
+ │ │
67
+ │ The setup wizard will guide you through configuration. │
68
+ ╰─────────────────────────────────────────────────────────────╯
69
+ `);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
26
74
  // Tool parameter schemas
27
75
  const SearchNotesSchema = z.object({
28
- query: z.string().min(1, "Query cannot be empty"),
29
- folder: z.string().optional(),
76
+ query: z.string().min(1, "Query cannot be empty").max(MAX_INPUT_LENGTH),
77
+ folder: z.string().max(200).optional(),
30
78
  limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
31
79
  mode: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
32
80
  include_content: z.boolean().default(false),
@@ -38,33 +86,63 @@ const IndexNotesSchema = z.object({
38
86
  });
39
87
 
40
88
  const ReindexNoteSchema = z.object({
41
- title: z.string(),
89
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
42
90
  });
43
91
 
44
92
  const GetNoteSchema = z.object({
45
- title: z.string(),
93
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
46
94
  });
47
95
 
48
96
  const CreateNoteSchema = z.object({
49
- title: z.string(),
50
- content: z.string(),
51
- folder: z.string().optional(),
97
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
98
+ content: z.string().min(1).max(MAX_INPUT_LENGTH),
99
+ folder: z.string().max(200).optional(),
52
100
  });
53
101
 
54
102
  const UpdateNoteSchema = z.object({
55
- title: z.string(),
56
- content: z.string(),
103
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
104
+ content: z.string().min(1).max(MAX_INPUT_LENGTH),
57
105
  reindex: z.boolean().default(true),
58
106
  });
59
107
 
60
108
  const DeleteNoteSchema = z.object({
61
- title: z.string(),
109
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
62
110
  confirm: z.boolean(),
63
111
  });
64
112
 
65
113
  const MoveNoteSchema = z.object({
66
- title: z.string(),
67
- folder: z.string(),
114
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
115
+ folder: z.string().min(1).max(200),
116
+ });
117
+
118
+ const EditTableSchema = z.object({
119
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
120
+ table_index: z.number().min(0).default(0),
121
+ edits: z.array(z.object({
122
+ row: z.number().min(0),
123
+ column: z.number().min(0),
124
+ value: z.string().max(10000),
125
+ })).min(1).max(100),
126
+ });
127
+
128
+ // Knowledge Graph tool schemas
129
+ const ListTagsSchema = z.object({});
130
+
131
+ const SearchByTagSchema = z.object({
132
+ tag: z.string().min(1).max(100),
133
+ folder: z.string().max(200).optional(),
134
+ limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
135
+ });
136
+
137
+ const RelatedNotesSchema = z.object({
138
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
139
+ types: z.array(z.enum(["tag", "link", "similar"])).default(["tag", "link", "similar"]),
140
+ limit: z.number().min(1).max(50).default(10),
141
+ });
142
+
143
+ const ExportGraphSchema = z.object({
144
+ format: z.enum(["json", "graphml"]),
145
+ folder: z.string().max(200).optional(),
68
146
  });
69
147
 
70
148
  // Create MCP server
@@ -231,6 +309,87 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
231
309
  required: ["title", "folder"],
232
310
  },
233
311
  },
312
+ {
313
+ name: "edit-table",
314
+ description: "Edit cells in a table within a note. Use for updating table data without rewriting the entire note.",
315
+ inputSchema: {
316
+ type: "object",
317
+ properties: {
318
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
319
+ table_index: { type: "number", description: "Which table to edit (0 = first table, default: 0)" },
320
+ edits: {
321
+ type: "array",
322
+ description: "Array of cell edits",
323
+ items: {
324
+ type: "object",
325
+ properties: {
326
+ row: { type: "number", description: "Row index (0 = header row)" },
327
+ column: { type: "number", description: "Column index (0 = first column)" },
328
+ value: { type: "string", description: "New cell value" },
329
+ },
330
+ required: ["row", "column", "value"],
331
+ },
332
+ },
333
+ },
334
+ required: ["title", "edits"],
335
+ },
336
+ },
337
+ // Knowledge Graph tools
338
+ {
339
+ name: "list-tags",
340
+ description: "List all tags with occurrence counts",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {},
344
+ required: [],
345
+ },
346
+ },
347
+ {
348
+ name: "search-by-tag",
349
+ description: "Find notes with a specific tag",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ tag: { type: "string", description: "Tag to search for (without #)" },
354
+ folder: { type: "string", description: "Filter by folder (optional)" },
355
+ limit: { type: "number", description: "Max results (default: 20)" },
356
+ },
357
+ required: ["tag"],
358
+ },
359
+ },
360
+ {
361
+ name: "related-notes",
362
+ description: "Find notes related to a source note by tags, links, or semantic similarity",
363
+ inputSchema: {
364
+ type: "object",
365
+ properties: {
366
+ title: { type: "string", description: "Source note title (use folder/title or id:xxx)" },
367
+ types: {
368
+ type: "array",
369
+ items: { type: "string", enum: ["tag", "link", "similar"] },
370
+ description: "Relationship types to include (default: all)"
371
+ },
372
+ limit: { type: "number", description: "Max results (default: 10)" },
373
+ },
374
+ required: ["title"],
375
+ },
376
+ },
377
+ {
378
+ name: "export-graph",
379
+ description: "Export knowledge graph to JSON or GraphML format for visualization",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ format: {
384
+ type: "string",
385
+ enum: ["json", "graphml"],
386
+ description: "Export format: json (for D3.js, custom viz) or graphml (for Gephi, yEd)"
387
+ },
388
+ folder: { type: "string", description: "Filter by folder (optional)" },
389
+ },
390
+ required: ["format"],
391
+ },
392
+ },
234
393
  ],
235
394
  };
236
395
  });
@@ -245,6 +404,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
245
404
  // Read tools
246
405
  case "search-notes": {
247
406
  const params = SearchNotesSchema.parse(args);
407
+
408
+ // Use chunk-based search if chunk index exists (better for long notes)
409
+ const useChunkSearch = await hasChunkIndex();
410
+
411
+ if (useChunkSearch) {
412
+ debug("Using chunk-based search");
413
+ const chunkResults = await searchChunks(params.query, {
414
+ folder: params.folder,
415
+ limit: params.limit,
416
+ mode: params.mode,
417
+ });
418
+
419
+ if (chunkResults.length === 0) {
420
+ return textResponse("No notes found matching your query.");
421
+ }
422
+
423
+ // Transform chunk results to match expected format
424
+ const results = chunkResults.map((r) => ({
425
+ id: r.note_id,
426
+ title: r.note_title,
427
+ folder: r.folder,
428
+ preview: r.matchedChunk.slice(0, 200) + (r.matchedChunk.length > 200 ? "..." : ""),
429
+ modified: r.modified,
430
+ score: r.score,
431
+ matchedChunkIndex: r.matchedChunkIndex,
432
+ }));
433
+
434
+ return textResponse(JSON.stringify(results, null, 2));
435
+ }
436
+
437
+ // Fall back to legacy search if no chunk index
438
+ debug("Using legacy search (no chunk index)");
248
439
  const results = await searchNotes(params.query, {
249
440
  folder: params.folder,
250
441
  limit: params.limit,
@@ -276,6 +467,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
276
467
  }
277
468
  }
278
469
 
470
+ // Run chunk indexing for full mode (for semantic search on long notes)
471
+ if (params.mode === "full") {
472
+ debug("Running chunk indexing for full mode...");
473
+ const chunkResult = await fullChunkIndex();
474
+ message += `\nChunk index: ${chunkResult.totalChunks} chunks from ${chunkResult.totalNotes} notes in ${(chunkResult.timeMs / 1000).toFixed(1)}s`;
475
+ }
476
+
279
477
  return textResponse(message);
280
478
  }
281
479
 
@@ -287,8 +485,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
287
485
 
288
486
  case "list-notes": {
289
487
  const store = getVectorStore();
290
- const count = await store.count();
291
- return textResponse(`${count} notes indexed. Run index-notes to update the index.`);
488
+ const noteCount = await store.count();
489
+
490
+ let message = `${noteCount} notes indexed.`;
491
+
492
+ // Show chunk statistics if chunk index exists
493
+ const hasChunks = await hasChunkIndex();
494
+ if (hasChunks) {
495
+ const chunkStore = getChunkStore();
496
+ const chunkCount = await chunkStore.count();
497
+ message += ` ${chunkCount} chunks indexed for semantic search.`;
498
+ } else {
499
+ message += " Run index-notes with mode='full' to enable chunk-based search.";
500
+ }
501
+
502
+ return textResponse(message);
292
503
  }
293
504
 
294
505
  case "get-note": {
@@ -323,19 +534,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
323
534
 
324
535
  case "update-note": {
325
536
  const params = UpdateNoteSchema.parse(args);
326
- await updateNote(params.title, params.content);
537
+ const result = await updateNote(params.title, params.content);
538
+
539
+ // Build location string for messages
540
+ const location = `${result.folder}/${result.newTitle}`;
541
+ const renamedMsg = result.titleChanged
542
+ ? ` (renamed from "${result.originalTitle}")`
543
+ : "";
327
544
 
328
545
  if (params.reindex) {
329
546
  try {
330
- await reindexNote(params.title);
331
- return textResponse(`Updated and reindexed note: "${params.title}"`);
547
+ // Use new title for reindexing (Apple Notes may have renamed it)
548
+ const reindexTitle = `${result.folder}/${result.newTitle}`;
549
+ await reindexNote(reindexTitle);
550
+ return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}`);
332
551
  } catch (reindexError) {
333
552
  debug("Reindex after update failed:", reindexError);
334
- return textResponse(`Updated note: "${params.title}" (reindexing failed, run index-notes to update)`);
553
+ return textResponse(`Updated note: "${location}"${renamedMsg} (reindexing failed, run index-notes to update)`);
335
554
  }
336
555
  }
337
556
 
338
- return textResponse(`Updated note: "${params.title}"`);
557
+ return textResponse(`Updated note: "${location}"${renamedMsg}`);
339
558
  }
340
559
 
341
560
  case "delete-note": {
@@ -353,6 +572,73 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
353
572
  return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
354
573
  }
355
574
 
575
+ case "edit-table": {
576
+ const params = EditTableSchema.parse(args);
577
+ await editTable(params.title, params.table_index, params.edits);
578
+ return textResponse(`Updated ${params.edits.length} cell(s) in table ${params.table_index}`);
579
+ }
580
+
581
+ // Knowledge Graph tools
582
+ case "list-tags": {
583
+ ListTagsSchema.parse(args);
584
+ const tags = await listTags();
585
+
586
+ if (tags.length === 0) {
587
+ return textResponse("No tags found. Add #tags to your notes and reindex.");
588
+ }
589
+
590
+ return textResponse(JSON.stringify(tags, null, 2));
591
+ }
592
+
593
+ case "search-by-tag": {
594
+ const params = SearchByTagSchema.parse(args);
595
+ const results = await searchByTag(params.tag, {
596
+ folder: params.folder,
597
+ limit: params.limit,
598
+ });
599
+
600
+ if (results.length === 0) {
601
+ return textResponse(`No notes found with tag: #${params.tag}`);
602
+ }
603
+
604
+ return textResponse(JSON.stringify(results, null, 2));
605
+ }
606
+
607
+ case "related-notes": {
608
+ const params = RelatedNotesSchema.parse(args);
609
+
610
+ // Resolve note to get ID
611
+ const note = await getNoteByTitle(params.title);
612
+ if (!note) {
613
+ return errorResponse(`Note not found: "${params.title}"`);
614
+ }
615
+
616
+ const results = await findRelatedNotes(note.id, {
617
+ types: params.types,
618
+ limit: params.limit,
619
+ });
620
+
621
+ if (results.length === 0) {
622
+ return textResponse("No related notes found.");
623
+ }
624
+
625
+ return textResponse(JSON.stringify(results, null, 2));
626
+ }
627
+
628
+ case "export-graph": {
629
+ const params = ExportGraphSchema.parse(args);
630
+ const result = await exportGraph({
631
+ format: params.format,
632
+ folder: params.folder,
633
+ });
634
+
635
+ if (typeof result === "string") {
636
+ return textResponse(result);
637
+ }
638
+
639
+ return textResponse(JSON.stringify(result, null, 2));
640
+ }
641
+
356
642
  default:
357
643
  return errorResponse(`Unknown tool: ${name}`);
358
644
  }