#
tokens: 11030/50000 11/11 files
lines: off (toggle) GitHub
raw markdown copy
# 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

```