# 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 | ```