@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/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,3 @@
1
+ """MCP server for the macOS Apple Photos library."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -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
+ }