# 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: -------------------------------------------------------------------------------- ``` ``` -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 3.12 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Mac OS .DS_Store # Virtual environments .venv # Apple Notes database for development database/* !database/.gitkeep ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Apple Notes Model Context Protocol Server for Claude Desktop. Read your local Apple Notes database and provide it to Claude Desktop. Now Claude can search your most forgotten notes and know even more about you. Noting could go wrong. ## Components ### Resources The server implements the ability to read and write to your Apple Notes. ### Tools The server provides multiple prompts: - `get-all-notes`: Get all notes. - `read-note`: Get full content of a specific note. - `search-notes`: Search through notes. ### Missing Features: - No handling of encrypted notes (ZISPASSWORDPROTECTED) - No support for pinned notes filtering - No handling of cloud sync status - Missing attachment content retrieval - No support for checklist status (ZHASCHECKLIST) - No ability to create or edit notes ## Quickstart ### Install the server Recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install the server locally for Claude. ``` uvx apple-notes-mcp ``` OR ``` uv pip install apple-notes-mcp ``` Add your config as described below. #### Claude Desktop On MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json` On Windows: `%APPDATA%/Claude/claude_desktop_config.json` Note: You might need to use the direct path to `uv`. Use `which uv` to find the path. __Development/Unpublished Servers Configuration__ ```json "mcpServers": { "apple-notes-mcp": { "command": "uv", "args": [ "--directory", "{project_dir}", "run", "apple-notes-mcp" ] } } ``` __Published Servers Configuration__ ```json "mcpServers": { "apple-notes-mcp": { "command": "uvx", "args": [ "apple-notes-mcp" ] } } ``` ## Mac OS Disk Permissions 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. 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. ## Development ### Building and Publishing To prepare the package for distribution: 1. Sync dependencies and update lockfile: ```bash uv sync ``` 2. Build package distributions: ```bash uv build ``` This will create source and wheel distributions in the `dist/` directory. 3. Publish to PyPI: ```bash uv publish ``` Note: You'll need to set PyPI credentials via environment variables or command flags: - Token: `--token` or `UV_PUBLISH_TOKEN` - Or username/password: `--username`/`UV_PUBLISH_USERNAME` and `--password`/`UV_PUBLISH_PASSWORD` ### Debugging Since MCP servers run over stdio, debugging can be challenging. For the best debugging experience, we strongly recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command: ```bash npx @modelcontextprotocol/inspector uv --directory {project_dir} run apple-notes-mcp ``` Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging. ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. ## Source Code The source code is available on [GitHub](https://github.com/sirmews/apple-notes-mcp). ## Contributing Send your ideas and feedback to me on [Bluesky](https://bsky.app/profile/perfectlycromulent.bsky.social) or by opening an issue. ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json { "[python]": { "editor.formatOnSave": true, "editor.defaultFormatter": "charliermarsh.ruff" } } ``` -------------------------------------------------------------------------------- /src/apple_notes_mcp/__init__.py: -------------------------------------------------------------------------------- ```python from . import server import asyncio import argparse def main(): parser = argparse.ArgumentParser(description="Apple Notes MCP Server") parser.add_argument( "--db-path", default=None, help="Path to Apple Notes database file. Will use OS default if not provided.", ) args = parser.parse_args() asyncio.run(server.main(args.db_path)) # Optionally expose other important items at package level __all__ = ["main", "server"] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [project] name = "apple-notes-mcp" version = "0.1.1" description = "Read local Apple Notes sqlite database and provide it as a context protocol server for Claude Desktop." readme = "README.md" requires-python = ">=3.12" dependencies = [ "httpx>=0.28.0", "mcp>=1.0.0", "protobuf>=5.29.0", "python-dotenv>=1.0.1", ] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", ] [[project.authors]] name = "Navishkar Rao" email = "[email protected]" [build-system] requires = [ "hatchling",] build-backend = "hatchling.build" [project.scripts] apple-notes-mcp = "apple_notes_mcp:main" [tool.apple-notes-mcp] server_name = "apple-notes-mcp" [project.urls] Homepage = "https://sirmews.github.io/apple-notes-mcp/" Issues = "https://github.com/sirmews/apple-notes-mcp/issues" ``` -------------------------------------------------------------------------------- /src/apple_notes_mcp/proto/notestore.proto: -------------------------------------------------------------------------------- ```protobuf // Note: These are directly copied from https://github.com/threeplanetssoftware/apple_cloud_notes_parser/blob/master/proto/notestore.proto // Their work is amazing. Without these I would be absolutely lost. // And I copied it from the excellent https://github.com/HamburgChimps/apple-notes-liberator syntax = "proto2"; package com.ciofecaforensics; // // Common classes used across a few types // //Represents a color message Color { optional float red = 1; optional float green = 2; optional float blue = 3; optional float alpha = 4; } // Represents an attachment (embedded object) message AttachmentInfo { optional string attachment_identifier = 1; optional string type_uti = 2; } // Represents a font message Font { optional string font_name = 1; optional float point_size = 2; optional int32 font_hints = 3; } // Styles a "Paragraph" (any run of characters in an AttributeRun) message ParagraphStyle { optional int32 style_type = 1 [default = -1]; optional int32 alignment = 2; optional int32 indent_amount = 4; optional Checklist checklist = 5; } // Represents a checklist item message Checklist { optional bytes uuid = 1; optional int32 done = 2; } // Represents an object that has pointers to a key and a value, asserting // somehow that the key object has to do with the value object. message DictionaryElement { optional ObjectID key = 1; optional ObjectID value = 2; } // A Dictionary holds many DictionaryElements message Dictionary { repeated DictionaryElement element = 1; } // ObjectIDs are used to identify objects within the protobuf, offsets in an arry, or // a simple String. message ObjectID { optional uint64 unsigned_integer_value = 2; optional string string_value = 4; optional int32 object_index = 6; } // Register Latest is used to identify the most recent version message RegisterLatest { optional ObjectID contents = 2; } // MapEntries have a key that maps to an array of key items and a value that points to an object. message MapEntry { optional int32 key = 1; optional ObjectID value = 2; } // Represents a "run" of characters that need to be styled/displayed/etc message AttributeRun { optional int32 length = 1; optional ParagraphStyle paragraph_style = 2; optional Font font = 3; optional int32 font_weight = 5; optional int32 underlined = 6; optional int32 strikethrough = 7; optional int32 superscript = 8; //Sign indicates super/sub optional string link = 9; optional Color color = 10; optional AttachmentInfo attachment_info = 12; } // // Classes related to the overall Note protobufs // // Overarching object in a ZNOTEDATA.ZDATA blob message NoteStoreProto { optional Document document = 2; } // A Document has a Note within it. message Document { optional int32 version = 2; optional Note note = 3; } // A Note has both text, and then a lot of formatting entries. // Other fields are present and not yet included in this proto. message Note { optional string note_text = 2; repeated AttributeRun attribute_run = 5; } // // Classes related to embedded objects // // Represents the top level object in a ZMERGEABLEDATA cell message MergableDataProto { optional MergableDataObject mergable_data_object = 2; } // Similar to Document for Notes, this is what holds the mergeable object message MergableDataObject { optional int32 version = 2; // Asserted to be version in https://github.com/dunhamsteve/notesutils optional MergeableDataObjectData mergeable_data_object_data = 3; } // This is the mergeable data object itself and has a lot of entries that are the parts of it // along with arrays of key, type, and UUID items, depending on type. message MergeableDataObjectData { repeated MergeableDataObjectEntry mergeable_data_object_entry = 3; repeated string mergeable_data_object_key_item = 4; repeated string mergeable_data_object_type_item = 5; repeated bytes mergeable_data_object_uuid_item = 6; } // Each entry is part of the pbject. For example, one entry might be identifying which // UUIDs are rows, and another might hold the text of a cell. message MergeableDataObjectEntry { optional RegisterLatest register_latest = 1; optional List list = 5; optional Dictionary dictionary = 6; optional UnknownMergeableDataObjectEntryMessage unknown_message = 9; optional Note note = 10; optional MergeableDataObjectMap custom_map = 13; optional OrderedSet ordered_set = 16; } // This is unknown, it first was noticed in folder order analysis. message UnknownMergeableDataObjectEntryMessage { optional UnknownMergeableDataObjectEntryMessageEntry unknown_entry = 1; } // This is unknown, it first was noticed in folder order analysis. // "unknown_int2" is where the folder order is stored message UnknownMergeableDataObjectEntryMessageEntry { optional int32 unknown_int1 = 1; optional int64 unknown_int2 = 2; } // The Object Map uses its type to identify what you are looking at and // then a map entry to do something with that value. message MergeableDataObjectMap { optional int32 type = 1; repeated MapEntry map_entry = 3; } // An ordered set is used to hold structural information for embedded tables message OrderedSet { optional OrderedSetOrdering ordering = 1; optional Dictionary elements = 2; } // The ordered set ordering identifies rows and columns in embedded tables, with an array // of the objects and contents that map lookup values to originals. message OrderedSetOrdering { optional OrderedSetOrderingArray array = 1; optional Dictionary contents = 2; } // This array holds both the text to replace and the array of UUIDs to tell what // embedded rows and columns are. message OrderedSetOrderingArray { optional Note contents = 1; repeated OrderedSetOrderingArrayAttachment attachment = 2; } // This array identifies the UUIDs that are embedded table rows or columns message OrderedSetOrderingArrayAttachment { optional int32 index = 1; optional bytes uuid = 2; } // A List holds details about multiple objects message List { repeated ListEntry list_entry = 1; } // A list Entry holds details about a specific object message ListEntry { optional ObjectID id = 2; optional ListEntryDetails details = 3; // I dislike this naming, but don't have better information optional ListEntryDetails additional_details = 4; } // List Entry Details hold another object ID and unidentified mapping message ListEntryDetails { optional ListEntryDetailsKey list_entry_details_key= 1; optional ObjectID id = 2; } message ListEntryDetailsKey { optional int32 list_entry_details_type_index = 1; optional int32 list_entry_details_key = 2; } ``` -------------------------------------------------------------------------------- /src/apple_notes_mcp/proto/notestore_pb2.py: -------------------------------------------------------------------------------- ```python # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: notestore.proto # Protobuf Python Version: 5.29.0 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder _runtime_version.ValidateProtobufRuntimeVersion( _runtime_version.Domain.PUBLIC, 5, 29, 0, '', 'notestore.proto' ) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() 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') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'notestore_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_COLOR']._serialized_start=41 _globals['_COLOR']._serialized_end=105 _globals['_ATTACHMENTINFO']._serialized_start=107 _globals['_ATTACHMENTINFO']._serialized_end=172 _globals['_FONT']._serialized_start=174 _globals['_FONT']._serialized_end=239 _globals['_PARAGRAPHSTYLE']._serialized_start=242 _globals['_PARAGRAPHSTYLE']._serialized_end=376 _globals['_CHECKLIST']._serialized_start=378 _globals['_CHECKLIST']._serialized_end=417 _globals['_DICTIONARYELEMENT']._serialized_start=419 _globals['_DICTIONARYELEMENT']._serialized_end=530 _globals['_DICTIONARY']._serialized_start=532 _globals['_DICTIONARY']._serialized_end=602 _globals['_OBJECTID']._serialized_start=604 _globals['_OBJECTID']._serialized_end=690 _globals['_REGISTERLATEST']._serialized_start=692 _globals['_REGISTERLATEST']._serialized_end=758 _globals['_MAPENTRY']._serialized_start=760 _globals['_MAPENTRY']._serialized_end=830 _globals['_ATTRIBUTERUN']._serialized_start=833 _globals['_ATTRIBUTERUN']._serialized_end=1174 _globals['_NOTESTOREPROTO']._serialized_start=1176 _globals['_NOTESTOREPROTO']._serialized_end=1242 _globals['_DOCUMENT']._serialized_start=1244 _globals['_DOCUMENT']._serialized_end=1313 _globals['_NOTE']._serialized_start=1315 _globals['_NOTE']._serialized_end=1399 _globals['_MERGABLEDATAPROTO']._serialized_start=1401 _globals['_MERGABLEDATAPROTO']._serialized_end=1492 _globals['_MERGABLEDATAOBJECT']._serialized_start=1494 _globals['_MERGABLEDATAOBJECT']._serialized_end=1614 _globals['_MERGEABLEDATAOBJECTDATA']._serialized_start=1617 _globals['_MERGEABLEDATAOBJECTDATA']._serialized_end=1849 _globals['_MERGEABLEDATAOBJECTENTRY']._serialized_start=1852 _globals['_MERGEABLEDATAOBJECTENTRY']._serialized_end=2287 _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGE']._serialized_start=2290 _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGE']._serialized_end=2420 _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGEENTRY']._serialized_start=2422 _globals['_UNKNOWNMERGEABLEDATAOBJECTENTRYMESSAGEENTRY']._serialized_end=2511 _globals['_MERGEABLEDATAOBJECTMAP']._serialized_start=2513 _globals['_MERGEABLEDATAOBJECTMAP']._serialized_end=2602 _globals['_ORDEREDSET']._serialized_start=2604 _globals['_ORDEREDSET']._serialized_end=2728 _globals['_ORDEREDSETORDERING']._serialized_start=2731 _globals['_ORDEREDSETORDERING']._serialized_end=2865 _globals['_ORDEREDSETORDERINGARRAY']._serialized_start=2868 _globals['_ORDEREDSETORDERINGARRAY']._serialized_end=3016 _globals['_ORDEREDSETORDERINGARRAYATTACHMENT']._serialized_start=3018 _globals['_ORDEREDSETORDERINGARRAYATTACHMENT']._serialized_end=3082 _globals['_LIST']._serialized_start=3084 _globals['_LIST']._serialized_end=3143 _globals['_LISTENTRY']._serialized_start=3146 _globals['_LISTENTRY']._serialized_end=3326 _globals['_LISTENTRYDETAILS']._serialized_start=3329 _globals['_LISTENTRYDETAILS']._serialized_end=3466 _globals['_LISTENTRYDETAILSKEY']._serialized_start=3468 _globals['_LISTENTRYDETAILSKEY']._serialized_end=3560 # @@protoc_insertion_point(module_scope) ``` -------------------------------------------------------------------------------- /src/apple_notes_mcp/server.py: -------------------------------------------------------------------------------- ```python import logging from mcp.server.models import InitializationOptions import mcp.types as types from mcp.server import NotificationOptions, Server from pydantic import AnyUrl import mcp.server.stdio from .notes_database import NotesDatabase import zlib from .proto.notestore_pb2 import NoteStoreProto from importlib import metadata # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("apple-notes-mcp") notes_db = None server = Server("apple-notes-mcp") def decode_note_content(content: bytes | None) -> str: """ Decode note content from Apple Notes binary format using protobuf decoder. Uses schema from: https://github.com/HamburgChimps/apple-notes-liberator """ if not content: return "Note has no content" try: # First decompress gzip if content.startswith(b"\x1f\x8b"): decompressed = zlib.decompress(content, 16 + zlib.MAX_WBITS) note_store = NoteStoreProto() note_store.ParseFromString(decompressed) # Extract note text and formatting if note_store.document and note_store.document.note: note = note_store.document.note # Start with the basic text output = [note.note_text] # Add formatting information if available # Might not need this for LLM needs if note.attribute_run: output.append("\nFormatting:") for run in note.attribute_run: fmt = [] if run.font_weight: fmt.append(f"weight: {run.font_weight}") if run.underlined: fmt.append("underlined") if run.strikethrough: fmt.append("strikethrough") if run.paragraph_style and run.paragraph_style.style_type != -1: fmt.append(f"style: {run.paragraph_style.style_type}") if fmt: output.append(f"- length {run.length}: {', '.join(fmt)}") return "\n".join(output) return "No note content found" except Exception as e: return f"Error processing note content: {str(e)}" @server.list_resources() async def handle_list_resources() -> list[types.Resource]: """List all notes as resources""" all_notes = notes_db.get_all_notes() return [ types.Resource( uri=f"notes://local/{note['pk']}", # Using primary key in URI name=note["title"], description=f"Note in {note['folder']} - Last modified: {note['modifiedAt']}", metadata={ "folder": note["folder"], "modified": note["modifiedAt"], "locked": note["locked"], "pinned": note["pinned"], "hasChecklist": note["checklist"], }, mimeType="text/plain", ) for note in all_notes ] @server.read_resource() async def handle_read_resource(uri: AnyUrl) -> str: """ Read a specific note's content Mostly from reading https://ciofecaforensics.com/2020/09/18/apple-notes-revisited-protobuf/ and I found a gist https://gist.github.com/paultopia/b8a0400cd8406ff85969b722d3a2ebd8 """ if not str(uri).startswith("notes://"): raise ValueError(f"Unsupported URI scheme: {uri}") try: note_id = str(uri).split("/")[-1] note = notes_db.get_note_content(note_id) if not note: raise ValueError(f"Note not found: {note_id}") # Format metadata and content as text output = [] output.append(f"Title: {note['title']}") output.append(f"Folder: {note['folder']}") output.append(f"Modified: {note['modifiedAt']}") output.append("") # Empty line between metadata and content decoded = decode_note_content(note["content"]) if isinstance(decoded, dict): # Here we could convert formatting to markdown or other rich text format # For now just return the plain text output.append(decoded["text"]) else: output.append(decoded) return "\n".join(output) except Exception as e: raise RuntimeError(f"Notes database error: {str(e)}") @server.list_prompts() async def handle_list_prompts() -> list[types.Prompt]: """ List available prompts. Each prompt can have optional arguments to customize its behavior. """ return [ types.Prompt( name="find-note", description="Find notes matching specific criteria", arguments=[ types.PromptArgument( name="query", description="What kind of note are you looking for?", required=True, ), types.PromptArgument( name="folder", description="Specific folder to search in", required=False, ), ], ) ] @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """ List available tools. Each tool specifies its arguments using JSON Schema validation. """ return [ types.Tool( name="get-all-notes", description="Get all notes", inputSchema={ "type": "object", "properties": {}, }, ), types.Tool( name="read-note", description="Get full content of a specific note", inputSchema={ "type": "object", "properties": { "note_id": { "type": "string", "description": "ID of the note to read", }, }, "required": ["note_id"], }, ), types.Tool( name="search-notes", description="Search through notes", inputSchema={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, }, "required": ["query"], }, ), ] @server.call_tool() async def handle_call_tool( name: str, arguments: dict | None ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: """ Handle tool execution requests. Tools can modify server state and notify clients of changes. """ if name == "search-notes": query = arguments.get("query") results = notes_db.search_notes(query) return [ types.TextContent( type="text", text=f"Found {len(results)} notes:\n" + "\n".join( f"- {note['title']} [ID: {note['pk']}]" for note in results ), ) ] elif name == "get-all-notes": notes = notes_db.get_all_notes() return [ types.TextContent( type="text", text="All notes:\n" + "\n".join(f"- {note['title']}" for note in notes), ) ] elif name == "read-note": note_id = arguments.get("note_id") note = notes_db.get_note_content(note_id) if note: decoded_content = decode_note_content(note["content"]) return [ types.TextContent( type="text", text=f"Title: {note['title']}\n" f"Modified: {note['modifiedAt']}\n" f"Folder: {note['folder']}\n" f"\nContent:\n{decoded_content}", ) ] return [types.TextContent(type="text", text="Note not found")] else: raise ValueError(f"Unknown tool: {name}") # Notify clients that resources have changed # do this when we start handling updates to notes await server.request_context.session.send_resource_list_changed() @server.get_prompt() async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: """Generate a prompt for finding notes""" if name != "find-note": raise ValueError(f"Unknown prompt: {name}") query = arguments.get("query", "") results = notes_db.search_notes(query) notes_context = "\n".join( f"- {note['title']}: {note['snippet']}" for note in results ) return types.GetPromptResult( description=f"Found {len(results)} notes matching '{query}'", messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Here are the notes that match your query:\n\n{notes_context}\n\n" f"Which note would you like to read?", ), ) ], resources=[ types.Resource( uri=f"notes://local/{note['pk']}", name=note["title"], description=note["snippet"], metadata={"folder": note["folder"], "modified": note["modifiedAt"]}, ) for note in results ], ) async def main(db_path: str | None = None): # Run the server using stdin/stdout streams logger.info(f"Starting MCP server with db_path: {db_path}") global notes_db notes_db = NotesDatabase(db_path) if db_path else NotesDatabase() # Get the distribution info from the package dist = metadata.distribution("apple-notes-mcp") async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, InitializationOptions( server_name=dist.metadata["Name"], server_version=dist.version, capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, ), ), ) ``` -------------------------------------------------------------------------------- /src/apple_notes_mcp/notes_database.py: -------------------------------------------------------------------------------- ```python import sqlite3 import logging from contextlib import closing from pathlib import Path from typing import Any, List, Dict import os import stat logger = logging.getLogger(__name__) class NotesDatabase: def __init__( self, db_path: str = "~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite", ): self.db_path = str(Path(db_path).expanduser()) self._validate_path() self._init_database() def _validate_path(self): """Validate database path and permissions""" db_path = Path(self.db_path) if not db_path.exists(): raise FileNotFoundError( f"Notes database not found at: {self.db_path}\n" "Please verify the path and ensure you have granted Full Disk Access" ) # Check file permissions try: mode = os.stat(self.db_path).st_mode readable = bool(mode & stat.S_IRUSR) if not readable: raise PermissionError( f"No read permission for database at: {self.db_path}\n" "Please check file permissions and Full Disk Access settings" ) except OSError as e: raise PermissionError( f"Cannot access database at {self.db_path}: {str(e)}\n" "You may need to grant Full Disk Access permission in System Preferences" ) def _init_database(self): logger.debug("Initializing database connection") logger.info(f"Initializing database with path: {self.db_path}") try: with closing(sqlite3.connect(self.db_path)) as conn: conn.row_factory = sqlite3.Row # Verify we can access key Apple Notes tables cursor = conn.cursor() cursor.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='ZICCLOUDSYNCINGOBJECT'" ) if not cursor.fetchone(): raise ValueError( "This doesn't appear to be an Apple Notes database - missing required tables" ) except sqlite3.Error as e: if "database is locked" in str(e): raise RuntimeError( f"Database is locked: {self.db_path}\n" "Please close Notes app or any other applications that might be accessing it" ) elif "unable to open" in str(e): raise PermissionError( f"Cannot open database: {self.db_path}\n" "Please verify:\n" "1. You have granted Full Disk Access permission\n" "2. The file exists and is readable\n" "3. The Notes app is not exclusively locking the database" ) else: logger.error(f"Failed to initialize database: {e}") raise def _execute_query( self, query: str, params: dict[str, Any] | None = None ) -> list[dict[str, Any]]: """Execute a SQL query and return results as a list of dictionaries""" logger.debug(f"Executing query: {query}") try: with closing(sqlite3.connect(self.db_path)) as conn: conn.row_factory = sqlite3.Row with closing(conn.cursor()) as cursor: if params: cursor.execute(query, params) else: cursor.execute(query) results = [dict(row) for row in cursor.fetchall()] logger.debug(f"Query returned {len(results)} rows") return results except sqlite3.Error as e: logger.error(f"Database error executing query: {e}") raise def get_all_notes(self) -> List[Dict[str, Any]]: """Retrieve all notes with their metadata""" query = """ SELECT 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id, note.z_pk AS pk, note.ztitle1 AS title, folder.ztitle2 AS folder, datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt, note.zsnippet AS snippet, acc.zname AS account, note.zidentifier AS UUID, (note.zispasswordprotected = 1) as locked, (note.zispinned = 1) as pinned, (note.zhaschecklist = 1) as checklist, (note.zhaschecklistinprogress = 1) as checklistInProgress FROM ziccloudsyncingobject AS note INNER JOIN ziccloudsyncingobject AS folder ON note.zfolder = folder.z_pk LEFT JOIN ziccloudsyncingobject AS acc ON note.zaccount4 = acc.z_pk LEFT JOIN z_metadata AS zmd ON 1=1 WHERE note.ztitle1 IS NOT NULL AND note.zmodificationdate1 IS NOT NULL AND note.z_pk IS NOT NULL AND note.zmarkedfordeletion != 1 AND folder.zmarkedfordeletion != 1 ORDER BY note.zmodificationdate1 DESC """ results = self._execute_query(query) return results def get_note_by_title(self, title: str) -> Dict[str, Any] | None: """Retrieve a specific note by its title including content and metadata""" query = """ SELECT 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id, note.z_pk AS pk, note.ztitle1 AS title, folder.ztitle2 AS folder, datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt, datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt, note.zsnippet AS snippet, notedata.zdata AS content, acc.zname AS account, note.zidentifier AS UUID, (note.zispasswordprotected = 1) as locked, (note.zispinned = 1) as pinned, (note.zhaschecklist = 1) as checklist, (note.zhaschecklistinprogress = 1) as checklistInProgress FROM ziccloudsyncingobject AS note INNER JOIN ziccloudsyncingobject AS folder ON note.zfolder = folder.z_pk LEFT JOIN ziccloudsyncingobject AS acc ON note.zaccount4 = acc.z_pk LEFT JOIN zicnotedata AS notedata ON note.znotedata = notedata.z_pk LEFT JOIN z_metadata AS zmd ON 1=1 WHERE note.ztitle1 = ? AND note.zmarkedfordeletion != 1 AND folder.zmarkedfordeletion != 1 LIMIT 1 """ results = self._execute_query(query, (title,)) return results[0] if results else None def search_notes(self, query_text: str) -> List[Dict[str, Any]]: """Search notes by title, content, or snippet with ranking by relevance""" query = """ SELECT 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id, note.z_pk AS pk, note.ztitle1 AS title, folder.ztitle2 AS folder, datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt, datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt, note.zsnippet AS snippet, notedata.zdata AS content, acc.zname AS account, note.zidentifier AS UUID, (note.zispasswordprotected = 1) as locked, (note.zispinned = 1) as pinned, (note.zhaschecklist = 1) as checklist, (note.zhaschecklistinprogress = 1) as checklistInProgress, CASE WHEN note.ztitle1 LIKE ? THEN 3 WHEN note.zsnippet LIKE ? THEN 2 WHEN notedata.zdata LIKE ? THEN 1 ELSE 0 END as relevance FROM ziccloudsyncingobject AS note INNER JOIN ziccloudsyncingobject AS folder ON note.zfolder = folder.z_pk LEFT JOIN ziccloudsyncingobject AS acc ON note.zaccount4 = acc.z_pk LEFT JOIN zicnotedata AS notedata ON note.znotedata = notedata.z_pk LEFT JOIN z_metadata AS zmd ON 1=1 WHERE note.zmarkedfordeletion != 1 AND folder.zmarkedfordeletion != 1 AND (note.ztitle1 LIKE ? OR note.zsnippet LIKE ? OR notedata.zdata LIKE ?) ORDER BY relevance DESC, note.zmodificationdate1 DESC """ search_pattern = f"%{query_text}%" # We need 6 parameters because the pattern is used twice in the query # 3 times for relevance scoring and 3 times for WHERE clause params = (search_pattern,) * 6 return self._execute_query(query, params) def get_note_content(self, note_id: str) -> Dict[str, Any] | None: """ Retrieve full note content and metadata by note ID This note ID is provided by the resource URI inside Claude """ query = """ SELECT 'x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk AS id, note.z_pk AS pk, note.ztitle1 AS title, folder.ztitle2 AS folder, datetime(note.zmodificationdate1 + 978307200, 'unixepoch') AS modifiedAt, datetime(note.zcreationdate1 + 978307200, 'unixepoch') AS createdAt, note.zsnippet AS snippet, notedata.zdata AS content, acc.zname AS account, note.zidentifier AS UUID, (note.zispasswordprotected = 1) as locked, (note.zispinned = 1) as pinned FROM ziccloudsyncingobject AS note INNER JOIN ziccloudsyncingobject AS folder ON note.zfolder = folder.z_pk LEFT JOIN ziccloudsyncingobject AS acc ON note.zaccount4 = acc.z_pk LEFT JOIN zicnotedata AS notedata ON note.znotedata = notedata.z_pk LEFT JOIN z_metadata AS zmd ON 1=1 WHERE note.z_pk = ? AND note.zmarkedfordeletion != 1 AND folder.zmarkedfordeletion != 1 LIMIT 1 """ results = self._execute_query(query, (note_id,)) return results[0] if results else None ```