@thenavidm/apple-photos-mcp-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/SKILL.md +149 -0
- package/package.json +35 -0
- package/pyproject.toml +79 -0
- package/src/apple_photos_mcp/__init__.py +3 -0
- package/src/apple_photos_mcp/__main__.py +40 -0
- package/src/apple_photos_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/__main__.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/config.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/doctor.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/library.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/previews.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/safety.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/search.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/server.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/writes.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/config.py +82 -0
- package/src/apple_photos_mcp/doctor.py +162 -0
- package/src/apple_photos_mcp/library.py +385 -0
- package/src/apple_photos_mcp/previews.py +140 -0
- package/src/apple_photos_mcp/safety.py +72 -0
- package/src/apple_photos_mcp/search.py +283 -0
- package/src/apple_photos_mcp/server.py +352 -0
- package/src/apple_photos_mcp/writes.py +225 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thenavidm/apple-photos-mcp-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Apple Photos MCP server and CLI for Claude Code and AI agents. 13 tools to search, look at, organise and export your own Photos library, entirely on your Mac.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"apple-photos-mcp": "dist/index.js",
|
|
9
|
+
"apple-photos-cli": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"engines": { "node": ">=20" },
|
|
12
|
+
"os": ["darwin"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.10.0",
|
|
25
|
+
"typescript": "^5.7.0",
|
|
26
|
+
"vitest": "^2.1.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Navid Moazzez (https://navid.me)",
|
|
30
|
+
"homepage": "https://navid.me",
|
|
31
|
+
"repository": { "type": "git", "url": "git+https://github.com/navidmoazzez/apple-photos-mcp.git" },
|
|
32
|
+
"bugs": { "url": "https://github.com/navidmoazzez/apple-photos-mcp/issues" },
|
|
33
|
+
"keywords": ["mcp","model-context-protocol","apple-photos","photos","macos","mcp-server","claude","claude-code","ai-agents","llm-tools","cli"],
|
|
34
|
+
"files": ["dist","src","pyproject.toml","README.md","SKILL.md","LICENSE"]
|
|
35
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# PyPI has no scopes, so an owner prefix is the closest equivalent to the
|
|
3
|
+
# @thenavidm npm scope the TypeScript servers use. Applied even though the bare
|
|
4
|
+
# name is free, so both Python packages are named the same way. The installed
|
|
5
|
+
# command is still `apple-photos-mcp`.
|
|
6
|
+
name = "thenavidm-apple-photos-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Apple Photos MCP server for Claude Code and AI agents. Photo search, scene labels, text in images, faces, places, albums, previews, and exports."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Navid Moazzez" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "apple-photos", "photos", "macos", "osxphotos", "claude", "claude-code", "ai-agents", "llm-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: MacOS :: MacOS X",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Multimedia :: Graphics",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=1.2.0",
|
|
27
|
+
# macOS only. Both pull in pyobjc, which refuses to build anywhere else:
|
|
28
|
+
# "error: PyObjC requires macOS to build". Without these markers a plain
|
|
29
|
+
# `pip install` fails on Linux instead of installing and then telling the
|
|
30
|
+
# user this server needs a Mac.
|
|
31
|
+
"osxphotos>=0.72; sys_platform == 'darwin'",
|
|
32
|
+
"photoscript>=0.3.0; sys_platform == 'darwin'",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/navidmoazzez/apple-photos-mcp"
|
|
37
|
+
Issues = "https://github.com/navidmoazzez/apple-photos-mcp/issues"
|
|
38
|
+
Author = "https://navid.me"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
apple-photos-mcp = "apple_photos_mcp.__main__:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["hatchling"]
|
|
45
|
+
build-backend = "hatchling.build"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/apple_photos_mcp"]
|
|
49
|
+
|
|
50
|
+
# SKILL.md ships inside the package so an agent that installs the server also
|
|
51
|
+
# learns how to drive it. The README teaches a human to install it.
|
|
52
|
+
force-include = { "SKILL.md" = "apple_photos_mcp/SKILL.md" }
|
|
53
|
+
|
|
54
|
+
[dependency-groups]
|
|
55
|
+
dev = ["pytest>=8.0", "ruff>=0.6"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 100
|
|
59
|
+
target-version = "py311"
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = ["E", "F", "W", "I", "B", "SIM", "UP", "PL", "RUF"]
|
|
63
|
+
ignore = [
|
|
64
|
+
# An MCP tool must never take the server down. Every tool body catches
|
|
65
|
+
# broadly on purpose and returns the error to the model as data, which is the
|
|
66
|
+
# only behavior a client can do anything useful with.
|
|
67
|
+
"BLE001",
|
|
68
|
+
"PLR0913", # tool signatures are wide because search filters are wide
|
|
69
|
+
"PLR2004", # score tuning constants read better inline than as names
|
|
70
|
+
"PLR0917", # search_photos takes many filters on purpose; they are the API
|
|
71
|
+
# osxphotos and photoscript are slow to import and pull in native deps. They
|
|
72
|
+
# are imported at point of use so the server starts fast and so `doctor` can
|
|
73
|
+
# still explain itself on a machine where they cannot load at all.
|
|
74
|
+
"PLC0415",
|
|
75
|
+
# Filters.keep, the ranking loop and the tool registry are each one long flat
|
|
76
|
+
# sequence by design. Splitting them into helpers to satisfy a counter would
|
|
77
|
+
# scatter logic that is only readable when read top to bottom.
|
|
78
|
+
"PLR0911", "PLR0912", "PLR0915", "SIM103",
|
|
79
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Entry point: ``apple-photos-mcp`` (stdio) and ``apple-photos-mcp doctor``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
# stderr only. Anything on stdout corrupts the JSON-RPC stream.
|
|
14
|
+
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
|
15
|
+
|
|
16
|
+
args = sys.argv[1:]
|
|
17
|
+
config = Config.from_env()
|
|
18
|
+
|
|
19
|
+
if args and args[0] in {"-v", "--version"}:
|
|
20
|
+
from . import __version__
|
|
21
|
+
|
|
22
|
+
print(__version__)
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
if args and args[0] == "doctor":
|
|
26
|
+
from . import doctor as doctor_mod
|
|
27
|
+
from .library import PhotosLibrary
|
|
28
|
+
|
|
29
|
+
report = doctor_mod.run(config, PhotosLibrary(config))
|
|
30
|
+
print(json.dumps(report, indent=2, default=str))
|
|
31
|
+
return 0 if report["ok"] else 1
|
|
32
|
+
|
|
33
|
+
from .server import build_server
|
|
34
|
+
|
|
35
|
+
build_server(config).run()
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Configuration, resolved once from the environment.
|
|
2
|
+
|
|
3
|
+
Settings are environment variables, not CLI flags, because a user editing a
|
|
4
|
+
client config is already inside a JSON `env` block and flags mean editing
|
|
5
|
+
`args` separately.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
ENV_PREFIX = "APPLE_PHOTOS_"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _flag(name: str, default: bool = False) -> bool:
|
|
18
|
+
raw = os.environ.get(ENV_PREFIX + name)
|
|
19
|
+
if raw is None:
|
|
20
|
+
return default
|
|
21
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _int(name: str, default: int) -> int:
|
|
25
|
+
try:
|
|
26
|
+
return int(os.environ.get(ENV_PREFIX + name, "").strip())
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _path(name: str) -> Path | None:
|
|
32
|
+
raw = os.environ.get(ENV_PREFIX + name)
|
|
33
|
+
return Path(raw).expanduser() if raw else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
"""Resolved server configuration."""
|
|
39
|
+
|
|
40
|
+
#: Explicit .photoslibrary path. ``None`` means "whichever Photos opened last".
|
|
41
|
+
library: Path | None = None
|
|
42
|
+
|
|
43
|
+
#: Removes every write tool from the tool list rather than erroring when one
|
|
44
|
+
#: is called. A model cannot call a tool it cannot see, and an error is an
|
|
45
|
+
#: invitation to retry differently. This is the real defense for an agent
|
|
46
|
+
#: working unattended.
|
|
47
|
+
read_only: bool = False
|
|
48
|
+
|
|
49
|
+
#: One JSON line per attempted write, allowed and blocked alike.
|
|
50
|
+
audit_log: Path | None = None
|
|
51
|
+
|
|
52
|
+
#: Where ``export_originals`` writes when the caller does not name a folder.
|
|
53
|
+
export_dir: Path = Path.home() / "Downloads" / "Photos Exports"
|
|
54
|
+
|
|
55
|
+
#: Where ``look_at_photos`` caches the previews it hands to the model.
|
|
56
|
+
preview_dir: Path = Path.home() / ".apple-photos-mcp" / "previews"
|
|
57
|
+
|
|
58
|
+
#: Longest edge, in pixels, of a preview. Small enough to stay cheap in context.
|
|
59
|
+
preview_px: int = 640
|
|
60
|
+
|
|
61
|
+
#: Most items a single ``look_at_photos`` call will render.
|
|
62
|
+
preview_max: int = 8
|
|
63
|
+
|
|
64
|
+
#: Most items any single write touches. A typo should not restructure a library.
|
|
65
|
+
write_batch_max: int = 100
|
|
66
|
+
|
|
67
|
+
#: Album that ``archive_photos`` moves items into. Apple forbids scripted deletion.
|
|
68
|
+
archive_album: str = "Archived by Claude"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_env(cls) -> Config:
|
|
72
|
+
return cls(
|
|
73
|
+
library=_path("LIBRARY"),
|
|
74
|
+
read_only=_flag("READ_ONLY"),
|
|
75
|
+
audit_log=_path("AUDIT_LOG"),
|
|
76
|
+
export_dir=_path("EXPORT_DIR") or cls.export_dir,
|
|
77
|
+
preview_dir=_path("PREVIEW_DIR") or cls.preview_dir,
|
|
78
|
+
preview_px=_int("PREVIEW_PX", cls.preview_px),
|
|
79
|
+
preview_max=_int("PREVIEW_MAX", cls.preview_max),
|
|
80
|
+
write_batch_max=_int("WRITE_BATCH_MAX", cls.write_batch_max),
|
|
81
|
+
archive_album=os.environ.get(ENV_PREFIX + "ARCHIVE_ALBUM") or cls.archive_album,
|
|
82
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Self-diagnosis.
|
|
2
|
+
|
|
3
|
+
Almost every failure with a Photos integration is one of four things, and all
|
|
4
|
+
four are invisible from inside a chat window: the wrong OS, a missing Full Disk
|
|
5
|
+
Access grant, a library that cannot be found, or writes being off when the user
|
|
6
|
+
expected them on. ``doctor`` names which one it is and what to click.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .library import PhotosLibrary
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check(name: str, ok: bool, detail: str, fix: str = "") -> dict[str, Any]:
|
|
22
|
+
out = {"check": name, "ok": ok, "detail": detail}
|
|
23
|
+
if not ok and fix:
|
|
24
|
+
out["fix"] = fix
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(config: Config, lib: PhotosLibrary) -> dict[str, Any]:
|
|
29
|
+
checks: list[dict[str, Any]] = []
|
|
30
|
+
|
|
31
|
+
is_mac = sys.platform == "darwin"
|
|
32
|
+
checks.append(
|
|
33
|
+
_check(
|
|
34
|
+
"macOS",
|
|
35
|
+
is_mac,
|
|
36
|
+
f"{platform.system()} {platform.mac_ver()[0] or platform.release()}",
|
|
37
|
+
"Apple Photos exists only on macOS. This server cannot run anywhere else.",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
checks.append(
|
|
42
|
+
_check(
|
|
43
|
+
"python",
|
|
44
|
+
sys.version_info >= (3, 11),
|
|
45
|
+
f"Python {platform.python_version()}",
|
|
46
|
+
"Python 3.11 or newer is required.",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
checks.append(
|
|
51
|
+
_check(
|
|
52
|
+
"sips",
|
|
53
|
+
shutil.which("sips") is not None,
|
|
54
|
+
"sips is used to render previews and ships with macOS",
|
|
55
|
+
"sips is missing, which should be impossible on a healthy macOS install.",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Locating the library.
|
|
60
|
+
lib_path: Path | None = None
|
|
61
|
+
try:
|
|
62
|
+
lib_path = lib._library_path()
|
|
63
|
+
found = lib_path.exists()
|
|
64
|
+
checks.append(
|
|
65
|
+
_check(
|
|
66
|
+
"library found",
|
|
67
|
+
found,
|
|
68
|
+
str(lib_path),
|
|
69
|
+
"Open Photos once so macOS records a library path, or set "
|
|
70
|
+
"APPLE_PHOTOS_MCP_LIBRARY to a .photoslibrary folder.",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
checks.append(
|
|
75
|
+
_check(
|
|
76
|
+
"library found",
|
|
77
|
+
False,
|
|
78
|
+
str(exc),
|
|
79
|
+
"Set APPLE_PHOTOS_MCP_LIBRARY to your .photoslibrary path.",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Full Disk Access is the single most common failure, and it presents as a
|
|
84
|
+
# permission error on the library's SQLite rather than as anything obvious.
|
|
85
|
+
readable = False
|
|
86
|
+
detail = "not attempted"
|
|
87
|
+
if lib_path and lib_path.exists():
|
|
88
|
+
db = lib_path / "database" / "Photos.sqlite"
|
|
89
|
+
try:
|
|
90
|
+
with open(db, "rb") as fh:
|
|
91
|
+
fh.read(16)
|
|
92
|
+
readable = True
|
|
93
|
+
detail = "library database is readable"
|
|
94
|
+
except PermissionError:
|
|
95
|
+
detail = "permission denied reading the library database"
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
detail = "library database not found at the expected path"
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
detail = str(exc)
|
|
100
|
+
checks.append(
|
|
101
|
+
_check(
|
|
102
|
+
"full disk access",
|
|
103
|
+
readable,
|
|
104
|
+
detail,
|
|
105
|
+
"Give the app that runs this server Full Disk Access: System Settings > "
|
|
106
|
+
"Privacy & Security > Full Disk Access. For Claude Desktop add Claude; for "
|
|
107
|
+
"a terminal add Terminal or iTerm. Then fully quit and reopen it.",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Index.
|
|
112
|
+
if readable:
|
|
113
|
+
try:
|
|
114
|
+
stats = lib.stats()
|
|
115
|
+
checks.append(
|
|
116
|
+
_check(
|
|
117
|
+
"index",
|
|
118
|
+
True,
|
|
119
|
+
f"{stats['total']} assets indexed "
|
|
120
|
+
f"({stats['with_ml_labels']} with ML labels, "
|
|
121
|
+
f"{stats['with_text_in_image']} with readable text)",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if stats["not_downloaded_to_mac"]:
|
|
125
|
+
checks.append(
|
|
126
|
+
_check(
|
|
127
|
+
"icloud",
|
|
128
|
+
True,
|
|
129
|
+
f"{stats['not_downloaded_to_mac']} of {stats['total']} assets live "
|
|
130
|
+
f"in iCloud and have no full-size local file. Previews still work "
|
|
131
|
+
f"(they come from Apple's own thumbnails); exporting an original "
|
|
132
|
+
f"downloads it first and is slower.",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
checks.append(
|
|
137
|
+
_check("index", False, str(exc), "Run doctor again after opening Photos once.")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
checks.append(
|
|
141
|
+
_check(
|
|
142
|
+
"writes",
|
|
143
|
+
True,
|
|
144
|
+
"read-only (APPLE_PHOTOS_READ_ONLY is set)" if config.read_only else "enabled",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
checks[-1]["note"] = (
|
|
148
|
+
"Write tools are hidden in read-only mode. Unset APPLE_PHOTOS_READ_ONLY to "
|
|
149
|
+
"restore them."
|
|
150
|
+
if config.read_only
|
|
151
|
+
else "Organizing works. archive_photos asks for confirm first, and nothing "
|
|
152
|
+
"can permanently delete a photo."
|
|
153
|
+
)
|
|
154
|
+
if config.audit_log:
|
|
155
|
+
checks.append(_check("audit log", True, str(config.audit_log)))
|
|
156
|
+
|
|
157
|
+
failed = [c for c in checks if not c["ok"]]
|
|
158
|
+
return {
|
|
159
|
+
"ok": not failed,
|
|
160
|
+
"summary": "All checks passed." if not failed else f"{len(failed)} check(s) failed.",
|
|
161
|
+
"checks": checks,
|
|
162
|
+
}
|