#
tokens: 13884/50000 11/11 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```