# Directory Structure
```
├── .gitignore
├── .python-version
├── .vscode
│ └── settings.json
├── database
│ └── .gitkeep
├── LICENSE
├── Makefile
├── pyproject.toml
├── README.md
├── src
│ └── apple_notes_mcp
│ ├── __init__.py
│ ├── notes_database.py
│ ├── proto
│ │ ├── notestore_pb2.py
│ │ └── notestore.proto
│ └── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/database/.gitkeep:
--------------------------------------------------------------------------------
```
1 |
```
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.12
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python-generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 |
9 | # Mac OS
10 | .DS_Store
11 |
12 | # Virtual environments
13 | .venv
14 |
15 | # Apple Notes database for development
16 | database/*
17 | !database/.gitkeep
18 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Apple Notes Model Context Protocol Server for Claude Desktop.
2 |
3 | Read your local Apple Notes database and provide it to Claude Desktop.
4 |
5 | Now Claude can search your most forgotten notes and know even more about you.
6 |
7 | Noting could go wrong.
8 |
9 | ## Components
10 |
11 | ### Resources
12 |
13 | The server implements the ability to read and write to your Apple Notes.
14 |
15 | ### Tools
16 |
17 | The server provides multiple prompts:
18 | - `get-all-notes`: Get all notes.
19 | - `read-note`: Get full content of a specific note.
20 | - `search-notes`: Search through notes.
21 |
22 | ### Missing Features:
23 |
24 | - No handling of encrypted notes (ZISPASSWORDPROTECTED)
25 | - No support for pinned notes filtering
26 | - No handling of cloud sync status
27 | - Missing attachment content retrieval
28 | - No support for checklist status (ZHASCHECKLIST)
29 | - No ability to create or edit notes
30 |
31 | ## Quickstart
32 |
33 | ### Install the server
34 |
35 | Recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install the server locally for Claude.
36 |
37 | ```
38 | uvx apple-notes-mcp
39 | ```
40 | OR
41 | ```
42 | uv pip install apple-notes-mcp
43 | ```
44 |
45 | Add your config as described below.
46 |
47 | #### Claude Desktop
48 |
49 | On MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`
50 | On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
51 |
52 | Note: You might need to use the direct path to `uv`. Use `which uv` to find the path.
53 |
54 |
55 | __Development/Unpublished Servers Configuration__
56 |
57 | ```json
58 | "mcpServers": {
59 | "apple-notes-mcp": {
60 | "command": "uv",
61 | "args": [
62 | "--directory",
63 | "{project_dir}",
64 | "run",
65 | "apple-notes-mcp"
66 | ]
67 | }
68 | }
69 | ```
70 |
71 |
72 | __Published Servers Configuration__
73 |
74 | ```json
75 | "mcpServers": {
76 | "apple-notes-mcp": {
77 | "command": "uvx",
78 | "args": [
79 | "apple-notes-mcp"
80 | ]
81 | }
82 | }
83 | ```
84 |
85 |
86 | ## Mac OS Disk Permissions
87 |
88 | You'll need to grant Full Disk Access to the server. This is because the Apple Notes sqlite database is nested deep in the MacOS file system.
89 |
90 | I may look at an AppleScript solution in the future if this annoys me further or if I want to start adding/appending to Apple Notes.
91 |
92 | ## Development
93 |
94 | ### Building and Publishing
95 |
96 | To prepare the package for distribution:
97 |
98 | 1. Sync dependencies and update lockfile:
99 | ```bash
100 | uv sync
101 | ```
102 |
103 | 2. Build package distributions:
104 | ```bash
105 | uv build
106 | ```
107 |
108 | This will create source and wheel distributions in the `dist/` directory.
109 |
110 | 3. Publish to PyPI:
111 | ```bash
112 | uv publish
113 | ```
114 |
115 | Note: You'll need to set PyPI credentials via environment variables or command flags:
116 | - Token: `--token` or `UV_PUBLISH_TOKEN`
117 | - Or username/password: `--username`/`UV_PUBLISH_USERNAME` and `--password`/`UV_PUBLISH_PASSWORD`
118 |
119 | ### Debugging
120 |
121 | Since MCP servers run over stdio, debugging can be challenging. For the best debugging
122 | experience, we strongly recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
123 |
124 |
125 | You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command:
126 |
127 | ```bash
128 | npx @modelcontextprotocol/inspector uv --directory {project_dir} run apple-notes-mcp
129 | ```
130 |
131 |
132 | Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging.
133 |
134 | ## License
135 |
136 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
137 |
138 | ## Source Code
139 |
140 | The source code is available on [GitHub](https://github.com/sirmews/apple-notes-mcp).
141 |
142 | ## Contributing
143 |
144 | Send your ideas and feedback to me on [Bluesky](https://bsky.app/profile/perfectlycromulent.bsky.social) or by opening an issue.
145 |
```
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "[python]": {
3 | "editor.formatOnSave": true,
4 | "editor.defaultFormatter": "charliermarsh.ruff"
5 | }
6 | }
```
--------------------------------------------------------------------------------
/src/apple_notes_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | from . import server
2 | import asyncio
3 | import argparse
4 |
5 |
6 | def main():
7 | parser = argparse.ArgumentParser(description="Apple Notes MCP Server")
8 | parser.add_argument(
9 | "--db-path",
10 | default=None,
11 | help="Path to Apple Notes database file. Will use OS default if not provided.",
12 | )
13 | args = parser.parse_args()
14 | asyncio.run(server.main(args.db_path))
15 |
16 |
17 | # Optionally expose other important items at package level
18 | __all__ = ["main", "server"]
19 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "apple-notes-mcp"
3 | version = "0.1.1"
4 | description = "Read local Apple Notes sqlite database and provide it as a context protocol server for Claude Desktop."
5 | readme = "README.md"
6 | requires-python = ">=3.12"
7 | dependencies = [
8 | "httpx>=0.28.0",
9 | "mcp>=1.0.0",
10 | "protobuf>=5.29.0",
11 | "python-dotenv>=1.0.1",
12 | ]
13 | classifiers = [
14 | "Programming Language :: Python :: 3",
15 | "License :: OSI Approved :: MIT License",
16 | "Operating System :: MacOS",
17 | ]
18 | [[project.authors]]
19 | name = "Navishkar Rao"
20 | email = "[email protected]"
21 |
22 | [build-system]
23 | requires = [ "hatchling",]
24 | build-backend = "hatchling.build"
25 |
26 | [project.scripts]
27 | apple-notes-mcp = "apple_notes_mcp:main"
28 |
29 | [tool.apple-notes-mcp]
30 | server_name = "apple-notes-mcp"
31 |
32 | [project.urls]
33 | Homepage = "https://sirmews.github.io/apple-notes-mcp/"
34 | Issues = "https://github.com/sirmews/apple-notes-mcp/issues"
35 |
```
--------------------------------------------------------------------------------
/src/apple_notes_mcp/proto/notestore.proto:
--------------------------------------------------------------------------------
```protobuf
1 | // Note: These are directly copied from https://github.com/threeplanetssoftware/apple_cloud_notes_parser/blob/master/proto/notestore.proto
2 | // Their work is amazing. Without these I would be absolutely lost.
3 | // And I copied it from the excellent https://github.com/HamburgChimps/apple-notes-liberator
4 | syntax = "proto2";
5 | package com.ciofecaforensics;
6 | //
7 | // Common classes used across a few types
8 | //
9 |
10 | //Represents a color
11 | message Color {
12 | optional float red = 1;
13 | optional float green = 2;
14 | optional float blue = 3;
15 | optional float alpha = 4;
16 | }
17 |
18 | // Represents an attachment (embedded object)
19 | message AttachmentInfo {
20 | optional string attachment_identifier = 1;
21 | optional string type_uti = 2;
22 | }
23 |
24 | // Represents a font
25 | message Font {
26 | optional string font_name = 1;
27 | optional float point_size = 2;
28 | optional int32 font_hints = 3;
29 | }
30 |
31 | // Styles a "Paragraph" (any run of characters in an AttributeRun)
32 | message ParagraphStyle {
33 | optional int32 style_type = 1 [default = -1];
34 | optional int32 alignment = 2;
35 | optional int32 indent_amount = 4;
36 | optional Checklist checklist = 5;
37 | }
38 |
39 | // Represents a checklist item
40 | message Checklist {
41 | optional bytes uuid = 1;
42 | optional int32 done = 2;
43 | }
44 |
45 | // Represents an object that has pointers to a key and a value, asserting
46 | // somehow that the key object has to do with the value object.
47 | message DictionaryElement {
48 | optional ObjectID key = 1;
49 | optional ObjectID value = 2;
50 | }
51 |
52 | // A Dictionary holds many DictionaryElements
53 | message Dictionary {
54 | repeated DictionaryElement element = 1;
55 | }
56 |
57 | // ObjectIDs are used to identify objects within the protobuf, offsets in an arry, or
58 | // a simple String.
59 | message ObjectID {
60 | optional uint64 unsigned_integer_value = 2;
61 | optional string string_value = 4;
62 | optional int32 object_index = 6;
63 | }
64 |
65 | // Register Latest is used to identify the most recent version
66 | message RegisterLatest {
67 | optional ObjectID contents = 2;
68 | }
69 |
70 | // MapEntries have a key that maps to an array of key items and a value that points to an object.
71 | message MapEntry {
72 | optional int32 key = 1;
73 | optional ObjectID value = 2;
74 | }
75 |
76 | // Represents a "run" of characters that need to be styled/displayed/etc
77 | message AttributeRun {
78 | optional int32 length = 1;
79 | optional ParagraphStyle paragraph_style = 2;
80 | optional Font font = 3;
81 | optional int32 font_weight = 5;
82 | optional int32 underlined = 6;
83 | optional int32 strikethrough = 7;
84 | optional int32 superscript = 8; //Sign indicates super/sub
85 | optional string link = 9;
86 | optional Color color = 10;
87 | optional AttachmentInfo attachment_info = 12;
88 | }
89 |
90 | //
91 | // Classes related to the overall Note protobufs
92 | //
93 |
94 | // Overarching object in a ZNOTEDATA.ZDATA blob
95 | message NoteStoreProto {
96 | optional Document document = 2;
97 | }
98 |
99 | // A Document has a Note within it.
100 | message Document {
101 | optional int32 version = 2;
102 | optional Note note = 3;
103 | }
104 |
105 | // A Note has both text, and then a lot of formatting entries.
106 | // Other fields are present and not yet included in this proto.
107 | message Note {
108 | optional string note_text = 2;
109 | repeated AttributeRun attribute_run = 5;
110 | }
111 |
112 | //
113 | // Classes related to embedded objects
114 | //
115 |
116 | // Represents the top level object in a ZMERGEABLEDATA cell
117 | message MergableDataProto {
118 | optional MergableDataObject mergable_data_object = 2;
119 | }
120 |
121 | // Similar to Document for Notes, this is what holds the mergeable object
122 | message MergableDataObject {
123 | optional int32 version = 2; // Asserted to be version in https://github.com/dunhamsteve/notesutils
124 | optional MergeableDataObjectData mergeable_data_object_data = 3;
125 | }
126 |
127 | // This is the mergeable data object itself and has a lot of entries that are the parts of it
128 | // along with arrays of key, type, and UUID items, depending on type.
129 | message MergeableDataObjectData {
130 | repeated MergeableDataObjectEntry mergeable_data_object_entry = 3;
131 | repeated string mergeable_data_object_key_item = 4;
132 | repeated string mergeable_data_object_type_item = 5;
133 | repeated bytes mergeable_data_object_uuid_item = 6;
134 | }
135 |
136 | // Each entry is part of the pbject. For example, one entry might be identifying which
137 | // UUIDs are rows, and another might hold the text of a cell.
138 | message MergeableDataObjectEntry {
139 | optional RegisterLatest register_latest = 1;
140 | optional List list = 5;
141 | optional Dictionary dictionary = 6;
142 | optional UnknownMergeableDataObjectEntryMessage unknown_message = 9;
143 | optional Note note = 10;
144 | optional MergeableDataObjectMap custom_map = 13;
145 | optional OrderedSet ordered_set = 16;
146 | }
147 |
148 | // This is unknown, it first was noticed in folder order analysis.
149 | message UnknownMergeableDataObjectEntryMessage {
150 | optional UnknownMergeableDataObjectEntryMessageEntry unknown_entry = 1;
151 | }
152 |
153 | // This is unknown, it first was noticed in folder order analysis.
154 | // "unknown_int2" is where the folder order is stored
155 | message UnknownMergeableDataObjectEntryMessageEntry {
156 | optional int32 unknown_int1 = 1;
157 | optional int64 unknown_int2 = 2;
158 | }
159 |
160 |
161 | // The Object Map uses its type to identify what you are looking at and
162 | // then a map entry to do something with that value.
163 | message MergeableDataObjectMap {
164 | optional int32 type = 1;
165 | repeated MapEntry map_entry = 3;
166 | }
167 |
168 | // An ordered set is used to hold structural information for embedded tables
169 | message OrderedSet {
170 | optional OrderedSetOrdering ordering = 1;
171 | optional Dictionary elements = 2;
172 | }
173 |
174 |
175 | // The ordered set ordering identifies rows and columns in embedded tables, with an array
176 | // of the objects and contents that map lookup values to originals.
177 | message OrderedSetOrdering {
178 | optional OrderedSetOrderingArray array = 1;
179 | optional Dictionary contents = 2;
180 | }
181 |
182 | // This array holds both the text to replace and the array of UUIDs to tell what
183 | // embedded rows and columns are.
184 | message OrderedSetOrderingArray {
185 | optional Note contents = 1;
186 | repeated OrderedSetOrderingArrayAttachment attachment = 2;
187 | }
188 |
189 | // This array identifies the UUIDs that are embedded table rows or columns
190 | message OrderedSetOrderingArrayAttachment {
191 | optional int32 index = 1;
192 | optional bytes uuid = 2;
193 | }
194 |
195 | // A List holds details about multiple objects
196 | message List {
197 | repeated ListEntry list_entry = 1;
198 | }
199 |
200 | // A list Entry holds details about a specific object
201 | message ListEntry {
202 | optional ObjectID id = 2;
203 | optional ListEntryDetails details = 3; // I dislike this naming, but don't have better information
204 | optional ListEntryDetails additional_details = 4;
205 | }
206 |
207 | // List Entry Details hold another object ID and unidentified mapping
208 | message ListEntryDetails {
209 | optional ListEntryDetailsKey list_entry_details_key= 1;
210 | optional ObjectID id = 2;
211 | }
212 |
213 | message ListEntryDetailsKey {
214 | optional int32 list_entry_details_type_index = 1;
215 | optional int32 list_entry_details_key = 2;
216 | }
```
--------------------------------------------------------------------------------
/src/apple_notes_mcp/proto/notestore_pb2.py:
--------------------------------------------------------------------------------
```python
1 | # -*- coding: utf-8 -*-
2 | # Generated by the protocol buffer compiler. DO NOT EDIT!
3 | # NO CHECKED-IN PROTOBUF GENCODE
4 | # source: notestore.proto
5 | # Protobuf Python Version: 5.29.0
6 | """Generated protocol buffer code."""
7 | from google.protobuf import descriptor as _descriptor
8 | from google.protobuf import descriptor_pool as _descriptor_pool
9 | from google.protobuf import runtime_version as _runtime_version
10 | from google.protobuf import symbol_database as _symbol_database
11 | from google.protobuf.internal import builder as _builder
12 | _runtime_version.ValidateProtobufRuntimeVersion(
13 | _runtime_version.Domain.PUBLIC,
14 | 5,
15 | 29,
16 | 0,
17 | '',
18 | 'notestore.proto'
19 | )
20 | # @@protoc_insertion_point(imports)
21 |
22 | _sym_db = _symbol_database.Default()
23 |
24 |
25 |
26 |
27 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fnotestore.proto\x12\x14\x63om.ciofecaforensics\"@\n\x05\x43olor\x12\x0b\n\x03red\x18\x01 \x01(\x02\x12\r\n\x05green\x18\x02 \x01(\x02\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x02\x12\r\n\x05\x61lpha\x18\x04 \x01(\x02\"A\n\x0e\x41ttachmentInfo\x12\x1d\n\x15\x61ttachment_identifier\x18\x01 \x01(\t\x12\x10\n\x08type_uti\x18\x02 \x01(\t\"A\n\x04\x46ont\x12\x11\n\tfont_name\x18\x01 \x01(\t\x12\x12\n\npoint_size\x18\x02 \x01(\x02\x12\x12\n\nfont_hints\x18\x03 \x01(\x05\"\x86\x01\n\x0eParagraphStyle\x12\x16\n\nstyle_type\x18\x01 \x01(\x05:\x02-1\x12\x11\n\talignment\x18\x02 \x01(\x05\x12\x15\n\rindent_amount\x18\x04 \x01(\x05\x12\x32\n\tchecklist\x18\x05 \x01(\x0b\x32\x1f.com.ciofecaforensics.Checklist\"\'\n\tChecklist\x12\x0c\n\x04uuid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64one\x18\x02 \x01(\x05\"o\n\x11\x44ictionaryElement\x12+\n\x03key\x18\x01 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\"F\n\nDictionary\x12\x38\n\x07\x65lement\x18\x01 \x03(\x0b\x32\'.com.ciofecaforensics.DictionaryElement\"V\n\x08ObjectID\x12\x1e\n\x16unsigned_integer_value\x18\x02 \x01(\x04\x12\x14\n\x0cstring_value\x18\x04 \x01(\t\x12\x14\n\x0cobject_index\x18\x06 \x01(\x05\"B\n\x0eRegisterLatest\x12\x30\n\x08\x63ontents\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\"F\n\x08MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\"\xd5\x02\n\x0c\x41ttributeRun\x12\x0e\n\x06length\x18\x01 \x01(\x05\x12=\n\x0fparagraph_style\x18\x02 \x01(\x0b\x32$.com.ciofecaforensics.ParagraphStyle\x12(\n\x04\x66ont\x18\x03 \x01(\x0b\x32\x1a.com.ciofecaforensics.Font\x12\x13\n\x0b\x66ont_weight\x18\x05 \x01(\x05\x12\x12\n\nunderlined\x18\x06 \x01(\x05\x12\x15\n\rstrikethrough\x18\x07 \x01(\x05\x12\x13\n\x0bsuperscript\x18\x08 \x01(\x05\x12\x0c\n\x04link\x18\t \x01(\t\x12*\n\x05\x63olor\x18\n \x01(\x0b\x32\x1b.com.ciofecaforensics.Color\x12=\n\x0f\x61ttachment_info\x18\x0c \x01(\x0b\x32$.com.ciofecaforensics.AttachmentInfo\"B\n\x0eNoteStoreProto\x12\x30\n\x08\x64ocument\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.Document\"E\n\x08\x44ocument\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12(\n\x04note\x18\x03 \x01(\x0b\x32\x1a.com.ciofecaforensics.Note\"T\n\x04Note\x12\x11\n\tnote_text\x18\x02 \x01(\t\x12\x39\n\rattribute_run\x18\x05 \x03(\x0b\x32\".com.ciofecaforensics.AttributeRun\"[\n\x11MergableDataProto\x12\x46\n\x14mergable_data_object\x18\x02 \x01(\x0b\x32(.com.ciofecaforensics.MergableDataObject\"x\n\x12MergableDataObject\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12Q\n\x1amergeable_data_object_data\x18\x03 \x01(\x0b\x32-.com.ciofecaforensics.MergeableDataObjectData\"\xe8\x01\n\x17MergeableDataObjectData\x12S\n\x1bmergeable_data_object_entry\x18\x03 \x03(\x0b\x32..com.ciofecaforensics.MergeableDataObjectEntry\x12&\n\x1emergeable_data_object_key_item\x18\x04 \x03(\t\x12\'\n\x1fmergeable_data_object_type_item\x18\x05 \x03(\t\x12\'\n\x1fmergeable_data_object_uuid_item\x18\x06 \x03(\x0c\"\xb3\x03\n\x18MergeableDataObjectEntry\x12=\n\x0fregister_latest\x18\x01 \x01(\x0b\x32$.com.ciofecaforensics.RegisterLatest\x12(\n\x04list\x18\x05 \x01(\x0b\x32\x1a.com.ciofecaforensics.List\x12\x34\n\ndictionary\x18\x06 \x01(\x0b\x32 .com.ciofecaforensics.Dictionary\x12U\n\x0funknown_message\x18\t \x01(\x0b\x32<.com.ciofecaforensics.UnknownMergeableDataObjectEntryMessage\x12(\n\x04note\x18\n \x01(\x0b\x32\x1a.com.ciofecaforensics.Note\x12@\n\ncustom_map\x18\r \x01(\x0b\x32,.com.ciofecaforensics.MergeableDataObjectMap\x12\x35\n\x0bordered_set\x18\x10 \x01(\x0b\x32 .com.ciofecaforensics.OrderedSet\"\x82\x01\n&UnknownMergeableDataObjectEntryMessage\x12X\n\runknown_entry\x18\x01 \x01(\x0b\x32\x41.com.ciofecaforensics.UnknownMergeableDataObjectEntryMessageEntry\"Y\n+UnknownMergeableDataObjectEntryMessageEntry\x12\x14\n\x0cunknown_int1\x18\x01 \x01(\x05\x12\x14\n\x0cunknown_int2\x18\x02 \x01(\x03\"Y\n\x16MergeableDataObjectMap\x12\x0c\n\x04type\x18\x01 \x01(\x05\x12\x31\n\tmap_entry\x18\x03 \x03(\x0b\x32\x1e.com.ciofecaforensics.MapEntry\"|\n\nOrderedSet\x12:\n\x08ordering\x18\x01 \x01(\x0b\x32(.com.ciofecaforensics.OrderedSetOrdering\x12\x32\n\x08\x65lements\x18\x02 \x01(\x0b\x32 .com.ciofecaforensics.Dictionary\"\x86\x01\n\x12OrderedSetOrdering\x12<\n\x05\x61rray\x18\x01 \x01(\x0b\x32-.com.ciofecaforensics.OrderedSetOrderingArray\x12\x32\n\x08\x63ontents\x18\x02 \x01(\x0b\x32 .com.ciofecaforensics.Dictionary\"\x94\x01\n\x17OrderedSetOrderingArray\x12,\n\x08\x63ontents\x18\x01 \x01(\x0b\x32\x1a.com.ciofecaforensics.Note\x12K\n\nattachment\x18\x02 \x03(\x0b\x32\x37.com.ciofecaforensics.OrderedSetOrderingArrayAttachment\"@\n!OrderedSetOrderingArrayAttachment\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x0c\n\x04uuid\x18\x02 \x01(\x0c\";\n\x04List\x12\x33\n\nlist_entry\x18\x01 \x03(\x0b\x32\x1f.com.ciofecaforensics.ListEntry\"\xb4\x01\n\tListEntry\x12*\n\x02id\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\x12\x37\n\x07\x64\x65tails\x18\x03 \x01(\x0b\x32&.com.ciofecaforensics.ListEntryDetails\x12\x42\n\x12\x61\x64\x64itional_details\x18\x04 \x01(\x0b\x32&.com.ciofecaforensics.ListEntryDetails\"\x89\x01\n\x10ListEntryDetails\x12I\n\x16list_entry_details_key\x18\x01 \x01(\x0b\x32).com.ciofecaforensics.ListEntryDetailsKey\x12*\n\x02id\x18\x02 \x01(\x0b\x32\x1e.com.ciofecaforensics.ObjectID\"\\\n\x13ListEntryDetailsKey\x12%\n\x1dlist_entry_details_type_index\x18\x01 \x01(\x05\x12\x1e\n\x16list_entry_details_key\x18\x02 \x01(\x05')
28 |
29 | _globals = globals()
30 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'notestore_pb2', _globals)
32 | if not _descriptor._USE_C_DESCRIPTORS:
33 | DESCRIPTOR._loaded_options = None
34 | _globals['_COLOR']._serialized_start=41
35 | _globals['_COLOR']._serialized_end=105
36 | _globals['_ATTACHMENTINFO']._serialized_start=107
37 | _globals['_ATTACHMENTINFO']._serialized_end=172
38 | _globals['_FONT']._serialized_start=174
39 | _globals['_FONT']._serialized_end=239
40 | _globals['_PARAGRAPHSTYLE']._serialized_start=242
41 | _globals['_PARAGRAPHSTYLE']._serialized_end=376
42 | _globals['_CHECKLIST']._serialized_start=378
43 | _globals['_CHECKLIST']._serialized_end=417
44 | _globals['_DICTIONARYELEMENT']._serialized_start=419
45 | _globals['_DICTIONARYELEMENT']._serialized_end=530
46 | _globals['_DICTIONARY']._serialized_start=532
47 | _globals['_DICTIONARY']._serialized_end=602
48 | _globals['_OBJECTID']._serialized_start=604
49 | _globals['_OBJECTID']._serialized_end=690
50 | _globals['_REGISTERLATEST']._serialized_start=692
51 | _globals['_REGISTERLATEST']._serialized_end=758
52 | _globals['_MAPENTRY']._serialized_start=760
53 | _globals['_MAPENTRY']._serialized_end=830
54 | _globals['_ATTRIBUTERUN']._serialized_start=833
55 | _globals['_ATTRIBUTERUN']._serialized_end=1174
56 | _globals['_NOTESTOREPROTO']._serialized_start=1176
57 | _globals['_NOTESTOREPROTO']._serialized_end=1242
58 | _globals['_DOCUMENT']._serialized_start=1244
59 | _globals['_DOCUMENT']._serialized_end=1313
60 | _globals['_NOTE']._serialized_start=1315
61 | _globals['_NOTE']._serialized_end=1399
62 | _globals['_MERGABLEDATAPROTO']._serialized_start=1401
63 | _globals['_MERGABLEDATAPROTO']._serialized_end=1492
64 | _globals['_MERGABLEDATAOBJECT']._serialized_start=1494
65 | _globals['_MERGABLEDATAOBJECT']._serialized_end=1614
66 | _globals['_MERGEABLEDATAOBJECTDATA']._serialized_start=1617
67 | _globals['_MERGEABLEDATAOBJECTDATA']._serialized_end=1849
68 | _globals['_MERGEABLEDATAOBJECTENTRY']._serialized_start=1852
69 | _globals['_MERGEABLEDATAOBJECTENTRY']._serialized_end=2287
70 | _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGE']._serialized_start=2290
71 | _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGE']._serialized_end=2420
72 | _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGEENTRY']._serialized_start=2422
73 | _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGEENTRY']._serialized_end=2511
74 | _globals['_MERGEABLEDATAOBJECTMAP']._serialized_start=2513
75 | _globals['_MERGEABLEDATAOBJECTMAP']._serialized_end=2602
76 | _globals['_ORDEREDSET']._serialized_start=2604
77 | _globals['_ORDEREDSET']._serialized_end=2728
78 | _globals['_ORDEREDSETORDERING']._serialized_start=2731
79 | _globals['_ORDEREDSETORDERING']._serialized_end=2865
80 | _globals['_ORDEREDSETORDERINGARRAY']._serialized_start=2868
81 | _globals['_ORDEREDSETORDERINGARRAY']._serialized_end=3016
82 | _globals['_ORDEREDSETORDERINGARRAYATTACHMENT']._serialized_start=3018
83 | _globals['_ORDEREDSETORDERINGARRAYATTACHMENT']._serialized_end=3082
84 | _globals['_LIST']._serialized_start=3084
85 | _globals['_LIST']._serialized_end=3143
86 | _globals['_LISTENTRY']._serialized_start=3146
87 | _globals['_LISTENTRY']._serialized_end=3326
88 | _globals['_LISTENTRYDETAILS']._serialized_start=3329
89 | _globals['_LISTENTRYDETAILS']._serialized_end=3466
90 | _globals['_LISTENTRYDETAILSKEY']._serialized_start=3468
91 | _globals['_LISTENTRYDETAILSKEY']._serialized_end=3560
92 | # @@protoc_insertion_point(module_scope)
93 |
```
--------------------------------------------------------------------------------
/src/apple_notes_mcp/server.py:
--------------------------------------------------------------------------------
```python
1 | import logging
2 | from mcp.server.models import InitializationOptions
3 | import mcp.types as types
4 | from mcp.server import NotificationOptions, Server
5 | from pydantic import AnyUrl
6 | import mcp.server.stdio
7 | from .notes_database import NotesDatabase
8 | import zlib
9 | from .proto.notestore_pb2 import NoteStoreProto
10 | from importlib import metadata
11 |
12 | # Configure logging
13 | logging.basicConfig(level=logging.INFO)
14 | logger = logging.getLogger("apple-notes-mcp")
15 |
16 | notes_db = None
17 |
18 | server = Server("apple-notes-mcp")
19 |
20 |
21 | def decode_note_content(content: bytes | None) -> str:
22 | """
23 | Decode note content from Apple Notes binary format using protobuf decoder.
24 | Uses schema from: https://github.com/HamburgChimps/apple-notes-liberator
25 | """
26 | if not content:
27 | return "Note has no content"
28 |
29 | try:
30 | # First decompress gzip
31 | if content.startswith(b"\x1f\x8b"):
32 | decompressed = zlib.decompress(content, 16 + zlib.MAX_WBITS)
33 |
34 | note_store = NoteStoreProto()
35 | note_store.ParseFromString(decompressed)
36 |
37 | # Extract note text and formatting
38 | if note_store.document and note_store.document.note:
39 | note = note_store.document.note
40 |
41 | # Start with the basic text
42 | output = [note.note_text]
43 |
44 | # Add formatting information if available
45 | # Might not need this for LLM needs
46 | if note.attribute_run:
47 | output.append("\nFormatting:")
48 | for run in note.attribute_run:
49 | fmt = []
50 | if run.font_weight:
51 | fmt.append(f"weight: {run.font_weight}")
52 | if run.underlined:
53 | fmt.append("underlined")
54 | if run.strikethrough:
55 | fmt.append("strikethrough")
56 | if run.paragraph_style and run.paragraph_style.style_type != -1:
57 | fmt.append(f"style: {run.paragraph_style.style_type}")
58 | if fmt:
59 | output.append(f"- length {run.length}: {', '.join(fmt)}")
60 |
61 | return "\n".join(output)
62 | return "No note content found"
63 |
64 | except Exception as e:
65 | return f"Error processing note content: {str(e)}"
66 |
67 |
68 | @server.list_resources()
69 | async def handle_list_resources() -> list[types.Resource]:
70 | """List all notes as resources"""
71 | all_notes = notes_db.get_all_notes()
72 | return [
73 | types.Resource(
74 | uri=f"notes://local/{note['pk']}", # Using primary key in URI
75 | name=note["title"],
76 | description=f"Note in {note['folder']} - Last modified: {note['modifiedAt']}",
77 | metadata={
78 | "folder": note["folder"],
79 | "modified": note["modifiedAt"],
80 | "locked": note["locked"],
81 | "pinned": note["pinned"],
82 | "hasChecklist": note["checklist"],
83 | },
84 | mimeType="text/plain",
85 | )
86 | for note in all_notes
87 | ]
88 |
89 |
90 | @server.read_resource()
91 | async def handle_read_resource(uri: AnyUrl) -> str:
92 | """
93 | Read a specific note's content
94 | Mostly from reading https://ciofecaforensics.com/2020/09/18/apple-notes-revisited-protobuf/
95 | and I found a gist https://gist.github.com/paultopia/b8a0400cd8406ff85969b722d3a2ebd8
96 | """
97 | if not str(uri).startswith("notes://"):
98 | raise ValueError(f"Unsupported URI scheme: {uri}")
99 |
100 | try:
101 | note_id = str(uri).split("/")[-1]
102 | note = notes_db.get_note_content(note_id)
103 |
104 | if not note:
105 | raise ValueError(f"Note not found: {note_id}")
106 |
107 | # Format metadata and content as text
108 | output = []
109 | output.append(f"Title: {note['title']}")
110 | output.append(f"Folder: {note['folder']}")
111 | output.append(f"Modified: {note['modifiedAt']}")
112 | output.append("") # Empty line between metadata and content
113 |
114 | decoded = decode_note_content(note["content"])
115 | if isinstance(decoded, dict):
116 | # Here we could convert formatting to markdown or other rich text format
117 | # For now just return the plain text
118 | output.append(decoded["text"])
119 | else:
120 | output.append(decoded)
121 |
122 | return "\n".join(output)
123 |
124 | except Exception as e:
125 | raise RuntimeError(f"Notes database error: {str(e)}")
126 |
127 |
128 | @server.list_prompts()
129 | async def handle_list_prompts() -> list[types.Prompt]:
130 | """
131 | List available prompts.
132 | Each prompt can have optional arguments to customize its behavior.
133 | """
134 | return [
135 | types.Prompt(
136 | name="find-note",
137 | description="Find notes matching specific criteria",
138 | arguments=[
139 | types.PromptArgument(
140 | name="query",
141 | description="What kind of note are you looking for?",
142 | required=True,
143 | ),
144 | types.PromptArgument(
145 | name="folder",
146 | description="Specific folder to search in",
147 | required=False,
148 | ),
149 | ],
150 | )
151 | ]
152 |
153 |
154 | @server.list_tools()
155 | async def handle_list_tools() -> list[types.Tool]:
156 | """
157 | List available tools.
158 | Each tool specifies its arguments using JSON Schema validation.
159 | """
160 | return [
161 | types.Tool(
162 | name="get-all-notes",
163 | description="Get all notes",
164 | inputSchema={
165 | "type": "object",
166 | "properties": {},
167 | },
168 | ),
169 | types.Tool(
170 | name="read-note",
171 | description="Get full content of a specific note",
172 | inputSchema={
173 | "type": "object",
174 | "properties": {
175 | "note_id": {
176 | "type": "string",
177 | "description": "ID of the note to read",
178 | },
179 | },
180 | "required": ["note_id"],
181 | },
182 | ),
183 | types.Tool(
184 | name="search-notes",
185 | description="Search through notes",
186 | inputSchema={
187 | "type": "object",
188 | "properties": {
189 | "query": {"type": "string", "description": "Search query"},
190 | },
191 | "required": ["query"],
192 | },
193 | ),
194 | ]
195 |
196 |
197 | @server.call_tool()
198 | async def handle_call_tool(
199 | name: str, arguments: dict | None
200 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
201 | """
202 | Handle tool execution requests.
203 | Tools can modify server state and notify clients of changes.
204 | """
205 |
206 | if name == "search-notes":
207 | query = arguments.get("query")
208 | results = notes_db.search_notes(query)
209 | return [
210 | types.TextContent(
211 | type="text",
212 | text=f"Found {len(results)} notes:\n"
213 | + "\n".join(
214 | f"- {note['title']} [ID: {note['pk']}]" for note in results
215 | ),
216 | )
217 | ]
218 |
219 | elif name == "get-all-notes":
220 | notes = notes_db.get_all_notes()
221 | return [
222 | types.TextContent(
223 | type="text",
224 | text="All notes:\n" + "\n".join(f"- {note['title']}" for note in notes),
225 | )
226 | ]
227 |
228 | elif name == "read-note":
229 | note_id = arguments.get("note_id")
230 | note = notes_db.get_note_content(note_id)
231 | if note:
232 | decoded_content = decode_note_content(note["content"])
233 | return [
234 | types.TextContent(
235 | type="text",
236 | text=f"Title: {note['title']}\n"
237 | f"Modified: {note['modifiedAt']}\n"
238 | f"Folder: {note['folder']}\n"
239 | f"\nContent:\n{decoded_content}",
240 | )
241 | ]
242 | return [types.TextContent(type="text", text="Note not found")]
243 |
244 | else:
245 | raise ValueError(f"Unknown tool: {name}")
246 |
247 | # Notify clients that resources have changed
248 | # do this when we start handling updates to notes
249 | await server.request_context.session.send_resource_list_changed()
250 |
251 |
252 | @server.get_prompt()
253 | async def handle_get_prompt(
254 | name: str, arguments: dict[str, str] | None
255 | ) -> types.GetPromptResult:
256 | """Generate a prompt for finding notes"""
257 | if name != "find-note":
258 | raise ValueError(f"Unknown prompt: {name}")
259 |
260 | query = arguments.get("query", "")
261 |
262 | results = notes_db.search_notes(query)
263 |
264 | notes_context = "\n".join(
265 | f"- {note['title']}: {note['snippet']}" for note in results
266 | )
267 |
268 | return types.GetPromptResult(
269 | description=f"Found {len(results)} notes matching '{query}'",
270 | messages=[
271 | types.PromptMessage(
272 | role="user",
273 | content=types.TextContent(
274 | type="text",
275 | text=f"Here are the notes that match your query:\n\n{notes_context}\n\n"
276 | f"Which note would you like to read?",
277 | ),
278 | )
279 | ],
280 | resources=[
281 | types.Resource(
282 | uri=f"notes://local/{note['pk']}",
283 | name=note["title"],
284 | description=note["snippet"],
285 | metadata={"folder": note["folder"], "modified": note["modifiedAt"]},
286 | )
287 | for note in results
288 | ],
289 | )
290 |
291 |
292 | async def main(db_path: str | None = None):
293 | # Run the server using stdin/stdout streams
294 |
295 | logger.info(f"Starting MCP server with db_path: {db_path}")
296 |
297 | global notes_db
298 | notes_db = NotesDatabase(db_path) if db_path else NotesDatabase()
299 |
300 | # Get the distribution info from the package
301 | dist = metadata.distribution("apple-notes-mcp")
302 |
303 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
304 | await server.run(
305 | read_stream,
306 | write_stream,
307 | InitializationOptions(
308 | server_name=dist.metadata["Name"],
309 | server_version=dist.version,
310 | capabilities=server.get_capabilities(
311 | notification_options=NotificationOptions(),
312 | experimental_capabilities={},
313 | ),
314 | ),
315 | )
316 |
```
--------------------------------------------------------------------------------
/src/apple_notes_mcp/notes_database.py:
--------------------------------------------------------------------------------
```python
1 | import sqlite3
2 | import logging
3 | from contextlib import closing
4 | from pathlib import Path
5 | from typing import Any, List, Dict
6 | import os
7 | import stat
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | class NotesDatabase:
13 | def __init__(
14 | self,
15 | db_path: str = "~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite",
16 | ):
17 | self.db_path = str(Path(db_path).expanduser())
18 | self._validate_path()
19 | self._init_database()
20 |
21 | def _validate_path(self):
22 | """Validate database path and permissions"""
23 | db_path = Path(self.db_path)
24 |
25 | if not db_path.exists():
26 | raise FileNotFoundError(
27 | f"Notes database not found at: {self.db_path}\n"
28 | "Please verify the path and ensure you have granted Full Disk Access"
29 | )
30 |
31 | # Check file permissions
32 | try:
33 | mode = os.stat(self.db_path).st_mode
34 | readable = bool(mode & stat.S_IRUSR)
35 | if not readable:
36 | raise PermissionError(
37 | f"No read permission for database at: {self.db_path}\n"
38 | "Please check file permissions and Full Disk Access settings"
39 | )
40 | except OSError as e:
41 | raise PermissionError(
42 | f"Cannot access database at {self.db_path}: {str(e)}\n"
43 | "You may need to grant Full Disk Access permission in System Preferences"
44 | )
45 |
46 | def _init_database(self):
47 | logger.debug("Initializing database connection")
48 | logger.info(f"Initializing database with path: {self.db_path}")
49 | try:
50 | with closing(sqlite3.connect(self.db_path)) as conn:
51 | conn.row_factory = sqlite3.Row
52 | # Verify we can access key Apple Notes tables
53 | cursor = conn.cursor()
54 | cursor.execute(
55 | "SELECT name FROM sqlite_master WHERE type='table' AND name='ZICCLOUDSYNCINGOBJECT'"
56 | )
57 | if not cursor.fetchone():
58 | raise ValueError(
59 | "This doesn't appear to be an Apple Notes database - missing required tables"
60 | )
61 | except sqlite3.Error as e:
62 | if "database is locked" in str(e):
63 | raise RuntimeError(
64 | f"Database is locked: {self.db_path}\n"
65 | "Please close Notes app or any other applications that might be accessing it"
66 | )
67 | elif "unable to open" in str(e):
68 | raise PermissionError(
69 | f"Cannot open database: {self.db_path}\n"
70 | "Please verify:\n"
71 | "1. You have granted Full Disk Access permission\n"
72 | "2. The file exists and is readable\n"
73 | "3. The Notes app is not exclusively locking the database"
74 | )
75 | else:
76 | logger.error(f"Failed to initialize database: {e}")
77 | raise
78 |
79 | def _execute_query(
80 | self, query: str, params: dict[str, Any] | None = None
81 | ) -> list[dict[str, Any]]:
82 | """Execute a SQL query and return results as a list of dictionaries"""
83 | logger.debug(f"Executing query: {query}")
84 | try:
85 | with closing(sqlite3.connect(self.db_path)) as conn:
86 | conn.row_factory = sqlite3.Row
87 | with closing(conn.cursor()) as cursor:
88 | if params:
89 | cursor.execute(query, params)
90 | else:
91 | cursor.execute(query)
92 |
93 | results = [dict(row) for row in cursor.fetchall()]
94 | logger.debug(f"Query returned {len(results)} rows")
95 | return results
96 | except sqlite3.Error as e:
97 | logger.error(f"Database error executing query: {e}")
98 | raise
99 |
100 | def get_all_notes(self) -> List[Dict[str, Any]]:
101 | """Retrieve all notes with their metadata"""
102 | query = """
103 | SELECT
104 | 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id,
105 | note.z_pk AS pk,
106 | note.ztitle1 AS title,
107 | folder.ztitle2 AS folder,
108 | datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt,
109 | note.zsnippet AS snippet,
110 | acc.zname AS account,
111 | note.zidentifier AS UUID,
112 | (note.zispasswordprotected = 1) as locked,
113 | (note.zispinned = 1) as pinned,
114 | (note.zhaschecklist = 1) as checklist,
115 | (note.zhaschecklistinprogress = 1) as checklistInProgress
116 | FROM
117 | ziccloudsyncingobject AS note
118 | INNER JOIN ziccloudsyncingobject AS folder
119 | ON note.zfolder = folder.z_pk
120 | LEFT JOIN ziccloudsyncingobject AS acc
121 | ON note.zaccount4 = acc.z_pk
122 | LEFT JOIN z_metadata AS zmd ON 1=1
123 | WHERE
124 | note.ztitle1 IS NOT NULL AND
125 | note.zmodificationdate1 IS NOT NULL AND
126 | note.z_pk IS NOT NULL AND
127 | note.zmarkedfordeletion != 1 AND
128 | folder.zmarkedfordeletion != 1
129 | ORDER BY
130 | note.zmodificationdate1 DESC
131 | """
132 | results = self._execute_query(query)
133 |
134 | return results
135 |
136 | def get_note_by_title(self, title: str) -> Dict[str, Any] | None:
137 | """Retrieve a specific note by its title including content and metadata"""
138 | query = """
139 | SELECT
140 | 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id,
141 | note.z_pk AS pk,
142 | note.ztitle1 AS title,
143 | folder.ztitle2 AS folder,
144 | datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt,
145 | datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt,
146 | note.zsnippet AS snippet,
147 | notedata.zdata AS content,
148 | acc.zname AS account,
149 | note.zidentifier AS UUID,
150 | (note.zispasswordprotected = 1) as locked,
151 | (note.zispinned = 1) as pinned,
152 | (note.zhaschecklist = 1) as checklist,
153 | (note.zhaschecklistinprogress = 1) as checklistInProgress
154 | FROM
155 | ziccloudsyncingobject AS note
156 | INNER JOIN ziccloudsyncingobject AS folder
157 | ON note.zfolder = folder.z_pk
158 | LEFT JOIN ziccloudsyncingobject AS acc
159 | ON note.zaccount4 = acc.z_pk
160 | LEFT JOIN zicnotedata AS notedata
161 | ON note.znotedata = notedata.z_pk
162 | LEFT JOIN z_metadata AS zmd ON 1=1
163 | WHERE
164 | note.ztitle1 = ? AND
165 | note.zmarkedfordeletion != 1 AND
166 | folder.zmarkedfordeletion != 1
167 | LIMIT 1
168 | """
169 | results = self._execute_query(query, (title,))
170 | return results[0] if results else None
171 |
172 | def search_notes(self, query_text: str) -> List[Dict[str, Any]]:
173 | """Search notes by title, content, or snippet with ranking by relevance"""
174 | query = """
175 | SELECT
176 | 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id,
177 | note.z_pk AS pk,
178 | note.ztitle1 AS title,
179 | folder.ztitle2 AS folder,
180 | datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt,
181 | datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt,
182 | note.zsnippet AS snippet,
183 | notedata.zdata AS content,
184 | acc.zname AS account,
185 | note.zidentifier AS UUID,
186 | (note.zispasswordprotected = 1) as locked,
187 | (note.zispinned = 1) as pinned,
188 | (note.zhaschecklist = 1) as checklist,
189 | (note.zhaschecklistinprogress = 1) as checklistInProgress,
190 | CASE
191 | WHEN note.ztitle1 LIKE ? THEN 3
192 | WHEN note.zsnippet LIKE ? THEN 2
193 | WHEN notedata.zdata LIKE ? THEN 1
194 | ELSE 0
195 | END as relevance
196 | FROM
197 | ziccloudsyncingobject AS note
198 | INNER JOIN ziccloudsyncingobject AS folder
199 | ON note.zfolder = folder.z_pk
200 | LEFT JOIN ziccloudsyncingobject AS acc
201 | ON note.zaccount4 = acc.z_pk
202 | LEFT JOIN zicnotedata AS notedata
203 | ON note.znotedata = notedata.z_pk
204 | LEFT JOIN z_metadata AS zmd ON 1=1
205 | WHERE
206 | note.zmarkedfordeletion != 1 AND
207 | folder.zmarkedfordeletion != 1 AND
208 | (note.ztitle1 LIKE ? OR
209 | note.zsnippet LIKE ? OR
210 | notedata.zdata LIKE ?)
211 | ORDER BY
212 | relevance DESC,
213 | note.zmodificationdate1 DESC
214 | """
215 | search_pattern = f"%{query_text}%"
216 | # We need 6 parameters because the pattern is used twice in the query
217 | # 3 times for relevance scoring and 3 times for WHERE clause
218 | params = (search_pattern,) * 6
219 |
220 | return self._execute_query(query, params)
221 |
222 | def get_note_content(self, note_id: str) -> Dict[str, Any] | None:
223 | """
224 | Retrieve full note content and metadata by note ID
225 | This note ID is provided by the resource URI inside Claude
226 | """
227 | query = """
228 | SELECT
229 | 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id,
230 | note.z_pk AS pk,
231 | note.ztitle1 AS title,
232 | folder.ztitle2 AS folder,
233 | datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt,
234 | datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt,
235 | note.zsnippet AS snippet,
236 | notedata.zdata AS content,
237 | acc.zname AS account,
238 | note.zidentifier AS UUID,
239 | (note.zispasswordprotected = 1) as locked,
240 | (note.zispinned = 1) as pinned
241 | FROM
242 | ziccloudsyncingobject AS note
243 | INNER JOIN ziccloudsyncingobject AS folder
244 | ON note.zfolder = folder.z_pk
245 | LEFT JOIN ziccloudsyncingobject AS acc
246 | ON note.zaccount4 = acc.z_pk
247 | LEFT JOIN zicnotedata AS notedata
248 | ON note.znotedata = notedata.z_pk
249 | LEFT JOIN z_metadata AS zmd ON 1=1
250 | WHERE
251 | note.z_pk = ? AND
252 | note.zmarkedfordeletion != 1 AND
253 | folder.zmarkedfordeletion != 1
254 | LIMIT 1
255 | """
256 |
257 | results = self._execute_query(query, (note_id,))
258 | return results[0] if results else None
259 |
```