MCP Browser Automation Server
by hrmeetsingh
- src
- apple_notes_mcp
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={},
),
),
)