@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 +2 -5
- package/src/db/lancedb.test.ts +45 -0
- package/src/db/lancedb.ts +40 -31
- package/src/embeddings/index.ts +1 -2
- package/src/embeddings/local.ts +0 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- package/src/index.ts +60 -14
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +76 -7
- package/src/notes/crud.ts +95 -46
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +90 -173
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.test.ts +70 -0
- package/src/notes/tables.ts +175 -0
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +16 -3
- package/src/types/index.ts +4 -0
- package/src/utils/debug.ts +0 -2
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -386
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@disco_trooper/apple-notes-mcp",
|
|
3
|
-
"version": "1.0
|
|
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"
|
package/src/db/lancedb.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
package/src/embeddings/index.ts
CHANGED
|
@@ -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
|
|
46
|
+
return detectedProvider ?? detectProvider();
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
/**
|
package/src/embeddings/local.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|