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