@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/CLAUDE.md ADDED
@@ -0,0 +1,56 @@
1
+ # CLAUDE.md
2
+
3
+ ## Project Overview
4
+
5
+ MCP server for Apple Notes with semantic search and CRUD operations.
6
+
7
+ ## Tech Stack
8
+
9
+ - **Runtime**: Bun
10
+ - **Language**: TypeScript
11
+ - **Database**: LanceDB (vector store)
12
+ - **Embeddings**: HuggingFace Transformers (local) or OpenRouter API
13
+ - **Apple Notes**: JXA (JavaScript for Automation)
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ bun run start # Start MCP server
19
+ bun run setup # Interactive setup wizard
20
+ bun run dev # Watch mode
21
+ bun run check # Type check
22
+ bun run test # Run tests (uses vitest, NOT bun test)
23
+ ```
24
+
25
+ ## Project Structure
26
+
27
+ ```
28
+ src/
29
+ ├── index.ts # MCP server entry (stdio transport)
30
+ ├── server.ts # Smithery-compatible export
31
+ ├── setup.ts # Interactive setup wizard
32
+ ├── config/ # Constants and env validation
33
+ ├── db/ # LanceDB vector store
34
+ ├── embeddings/ # Local and OpenRouter embeddings
35
+ ├── notes/ # Apple Notes CRUD via JXA
36
+ ├── search/ # Hybrid search and indexing
37
+ └── utils/ # Debug logging, errors, text utils
38
+ ```
39
+
40
+ ## Key Patterns
41
+
42
+ - **Dual embedding support**: Detects `OPENROUTER_API_KEY` to choose provider
43
+ - **Hybrid search**: Combines vector + keyword search with RRF fusion
44
+ - **Incremental indexing**: Only re-embeds changed notes
45
+ - **Folder/title disambiguation**: Use `Folder/Note Title` format for duplicates
46
+
47
+ ## Testing
48
+
49
+ Always use `bun run test` (vitest), never `bun test` (incompatible bun runner).
50
+
51
+ ## Environment Variables
52
+
53
+ See README.md for full list. Key ones:
54
+ - `OPENROUTER_API_KEY` - Enables cloud embeddings
55
+ - `READONLY_MODE` - Blocks write operations
56
+ - `DEBUG` - Enables debug logging
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 disco-trooper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # apple-notes-mcp
2
+
3
+ MCP server for Apple Notes with semantic search and CRUD operations. Claude searches, reads, creates, updates, and manages your Apple Notes through natural language.
4
+
5
+ ## Features
6
+
7
+ - **Semantic Search** - Find notes by meaning, not keywords
8
+ - **Hybrid Search** - Combine vector and keyword search for better results
9
+ - **Full CRUD** - Create, read, update, delete, and move notes
10
+ - **Incremental Indexing** - Re-embed only changed notes
11
+ - **Dual Embedding Support** - Local HuggingFace or OpenRouter API
12
+ - **Claude Code Integration** - Works with Claude Code CLI
13
+
14
+ ## Requirements
15
+
16
+ - macOS (uses Apple Notes via JXA)
17
+ - [Bun](https://bun.sh) runtime
18
+ - Apple Notes app with notes
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ git clone https://github.com/disco-trooper/apple-notes-mcp.git
24
+ cd apple-notes-mcp
25
+ bun install
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ Run the setup wizard:
31
+
32
+ ```bash
33
+ bun run setup
34
+ ```
35
+
36
+ The wizard:
37
+ 1. Chooses your embedding provider (local or OpenRouter)
38
+ 2. Configures API keys if needed
39
+ 3. Sets auto-indexing preferences
40
+ 4. Adds to Claude Code configuration
41
+ 5. Indexes your notes
42
+
43
+ ## Configuration
44
+
45
+ Store configuration in `.env`:
46
+
47
+ | Variable | Description | Default |
48
+ |----------|-------------|---------|
49
+ | `OPENROUTER_API_KEY` | OpenRouter API key (enables cloud embeddings) | - |
50
+ | `EMBEDDING_MODEL` | Model name (local or OpenRouter) | `Xenova/multilingual-e5-small` |
51
+ | `EMBEDDING_DIMS` | Embedding dimensions | `4096` |
52
+ | `READONLY_MODE` | Block all write operations | `false` |
53
+ | `INDEX_TTL` | Auto-reindex interval in seconds | - |
54
+ | `DEBUG` | Enable debug logging | `false` |
55
+
56
+ ### Embedding Providers
57
+
58
+ **Local (default)**: Uses HuggingFace Transformers with `Xenova/multilingual-e5-small`. Free, runs locally, ~200MB download.
59
+
60
+ **OpenRouter**: Uses cloud API. Fast, requires no local resources, needs API key from [openrouter.ai](https://openrouter.ai).
61
+
62
+ See [docs/models.md](docs/models.md) for model comparison.
63
+
64
+ ## Tools
65
+
66
+ ### Search & Discovery
67
+
68
+ #### `search-notes`
69
+ Search notes with hybrid vector + fulltext search.
70
+
71
+ ```
72
+ query: "meeting notes from last week"
73
+ folder: "Work" # optional, filter by folder
74
+ limit: 10 # default: 20
75
+ mode: "hybrid" # hybrid, keyword, or semantic
76
+ include_content: false # include full content vs preview
77
+ ```
78
+
79
+ #### `list-notes`
80
+ Count indexed notes.
81
+
82
+ #### `list-folders`
83
+ List all Apple Notes folders.
84
+
85
+ #### `get-note`
86
+ Get a note's full content by title.
87
+
88
+ ```
89
+ title: "My Note" # or "Work/My Note" for disambiguation
90
+ ```
91
+
92
+ ### Indexing
93
+
94
+ #### `index-notes`
95
+ Index all notes for semantic search.
96
+
97
+ ```
98
+ mode: "incremental" # incremental (default) or full
99
+ force: false # force reindex even if TTL hasn't expired
100
+ ```
101
+
102
+ #### `reindex-note`
103
+ Re-index a single note after manual edits.
104
+
105
+ ```
106
+ title: "My Note"
107
+ ```
108
+
109
+ ### CRUD Operations
110
+
111
+ #### `create-note`
112
+ Create a note in Apple Notes.
113
+
114
+ ```
115
+ title: "New Note"
116
+ content: "# Heading\n\nMarkdown content..."
117
+ folder: "Work" # optional, defaults to Notes
118
+ ```
119
+
120
+ #### `update-note`
121
+ Update an existing note.
122
+
123
+ ```
124
+ title: "My Note"
125
+ content: "Updated markdown content..."
126
+ reindex: true # re-embed after update (default: true)
127
+ ```
128
+
129
+ #### `delete-note`
130
+ Delete a note (requires confirmation).
131
+
132
+ ```
133
+ title: "My Note"
134
+ confirm: true # must be true to delete
135
+ ```
136
+
137
+ #### `move-note`
138
+ Move a note to another folder.
139
+
140
+ ```
141
+ title: "My Note"
142
+ folder: "Archive"
143
+ ```
144
+
145
+ ## Claude Code Setup
146
+
147
+ ### Automatic (via setup wizard)
148
+
149
+ Run `bun run setup` and select "Add to Claude Code configuration".
150
+
151
+ ### Manual
152
+
153
+ Add to `~/.claude.json`:
154
+
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "apple-notes": {
159
+ "command": "bun",
160
+ "args": ["run", "/path/to/apple-notes-mcp/src/index.ts"],
161
+ "env": {}
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ ## Usage Examples
168
+
169
+ After setup, use natural language with Claude:
170
+
171
+ - "Search my notes for project ideas"
172
+ - "Create a note called 'Meeting Notes' in the Work folder"
173
+ - "What's in my note about vacation plans?"
174
+ - "Move the 'Old Project' note to Archive"
175
+ - "Index my notes" (after adding notes in Apple Notes)
176
+
177
+ ## Troubleshooting
178
+
179
+ ### "Note not found"
180
+ Use full path format `Folder/Note Title` when multiple notes share the same name.
181
+
182
+ ### Slow first search
183
+ Local embeddings download the model on first use (~200MB). Subsequent searches run fast.
184
+
185
+ ### "READONLY_MODE is enabled"
186
+ Set `READONLY_MODE=false` in `.env` to enable write operations.
187
+
188
+ ### Notes missing from search
189
+ Run `index-notes` to update the search index. Use `mode: full` if incremental misses changes.
190
+
191
+ ### JXA errors
192
+ Ensure Apple Notes runs and contains notes. Grant automation permissions when prompted.
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ # Type check
198
+ bun run check
199
+
200
+ # Run with debug logging
201
+ DEBUG=true bun run start
202
+
203
+ # Watch mode
204
+ bun run dev
205
+ ```
206
+
207
+ ## Contributing
208
+
209
+ PRs welcome! Please:
210
+ - Run `bun run check` before submitting
211
+ - Add tests for new functionality
212
+ - Update documentation as needed
213
+
214
+ ## License
215
+
216
+ MIT
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@disco_trooper/apple-notes-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Apple Notes with semantic search and CRUD operations",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "module": "src/server.ts",
8
+ "scripts": {
9
+ "start": "bun run src/index.ts",
10
+ "setup": "bun run src/setup.ts",
11
+ "dev": "bun --watch run src/index.ts",
12
+ "check": "bun run tsc --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.0.0",
18
+ "@lancedb/lancedb": "^0.13.0",
19
+ "@huggingface/transformers": "^3.0.0",
20
+ "turndown": "^7.2.0",
21
+ "marked": "^15.0.0",
22
+ "run-jxa": "^3.0.0",
23
+ "zod": "^3.24.0",
24
+ "@clack/prompts": "^0.8.0",
25
+ "dotenv": "^16.4.0"
26
+ },
27
+ "devDependencies": {
28
+ "@smithery/cli": "^3.0.3",
29
+ "@types/bun": "^1.1.0",
30
+ "@types/turndown": "^5.0.0",
31
+ "typescript": "^5.7.0",
32
+ "vitest": "^4.0.16"
33
+ },
34
+ "keywords": [
35
+ "mcp",
36
+ "apple-notes",
37
+ "semantic-search",
38
+ "rag",
39
+ "claude"
40
+ ],
41
+ "author": "disco-trooper",
42
+ "license": "MIT",
43
+ "bin": {
44
+ "apple-notes-mcp": "./src/index.ts"
45
+ },
46
+ "files": [
47
+ "src",
48
+ "README.md",
49
+ "CLAUDE.md"
50
+ ],
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "os": [
55
+ "darwin"
56
+ ],
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://github.com/disco-trooper/apple-notes-mcp"
60
+ }
61
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Application constants extracted from magic numbers throughout the codebase.
3
+ * Centralizes configuration values for easy maintenance and documentation.
4
+ */
5
+
6
+ // Search defaults
7
+ export const DEFAULT_SEARCH_LIMIT = 20;
8
+ export const MAX_SEARCH_LIMIT = 100;
9
+ export const PREVIEW_LENGTH = 200;
10
+
11
+ // Embedding settings
12
+ export const DEFAULT_LOCAL_EMBEDDING_DIMS = 384;
13
+ export const DEFAULT_OPENROUTER_EMBEDDING_DIMS = 4096;
14
+ export const DEFAULT_OPENROUTER_MODEL = "qwen/qwen3-embedding-8b";
15
+
16
+ // Vector search
17
+ export const DEFAULT_VECTOR_SEARCH_LIMIT = 50;
18
+ export const RRF_K = 60; // Reciprocal Rank Fusion constant
19
+
20
+ // Timeouts and retries
21
+ export const OPENROUTER_TIMEOUT_MS = 30000;
22
+
23
+ // Cache settings
24
+ export const EMBEDDING_CACHE_MAX_SIZE = 1000;
25
+
26
+ // Input processing
27
+ export const MAX_INPUT_LENGTH = 8000;
28
+ export const MAX_TITLE_LENGTH = 500;
29
+ export const ERROR_MESSAGE_MAX_LENGTH = 200;
30
+
31
+ // Retry and backoff
32
+ export const MAX_RETRIES = 3;
33
+ export const RATE_LIMIT_BACKOFF_BASE_MS = 2000;
34
+
35
+ // Indexing
36
+ export const EMBEDDING_DELAY_MS = 300;
37
+
38
+ // Search tuning
39
+ export const HYBRID_SEARCH_MIN_FETCH = 40;
40
+ export const FOLDER_FILTER_MULTIPLIER = 3;
41
+ export const PREVIEW_TRUNCATE_RATIO = 0.7;
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // We need to test validateEnv with different env vars
4
+ // The module reads process.env at import time, so we need dynamic imports
5
+
6
+ describe("validateEnv", () => {
7
+ const originalEnv = { ...process.env };
8
+
9
+ beforeEach(() => {
10
+ vi.resetModules();
11
+ });
12
+
13
+ afterEach(() => {
14
+ process.env = { ...originalEnv };
15
+ });
16
+
17
+ it("accepts valid configuration", async () => {
18
+ process.env.DEBUG = "true";
19
+ process.env.READONLY_MODE = "false";
20
+ const { validateEnv } = await import("./env.js");
21
+ expect(() => validateEnv()).not.toThrow();
22
+ });
23
+
24
+ it("accepts empty configuration", async () => {
25
+ // Clear relevant env vars
26
+ delete process.env.OPENROUTER_API_KEY;
27
+ delete process.env.DEBUG;
28
+ delete process.env.READONLY_MODE;
29
+ delete process.env.EMBEDDING_DIMS;
30
+ delete process.env.INDEX_TTL;
31
+ const { validateEnv } = await import("./env.js");
32
+ expect(() => validateEnv()).not.toThrow();
33
+ });
34
+
35
+ it("validates OPENROUTER_API_KEY format", async () => {
36
+ process.env.OPENROUTER_API_KEY = "invalid-key";
37
+ const { validateEnv } = await import("./env.js");
38
+ expect(() => validateEnv()).toThrow();
39
+ });
40
+
41
+ it("accepts valid OPENROUTER_API_KEY", async () => {
42
+ process.env.OPENROUTER_API_KEY = "sk-or-valid-key-123";
43
+ const { validateEnv } = await import("./env.js");
44
+ expect(() => validateEnv()).not.toThrow();
45
+ });
46
+
47
+ it("validates EMBEDDING_DIMS is numeric", async () => {
48
+ process.env.EMBEDDING_DIMS = "not-a-number";
49
+ const { validateEnv } = await import("./env.js");
50
+ expect(() => validateEnv()).toThrow();
51
+ });
52
+
53
+ it("validates DEBUG is boolean string", async () => {
54
+ process.env.DEBUG = "yes";
55
+ const { validateEnv } = await import("./env.js");
56
+ expect(() => validateEnv()).toThrow();
57
+ });
58
+ });
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ const EnvSchema = z.object({
4
+ OPENROUTER_API_KEY: z.string().startsWith("sk-or-").optional(),
5
+ EMBEDDING_MODEL: z.string().optional(),
6
+ EMBEDDING_DIMS: z.string().regex(/^\d+$/).optional(),
7
+ READONLY_MODE: z.enum(["true", "false"]).optional(),
8
+ INDEX_TTL: z.string().regex(/^\d+$/).optional(),
9
+ DEBUG: z.enum(["true", "false"]).optional(),
10
+ });
11
+
12
+ export type Env = z.infer<typeof EnvSchema>;
13
+
14
+ export function validateEnv(): Env {
15
+ const result = EnvSchema.safeParse(process.env);
16
+
17
+ if (!result.success) {
18
+ const errors = result.error.errors
19
+ .map((e) => ` ${e.path.join(".")}: ${e.message}`)
20
+ .join("\n");
21
+ throw new Error(`Invalid environment configuration:\n${errors}`);
22
+ }
23
+
24
+ return result.data;
25
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { LanceDBStore } from "./lancedb.js";
3
+ import type { NoteRecord } from "./lancedb.js";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+
7
+ describe("LanceDBStore", () => {
8
+ let store: LanceDBStore;
9
+ let testDbPath: string;
10
+
11
+ beforeEach(() => {
12
+ testDbPath = path.join("/tmp", `lancedb-test-${Date.now()}`);
13
+ store = new LanceDBStore(testDbPath);
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (fs.existsSync(testDbPath)) {
18
+ fs.rmSync(testDbPath, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ const createTestRecord = (title: string): NoteRecord => ({
23
+ title,
24
+ folder: "Test",
25
+ content: `Content of ${title}`,
26
+ modified: new Date().toISOString(),
27
+ created: new Date().toISOString(),
28
+ indexed_at: new Date().toISOString(),
29
+ vector: Array(384).fill(0.1),
30
+ });
31
+
32
+ describe("index and getByTitle", () => {
33
+ it("indexes and retrieves a record", async () => {
34
+ const record = createTestRecord("Test Note");
35
+ await store.index([record]);
36
+ const retrieved = await store.getByTitle("Test Note");
37
+ expect(retrieved).not.toBeNull();
38
+ expect(retrieved?.title).toBe("Test Note");
39
+ });
40
+
41
+ it("returns null for non-existent title", async () => {
42
+ await store.index([createTestRecord("Other")]); // Initialize table
43
+ const retrieved = await store.getByTitle("Does Not Exist");
44
+ expect(retrieved).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe("count", () => {
49
+ it("returns correct count", async () => {
50
+ await store.index([createTestRecord("Note 1"), createTestRecord("Note 2")]);
51
+ expect(await store.count()).toBe(2);
52
+ });
53
+
54
+ it("returns 0 for empty store", async () => {
55
+ expect(await store.count()).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("delete", () => {
60
+ it("deletes existing record", async () => {
61
+ await store.index([createTestRecord("Delete Me")]);
62
+ await store.delete("Delete Me");
63
+ const retrieved = await store.getByTitle("Delete Me");
64
+ expect(retrieved).toBeNull();
65
+ });
66
+
67
+ it("does not throw when deleting non-existent record", async () => {
68
+ await store.index([createTestRecord("Existing")]);
69
+ await expect(store.delete("Non Existent")).resolves.not.toThrow();
70
+ });
71
+ });
72
+
73
+ describe("getAll", () => {
74
+ it("returns all indexed records", async () => {
75
+ await store.index([
76
+ createTestRecord("Note 1"),
77
+ createTestRecord("Note 2"),
78
+ createTestRecord("Note 3"),
79
+ ]);
80
+ const all = await store.getAll();
81
+ expect(all).toHaveLength(3);
82
+ const titles = all.map((r) => r.title);
83
+ expect(titles).toContain("Note 1");
84
+ expect(titles).toContain("Note 2");
85
+ expect(titles).toContain("Note 3");
86
+ });
87
+ });
88
+
89
+ describe("update", () => {
90
+ it("updates existing record content", async () => {
91
+ await store.index([createTestRecord("Update Me")]);
92
+
93
+ const updatedRecord = createTestRecord("Update Me");
94
+ updatedRecord.content = "New updated content";
95
+ await store.update(updatedRecord);
96
+
97
+ const retrieved = await store.getByTitle("Update Me");
98
+ expect(retrieved?.content).toBe("New updated content");
99
+ });
100
+
101
+ it("adds new record if title does not exist", async () => {
102
+ await store.index([createTestRecord("Existing")]);
103
+
104
+ const newRecord = createTestRecord("New Note");
105
+ await store.update(newRecord);
106
+
107
+ const retrieved = await store.getByTitle("New Note");
108
+ expect(retrieved).not.toBeNull();
109
+ expect(retrieved?.title).toBe("New Note");
110
+ });
111
+ });
112
+
113
+ describe("clear", () => {
114
+ it("removes all records", async () => {
115
+ await store.index([
116
+ createTestRecord("Note 1"),
117
+ createTestRecord("Note 2"),
118
+ ]);
119
+ expect(await store.count()).toBe(2);
120
+
121
+ await store.clear();
122
+ expect(await store.count()).toBe(0);
123
+ });
124
+ });
125
+
126
+ describe("search", () => {
127
+ it("returns results based on vector similarity", async () => {
128
+ await store.index([
129
+ createTestRecord("Note 1"),
130
+ createTestRecord("Note 2"),
131
+ ]);
132
+
133
+ const queryVector = Array(384).fill(0.1);
134
+ const results = await store.search(queryVector, 2);
135
+
136
+ expect(results).toHaveLength(2);
137
+ expect(results[0]).toHaveProperty("title");
138
+ expect(results[0]).toHaveProperty("score");
139
+ });
140
+ });
141
+ });