@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.
@@ -0,0 +1,385 @@
1
+ """Reading the Photos library.
2
+
3
+ Photos keeps far more than filenames. Every asset carries an on-device machine
4
+ learning record: scene labels, text read out of the image, the kind of activity
5
+ it looks like, the kind of venue it was taken at, and a reverse-geocoded place.
6
+ All of it is written into the library's SQLite by Apple, on the Mac, and none of
7
+ it needs Photos.app to be running to read.
8
+
9
+ ``osxphotos`` exposes that record. This module turns it into one flat, cheap
10
+ document per asset so a query can be answered without walking 37,000 objects
11
+ again. The document is cached on disk and rebuilt only when the library changes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import collections
17
+ import gzip
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import threading
22
+ import time
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .config import Config
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ #: Bumped whenever the shape of a cached document changes, so old caches are
33
+ #: discarded instead of being read with the wrong field names.
34
+ INDEX_VERSION = 3
35
+
36
+
37
+ @dataclass
38
+ class Asset:
39
+ """One photo or video, flattened to exactly what search and display need."""
40
+
41
+ uuid: str
42
+ filename: str
43
+ date: str | None = None
44
+ is_video: bool = False
45
+ favorite: bool = False
46
+ hidden: bool = False
47
+ in_trash: bool = False
48
+ screenshot: bool = False
49
+ selfie: bool = False
50
+ portrait: bool = False
51
+ live: bool = False
52
+ raw: bool = False
53
+ width: int = 0
54
+ height: int = 0
55
+ duration: float = 0.0
56
+ #: False when the asset lives only in iCloud and has no local pixels yet.
57
+ local: bool = True
58
+ title: str | None = None
59
+ description: str | None = None
60
+ keywords: list[str] = field(default_factory=list)
61
+ persons: list[str] = field(default_factory=list)
62
+ albums: list[str] = field(default_factory=list)
63
+ labels: list[str] = field(default_factory=list)
64
+ activities: list[str] = field(default_factory=list)
65
+ venues: list[str] = field(default_factory=list)
66
+ place: str | None = None
67
+ city: str | None = None
68
+ state: str | None = None
69
+ country: str | None = None
70
+ #: Words Apple's OCR read inside the image, lowercased and de-duplicated.
71
+ text: list[str] = field(default_factory=list)
72
+ #: Everything above, lowercased and joined. Built once; never serialized.
73
+ haystack: str = ""
74
+
75
+ def summary(self) -> dict[str, Any]:
76
+ """The compact form returned in search results."""
77
+ out: dict[str, Any] = {
78
+ "uuid": self.uuid,
79
+ "filename": self.filename,
80
+ "date": self.date,
81
+ "kind": "video" if self.is_video else "photo",
82
+ }
83
+ if self.title:
84
+ out["title"] = self.title
85
+ if self.persons:
86
+ out["persons"] = self.persons
87
+ if self.place:
88
+ out["place"] = self.place
89
+ if self.labels:
90
+ out["labels"] = self.labels[:8]
91
+ if self.favorite:
92
+ out["favorite"] = True
93
+ if not self.local:
94
+ out["icloud_only"] = True
95
+ return out
96
+
97
+ def details(self) -> dict[str, Any]:
98
+ """Everything known about the asset, for ``photo_info``."""
99
+ out = self.summary()
100
+ out.update(
101
+ {
102
+ "description": self.description,
103
+ "keywords": self.keywords,
104
+ "albums": self.albums,
105
+ "labels": self.labels,
106
+ "activities": self.activities,
107
+ "venues": self.venues,
108
+ "city": self.city,
109
+ "state": self.state,
110
+ "country": self.country,
111
+ "dimensions": f"{self.width}x{self.height}" if self.width else None,
112
+ "favorite": self.favorite,
113
+ "hidden": self.hidden,
114
+ "media": [
115
+ k
116
+ for k, v in (
117
+ ("screenshot", self.screenshot),
118
+ ("selfie", self.selfie),
119
+ ("portrait", self.portrait),
120
+ ("live", self.live),
121
+ ("raw", self.raw),
122
+ )
123
+ if v
124
+ ],
125
+ "downloaded_to_mac": self.local,
126
+ }
127
+ )
128
+ if self.is_video and self.duration:
129
+ out["duration_seconds"] = round(self.duration, 1)
130
+ if self.text:
131
+ out["text_in_image"] = self.text[:60]
132
+ return {k: v for k, v in out.items() if v not in (None, [], "")}
133
+
134
+
135
+ def _text_words(detected: Any) -> list[str]:
136
+ """Normalize osxphotos' OCR payload, which is a str on some versions and a
137
+ list on others, into a de-duplicated list of lowercase words."""
138
+ if not detected:
139
+ return []
140
+ if isinstance(detected, str):
141
+ parts: Iterable[str] = detected.replace("\n", " ").split()
142
+ else:
143
+ parts = []
144
+ for item in detected:
145
+ # Some versions yield (text, confidence) pairs instead of plain text.
146
+ value = item[0] if isinstance(item, (list, tuple)) and item else item
147
+ if isinstance(value, str):
148
+ parts.extend(value.split())
149
+ seen: dict[str, None] = {}
150
+ for word in parts:
151
+ w = word.strip().strip(".,:;!?\"'()[]{}").lower()
152
+ if len(w) > 1:
153
+ seen.setdefault(w, None)
154
+ return list(seen)
155
+
156
+
157
+ def _build_haystack(a: Asset) -> str:
158
+ bits = [
159
+ a.filename,
160
+ a.title or "",
161
+ a.description or "",
162
+ a.place or "",
163
+ a.city or "",
164
+ a.state or "",
165
+ a.country or "",
166
+ *a.keywords,
167
+ *a.persons,
168
+ *a.albums,
169
+ *a.labels,
170
+ *a.activities,
171
+ *a.venues,
172
+ *a.text,
173
+ ]
174
+ if a.screenshot:
175
+ bits.append("screenshot")
176
+ if a.selfie:
177
+ bits.append("selfie")
178
+ if a.portrait:
179
+ bits.append("portrait")
180
+ if a.is_video:
181
+ bits.append("video movie clip")
182
+ return " ".join(b for b in bits if b).lower()
183
+
184
+
185
+ class PhotosLibrary:
186
+ """Lazily loads the library, caches a flat index, and answers queries."""
187
+
188
+ def __init__(self, config: Config):
189
+ self.config = config
190
+ self._assets: list[Asset] | None = None
191
+ self._by_uuid: dict[str, Asset] = {}
192
+ self._lock = threading.Lock()
193
+ self._loaded_at: float = 0.0
194
+ self._source: str = ""
195
+ self._vocab: list[str] | None = None
196
+ self._structured: set[str] | None = None
197
+
198
+ # ---------------------------------------------------------------- loading
199
+
200
+ def _library_path(self) -> Path:
201
+ if self.config.library:
202
+ return self.config.library
203
+ import osxphotos
204
+
205
+ return Path(osxphotos.utils.get_last_library_path())
206
+
207
+ def _cache_file(self, lib: Path) -> Path:
208
+ key = hashlib.sha256(str(lib).encode()).hexdigest()[:12]
209
+ return self.config.preview_dir.parent / "index" / f"{key}-v{INDEX_VERSION}.json.gz"
210
+
211
+ def _library_stamp(self, lib: Path) -> float:
212
+ """Newest mtime across the library's SQLite files. Cheap staleness check."""
213
+ newest = 0.0
214
+ for name in ("database/Photos.sqlite", "database/photos.db"):
215
+ p = lib / name
216
+ if p.exists():
217
+ newest = max(newest, p.stat().st_mtime)
218
+ return newest or lib.stat().st_mtime
219
+
220
+ def load(self, force: bool = False) -> list[Asset]:
221
+ with self._lock:
222
+ if self._assets is not None and not force:
223
+ return self._assets
224
+ lib = self._library_path()
225
+ cache = self._cache_file(lib)
226
+ stamp = self._library_stamp(lib)
227
+
228
+ if not force and cache.exists():
229
+ try:
230
+ with gzip.open(cache, "rt", encoding="utf-8") as fh:
231
+ blob = json.load(fh)
232
+ if blob.get("stamp") == stamp:
233
+ assets = [Asset(**row) for row in blob["assets"]]
234
+ for a in assets:
235
+ a.haystack = _build_haystack(a)
236
+ self._install(assets, f"cache ({cache.name})")
237
+ return assets
238
+ except Exception as exc: # a bad cache must never be fatal
239
+ log.warning("ignoring unreadable index cache: %s", exc)
240
+
241
+ assets = self._scan(lib)
242
+ self._install(assets, "library scan")
243
+ try:
244
+ cache.parent.mkdir(parents=True, exist_ok=True)
245
+ payload = {
246
+ "stamp": stamp,
247
+ "assets": [
248
+ {k: v for k, v in a.__dict__.items() if k != "haystack"} for a in assets
249
+ ],
250
+ }
251
+ with gzip.open(cache, "wt", encoding="utf-8") as fh:
252
+ json.dump(payload, fh)
253
+ except Exception as exc:
254
+ log.warning("could not write index cache: %s", exc)
255
+ return assets
256
+
257
+ def _install(self, assets: list[Asset], source: str) -> None:
258
+ self._assets = assets
259
+ self._by_uuid = {a.uuid: a for a in assets}
260
+ self._loaded_at = time.time()
261
+ self._source = source
262
+
263
+ def _scan(self, lib: Path) -> list[Asset]:
264
+ import osxphotos
265
+
266
+ db = osxphotos.PhotosDB(str(lib))
267
+ out: list[Asset] = []
268
+ for p in db.photos(movies=True, intrash=False):
269
+ si = p.search_info
270
+ place = p.place.name if p.place else None
271
+ a = Asset(
272
+ uuid=p.uuid,
273
+ filename=p.original_filename or p.filename or "",
274
+ date=p.date.isoformat() if p.date else None,
275
+ is_video=bool(p.ismovie),
276
+ favorite=bool(p.favorite),
277
+ hidden=bool(p.hidden),
278
+ in_trash=bool(p.intrash),
279
+ screenshot=bool(p.screenshot),
280
+ selfie=bool(p.selfie),
281
+ portrait=bool(p.portrait),
282
+ live=bool(p.live_photo),
283
+ raw=bool(p.has_raw or p.israw),
284
+ width=int(p.width or 0),
285
+ height=int(p.height or 0),
286
+ duration=float(getattr(p, "duration", 0) or 0),
287
+ # `path` is None precisely when the asset has not been pulled
288
+ # down from iCloud. That is the majority in most libraries.
289
+ local=bool(p.path),
290
+ title=p.title or None,
291
+ description=p.description or None,
292
+ keywords=list(p.keywords or []),
293
+ persons=[x for x in (p.persons or []) if x and x != "_UNKNOWN_"],
294
+ albums=list(p.albums or []),
295
+ labels=list(p.labels or []),
296
+ activities=list(si.activities or []) if si else [],
297
+ venues=list((si.venue_types or []) + (si.venues or [])) if si else [],
298
+ place=place,
299
+ city=(si.city if si else None) or None,
300
+ state=(si.state if si else None) or None,
301
+ country=(si.country if si else None) or None,
302
+ text=_text_words(si.detected_text if si else None),
303
+ )
304
+ a.haystack = _build_haystack(a)
305
+ out.append(a)
306
+ return out
307
+
308
+ # ----------------------------------------------------------------- access
309
+
310
+ @property
311
+ def assets(self) -> list[Asset]:
312
+ return self.load()
313
+
314
+ def get(self, ref: str) -> Asset | None:
315
+ """Resolve a uuid, or fall back to an exact/basename filename match."""
316
+ self.load()
317
+ ref = ref.strip()
318
+ hit = self._by_uuid.get(ref) or self._by_uuid.get(ref.upper())
319
+ if hit:
320
+ return hit
321
+ low = ref.lower()
322
+ for a in self._assets or []:
323
+ if a.filename.lower() == low:
324
+ return a
325
+ return None
326
+
327
+ def vocabulary(self) -> list[str]:
328
+ """Every scene label, activity and venue type present in this library.
329
+
330
+ Apple's classifier has a closed vocabulary, so this is the definitive
331
+ list of visual words a search can actually hit. Cached after first use.
332
+ """
333
+ if self._vocab is None:
334
+ terms: set[str] = set()
335
+ for a in self.load():
336
+ terms.update(x.lower() for x in a.labels)
337
+ terms.update(x.lower() for x in a.activities)
338
+ terms.update(x.lower() for x in a.venues)
339
+ self._vocab = sorted(terms)
340
+ return self._vocab
341
+
342
+ def structured_terms(self) -> set[str]:
343
+ """Words that mean something because a human or Apple's geocoder put
344
+ them there: names, album titles, places, keywords, captions."""
345
+ if self._structured is None:
346
+ terms: set[str] = set()
347
+ for a in self.load():
348
+ for value in (
349
+ *a.persons, *a.albums, *a.keywords,
350
+ a.title or "", a.description or "",
351
+ a.place or "", a.city or "", a.state or "", a.country or "",
352
+ ):
353
+ for token in str(value).lower().replace(",", " ").split():
354
+ if len(token) > 1:
355
+ terms.add(token)
356
+ self._structured = terms
357
+ return self._structured
358
+
359
+ def label_counts(self) -> collections.Counter[str]:
360
+ import collections as _c
361
+
362
+ counts: _c.Counter[str] = _c.Counter()
363
+ for a in self.load():
364
+ for label in a.labels:
365
+ counts[label] += 1
366
+ return counts
367
+
368
+ def stats(self) -> dict[str, Any]:
369
+ assets = self.load()
370
+ videos = sum(1 for a in assets if a.is_video)
371
+ return {
372
+ "library": str(self._library_path()),
373
+ "total": len(assets),
374
+ "photos": len(assets) - videos,
375
+ "videos": videos,
376
+ "favorites": sum(1 for a in assets if a.favorite),
377
+ "screenshots": sum(1 for a in assets if a.screenshot),
378
+ "with_ml_labels": sum(1 for a in assets if a.labels),
379
+ "with_text_in_image": sum(1 for a in assets if a.text),
380
+ "with_place": sum(1 for a in assets if a.place or a.city),
381
+ "named_people": sorted({p for a in assets for p in a.persons}),
382
+ "albums": sorted({al for a in assets for al in a.albums}),
383
+ "not_downloaded_to_mac": sum(1 for a in assets if not a.local),
384
+ "index_source": self._source,
385
+ }
@@ -0,0 +1,140 @@
1
+ """Turning assets into small images the model can actually look at.
2
+
3
+ Search returns candidates, not answers. A ranked list of filenames is a guess
4
+ until something looks at the pixels, so this module renders a downscaled preview
5
+ and hands it back through MCP as an image the model sees directly.
6
+
7
+ Two things make this harder than it sounds:
8
+
9
+ 1. Most assets in a modern library are **not on the Mac**. iCloud keeps the
10
+ originals in the cloud and leaves a thumbnail behind. In the library this was
11
+ built against, 36,996 of 37,129 assets had no local file.
12
+ 2. Apple's own thumbnails live inside the library bundle as ``derivatives``.
13
+ Reading one is instant and needs no network, which makes it the right source
14
+ for "show me what this is" even when the original is a 48 MP HEIC in iCloud.
15
+
16
+ So previews come from the derivative when there is one, and fall back to the
17
+ original only when there is not.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from .config import Config
28
+ from .library import Asset, PhotosLibrary
29
+
30
+
31
+ @dataclass
32
+ class Preview:
33
+ uuid: str
34
+ filename: str
35
+ path: Path | None
36
+ error: str | None = None
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return self.path is not None and self.path.exists()
41
+
42
+
43
+ def _sips_resize(src: Path, dst: Path, px: int) -> bool:
44
+ """Resize with ``sips``, which ships with macOS. No Pillow dependency, and it
45
+ reads HEIC, RAW and video posters that Pillow would refuse."""
46
+ dst.parent.mkdir(parents=True, exist_ok=True)
47
+ try:
48
+ r = subprocess.run(
49
+ ["sips", "-s", "format", "jpeg", "-Z", str(px), str(src), "--out", str(dst)],
50
+ capture_output=True,
51
+ timeout=60,
52
+ check=False, # a failed resize is reported, not raised
53
+ )
54
+ return r.returncode == 0 and dst.exists()
55
+ except (OSError, subprocess.SubprocessError):
56
+ return False
57
+
58
+
59
+ def _derivative_for(lib_path: Path, uuid: str) -> Path | None:
60
+ """Apple files derivatives under resources/derivatives/<first hex char>/.
61
+
62
+ The layout is not a public contract, so this is best-effort: a miss simply
63
+ falls through to the original.
64
+ """
65
+ base = lib_path / "resources" / "derivatives"
66
+ if not base.is_dir():
67
+ return None
68
+ candidates: list[Path] = []
69
+ shard = base / uuid[0].upper()
70
+ for folder in (shard, base):
71
+ if not folder.is_dir():
72
+ continue
73
+ try:
74
+ candidates.extend(p for p in folder.glob(f"{uuid}*") if p.is_file())
75
+ except OSError:
76
+ continue
77
+ if candidates:
78
+ break
79
+ if not candidates:
80
+ return None
81
+
82
+ # Prefer a real image over a .THM video stub, then take the largest of
83
+ # those, Photos writes several sizes per asset and the biggest is the one
84
+ # worth looking at.
85
+ def rank(p: Path) -> tuple[int, int]:
86
+ is_image = p.suffix.lower() in {".jpeg", ".jpg", ".heic", ".png"}
87
+ return (1 if is_image else 0, p.stat().st_size)
88
+
89
+ return max(candidates, key=rank)
90
+
91
+
92
+ class PreviewRenderer:
93
+ def __init__(self, config: Config, lib: PhotosLibrary):
94
+ self.config = config
95
+ self.lib = lib
96
+
97
+ def render(self, asset: Asset, px: int | None = None) -> Preview:
98
+ px = px or self.config.preview_px
99
+ out = self.config.preview_dir / f"{asset.uuid}-{px}.jpg"
100
+ if out.exists():
101
+ return Preview(asset.uuid, asset.filename, out)
102
+
103
+ lib_path = self.lib._library_path()
104
+ source = _derivative_for(lib_path, asset.uuid)
105
+
106
+ if source is None:
107
+ # No derivative. Fall back to the original, which only exists when
108
+ # the asset has been downloaded from iCloud.
109
+ original = self._original_path(asset)
110
+ if original is None:
111
+ return Preview(
112
+ asset.uuid,
113
+ asset.filename,
114
+ None,
115
+ error=(
116
+ "No local preview. This asset lives in iCloud and has not been "
117
+ "downloaded to this Mac. Open it in Photos once, or run "
118
+ "export_originals, to pull it down."
119
+ ),
120
+ )
121
+ source = original
122
+
123
+ if not _sips_resize(source, out, px):
124
+ return Preview(asset.uuid, asset.filename, None, error="Could not render a preview.")
125
+ return Preview(asset.uuid, asset.filename, out)
126
+
127
+ def _original_path(self, asset: Asset) -> Path | None:
128
+ if not asset.local:
129
+ return None
130
+ import osxphotos
131
+
132
+ db = osxphotos.PhotosDB(str(self.lib._library_path()))
133
+ for p in db.photos(uuid=[asset.uuid]):
134
+ if p.path:
135
+ return Path(p.path)
136
+ return None
137
+
138
+ @staticmethod
139
+ def as_base64(path: Path) -> str:
140
+ return base64.b64encode(path.read_bytes()).decode("ascii")
@@ -0,0 +1,72 @@
1
+ """Read-only mode and the write audit log.
2
+
3
+ The shape is the one every server here uses: writes work, the irreversible ones
4
+ ask, and one environment variable removes writes entirely for an unattended
5
+ agent.
6
+
7
+ Writes are not off by default. A server that gates every write behind a flag
8
+ produces one of two outcomes: the user gives up, or the user pastes the flag
9
+ into their config once and never thinks about it again. The second is common and
10
+ is worse than no gate, because it looks like a safeguard while being permanently
11
+ disabled.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from typing import Any
19
+
20
+ from .config import Config
21
+
22
+
23
+ class AuditLog:
24
+ """One JSON line per attempted write.
25
+
26
+ A failed audit write must never turn a successful action into a reported
27
+ error. This is a record, not a control, so every failure here is swallowed.
28
+ """
29
+
30
+ def __init__(self, config: Config):
31
+ self.path = config.audit_log
32
+
33
+ def record(self, action: str, *, allowed: bool, summary: str, **extra: Any) -> None:
34
+ if self.path is None:
35
+ return
36
+ line = {
37
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
38
+ "action": action,
39
+ "allowed": allowed,
40
+ "summary": summary,
41
+ **extra,
42
+ }
43
+ try:
44
+ self.path.parent.mkdir(parents=True, exist_ok=True)
45
+ with open(self.path, "a", encoding="utf-8") as fh:
46
+ fh.write(json.dumps(line, default=str) + "\n")
47
+ except OSError:
48
+ pass
49
+
50
+
51
+ #: MCP tool annotations, so a client can decide what to auto-approve.
52
+ #: `openWorldHint` is false throughout: nothing here leaves the machine.
53
+ READ_ONLY_TOOL = {
54
+ "readOnlyHint": True,
55
+ "destructiveHint": False,
56
+ "idempotentHint": True,
57
+ "openWorldHint": False,
58
+ }
59
+
60
+ REVERSIBLE_WRITE = {
61
+ "readOnlyHint": False,
62
+ "destructiveHint": False,
63
+ "idempotentHint": True,
64
+ "openWorldHint": False,
65
+ }
66
+
67
+ DESTRUCTIVE_WRITE = {
68
+ "readOnlyHint": False,
69
+ "destructiveHint": True,
70
+ "idempotentHint": False,
71
+ "openWorldHint": False,
72
+ }