@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,283 @@
1
+ """Ranking assets against a natural-language phrase.
2
+
3
+ The useful thing about a Photos library is that Apple has already done the hard
4
+ part: every asset carries scene labels, OCR text, an activity guess, a venue
5
+ type and a place. What is missing is a way to ask for them in one phrase.
6
+
7
+ So a query is split into words, and each word is scored against each field with
8
+ a weight that reflects how much that field means. A word matching a person's
9
+ name is worth far more than the same word appearing in OCR noise. Assets that
10
+ match more of the distinct query words rank above assets that match one word
11
+ many times, which is what stops a single spammy field from winning.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import re
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from .library import Asset, PhotosLibrary
23
+
24
+ #: Words carrying no discriminating power. Kept short on purpose: over-filtering
25
+ #: hurts more than it helps once field weighting is doing the real work.
26
+ STOPWORDS = {
27
+ "a", "an", "and", "any", "are", "at", "be", "can", "did", "do", "does", "find",
28
+ "for", "from", "get", "give", "has", "have", "i", "in", "is", "it", "its", "me",
29
+ "my", "of", "on", "or", "our", "photo", "photos", "pic", "pics", "picture",
30
+ "pictures", "show", "some", "that", "the", "their", "them", "there", "they",
31
+ "this", "to", "was", "were", "what", "when", "where", "which", "with", "you",
32
+ "your",
33
+ }
34
+
35
+ #: Field weights. Tuned so an explicit human signal (a face, a place you named,
36
+ #: a caption you wrote) always outranks an incidental one (a word Apple's OCR
37
+ #: happened to read in the background of a receipt).
38
+ WEIGHTS: list[tuple[str, float, Callable[[Asset], list[str]]]] = [
39
+ ("person", 10.0, lambda a: a.persons),
40
+ ("title", 8.0, lambda a: [a.title] if a.title else []),
41
+ ("keyword", 7.0, lambda a: a.keywords),
42
+ ("description", 6.0, lambda a: [a.description] if a.description else []),
43
+ ("album", 5.0, lambda a: a.albums),
44
+ ("place", 5.0, lambda a: [x for x in (a.place, a.city, a.state, a.country) if x]),
45
+ ("label", 4.0, lambda a: a.labels),
46
+ ("activity", 3.5, lambda a: a.activities),
47
+ ("venue", 3.0, lambda a: a.venues),
48
+ ("filename", 2.0, lambda a: [a.filename]),
49
+ ("text", 1.2, lambda a: a.text),
50
+ ]
51
+
52
+
53
+ @dataclass
54
+ class Filters:
55
+ """Structured narrowing applied before ranking."""
56
+
57
+ kind: str | None = None # "photo" | "video"
58
+ favorite: bool | None = None
59
+ person: str | None = None
60
+ album: str | None = None
61
+ place: str | None = None
62
+ year: int | None = None
63
+ date_from: str | None = None # ISO date, inclusive
64
+ date_to: str | None = None # ISO date, inclusive
65
+ screenshots: bool | None = None # None = include, False = exclude, True = only
66
+ include_hidden: bool = False
67
+ downloaded_only: bool = False
68
+
69
+ def keep(self, a: Asset) -> bool:
70
+ if a.hidden and not self.include_hidden:
71
+ return False
72
+ if self.kind == "photo" and a.is_video:
73
+ return False
74
+ if self.kind == "video" and not a.is_video:
75
+ return False
76
+ if self.favorite is not None and a.favorite is not self.favorite:
77
+ return False
78
+ if self.screenshots is True and not a.screenshot:
79
+ return False
80
+ if self.screenshots is False and a.screenshot:
81
+ return False
82
+ if self.downloaded_only and not a.local:
83
+ return False
84
+ if self.person:
85
+ needle = self.person.lower()
86
+ if not any(needle in p.lower() for p in a.persons):
87
+ return False
88
+ if self.album:
89
+ needle = self.album.lower()
90
+ if not any(needle in al.lower() for al in a.albums):
91
+ return False
92
+ if self.place:
93
+ needle = self.place.lower()
94
+ hay = " ".join(x for x in (a.place, a.city, a.state, a.country) if x).lower()
95
+ if needle not in hay:
96
+ return False
97
+ if self.year is not None and (not a.date or not a.date.startswith(str(self.year))):
98
+ return False
99
+ if self.date_from and (not a.date or a.date[:10] < self.date_from):
100
+ return False
101
+ if self.date_to and (not a.date or a.date[:10] > self.date_to):
102
+ return False
103
+ return True
104
+
105
+
106
+ def tokenize(query: str) -> list[str]:
107
+ words = re.findall(r"[a-z0-9']+", query.lower())
108
+ kept = [w for w in words if w not in STOPWORDS and len(w) > 1]
109
+ # A query made entirely of stopwords ("show me the photos") should still
110
+ # return the filtered set rather than nothing at all.
111
+ return kept
112
+
113
+
114
+ def _field_score(word: str, values: list[str], weight: float) -> float:
115
+ """Exact token match scores full weight; a prefix match scores half.
116
+
117
+ Substring-anywhere matching is deliberately not used: it makes "art" match
118
+ "heart" and "Sparta", which is the single fastest way to make a search feel
119
+ broken.
120
+ """
121
+ best = 0.0
122
+ for value in values:
123
+ if not value:
124
+ continue
125
+ for token in re.findall(r"[a-z0-9']+", value.lower()):
126
+ if token == word:
127
+ return weight
128
+ if len(word) > 3 and token.startswith(word):
129
+ best = max(best, weight * 0.5)
130
+ return best
131
+
132
+
133
+ #: Words that mean the user is deliberately after screenshots, documents or
134
+ #: text, rather than a photograph of something.
135
+ TEXTUAL_INTENT = {
136
+ "screenshot", "screenshots", "screen", "document", "documents", "receipt",
137
+ "receipts", "invoice", "invoices", "ticket", "tickets", "text", "note",
138
+ "notes", "email", "message", "chat", "page", "pdf", "form", "passport",
139
+ "id", "card", "whiteboard", "slide", "slides", "quote", "tweet", "post",
140
+ }
141
+
142
+
143
+ def score(asset: Asset, words: list[str]) -> tuple[float, list[str]]:
144
+ """Return the asset's score and which fields earned it."""
145
+ total = 0.0
146
+ matched_words = 0
147
+ why: set[str] = set()
148
+ text_only = True
149
+ for word in words:
150
+ best = 0.0
151
+ best_field = ""
152
+ for field_name, weight, getter in WEIGHTS:
153
+ s = _field_score(word, getter(asset), weight)
154
+ if s > best:
155
+ best, best_field = s, field_name
156
+ if best > 0:
157
+ total += best
158
+ matched_words += 1
159
+ why.add(best_field)
160
+ if best_field != "text":
161
+ text_only = False
162
+ if not matched_words:
163
+ return 0.0, []
164
+
165
+ # Covering more of the query is worth more than scoring high on one word,
166
+ # but only mildly, a strong bonus per extra word lets a screenshot full of
167
+ # OCR noise beat a photograph that actually shows the thing asked for.
168
+ coverage = matched_words / len(words)
169
+ total *= 0.4 + 0.6 * coverage
170
+ total *= 1 + 0.2 * (matched_words - 1)
171
+
172
+ wants_text = bool(TEXTUAL_INTENT & set(words))
173
+
174
+ # A screenshot is rarely what "find my photo of X" means. Unless the query
175
+ # itself is textual, it competes at a discount.
176
+ if asset.screenshot and not wants_text:
177
+ total *= 0.45
178
+
179
+ # Matching purely on words Apple's OCR read is the weakest evidence there
180
+ # is: it means the phrase appeared *written inside* the image, not that the
181
+ # image depicts it. Only trust it when the query was asking about text.
182
+ if text_only and not wants_text:
183
+ total *= 0.35
184
+
185
+ return total, sorted(why)
186
+
187
+
188
+ def search(
189
+ lib: PhotosLibrary,
190
+ query: str = "",
191
+ filters: Filters | None = None,
192
+ limit: int = 12,
193
+ ) -> dict[str, Any]:
194
+ filters = filters or Filters()
195
+ words = tokenize(query)
196
+ candidates = [a for a in lib.assets if filters.keep(a)]
197
+
198
+ if not words:
199
+ # Pure browse: newest first is the only ordering that makes sense.
200
+ ranked = sorted(candidates, key=lambda a: a.date or "", reverse=True)
201
+ rows = [a.summary() for a in ranked[:limit]]
202
+ return {
203
+ "query": query,
204
+ "matched": len(candidates),
205
+ "returned": len(rows),
206
+ "results": rows,
207
+ "note": "No search terms, showing the most recent matches for the filters.",
208
+ }
209
+
210
+ scored: list[tuple[float, list[str], Asset]] = []
211
+ for a in candidates:
212
+ s, why = score(a, words)
213
+ if s > 0:
214
+ scored.append((s, why, a))
215
+ # Highest score first; newest first among ties.
216
+ scored.sort(key=lambda t: (t[0], t[2].date or ""), reverse=True)
217
+
218
+ rows = []
219
+ for _score, why, a in scored[:limit]:
220
+ row = a.summary()
221
+ row["matched_on"] = why
222
+ rows.append(row)
223
+
224
+ out: dict[str, Any] = {
225
+ "query": query,
226
+ "searched": len(candidates),
227
+ "matched": len(scored),
228
+ "returned": len(rows),
229
+ "results": rows,
230
+ }
231
+
232
+ # Apple's label vocabulary is a fixed set of about 1,500 words that its
233
+ # on-device classifier was trained on. "smiling", "cosy" and "aesthetic" are
234
+ # not in it, and no amount of rephrasing the same idea will find them. Words
235
+ # that matched nothing are far more useful to report than a bare zero, so
236
+ # every unmatched word gets checked against the vocabulary that does exist.
237
+ unmatched = [w for w in words if not _understood(w, lib)]
238
+ if unmatched:
239
+ suggestions = suggest_terms(lib, unmatched)
240
+ if suggestions:
241
+ out["did_you_mean"] = suggestions
242
+ out["unmatched_terms"] = unmatched
243
+
244
+ if not rows:
245
+ out["hint"] = (
246
+ "Nothing matched. Apple indexes what a photo looks like, not what you "
247
+ "call it, try a scene word ('sunset', 'document', 'beach'), a place, a "
248
+ "person's name, or a word that would literally appear inside the image. "
249
+ "Call list_vocabulary to see the words this library actually knows."
250
+ )
251
+ return out
252
+
253
+
254
+ def _understood(word: str, lib: PhotosLibrary) -> bool:
255
+ """Does this library have a *visual* or *structured* meaning for the word?
256
+
257
+ Appearing in OCR text does not count. "smiling" turns up inside the text of
258
+ some screenshot in almost any library, and treating that as understanding is
259
+ exactly how a search convinces someone it looked when it did not.
260
+ """
261
+ if word in lib.structured_terms():
262
+ return True
263
+ return any(
264
+ word == v or v.startswith(word + " ") or (" " + word) in v
265
+ for v in lib.vocabulary()
266
+ )
267
+
268
+
269
+ def suggest_terms(lib: PhotosLibrary, words: list[str], per_word: int = 4) -> dict[str, list[str]]:
270
+ """For each word Apple has never heard of, offer the closest words it has.
271
+
272
+ This is what turns a dead end into a next step: the agent learns that this
273
+ library knows "Crowd" and "Audience" but not "keynote", and can ask again.
274
+ """
275
+ vocab = lib.vocabulary()
276
+ out: dict[str, list[str]] = {}
277
+ for word in words:
278
+ near = difflib.get_close_matches(word, vocab, n=per_word, cutoff=0.72)
279
+ prefix = [v for v in vocab if v.startswith(word) and v not in near][:per_word]
280
+ combined = (near + prefix)[:per_word]
281
+ if combined:
282
+ out[word] = combined
283
+ return out
@@ -0,0 +1,352 @@
1
+ """The MCP server: tool definitions and their wiring.
2
+
3
+ The tool surface is deliberately small. A large surface reads well in a table
4
+ and behaves badly in practice, because the model has to guess which of five
5
+ similar tools it wants. What matters here is that `search_photos` is genuinely
6
+ good and that `look_at_photos` exists, so the model can check its own answer
7
+ before giving it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # The Python MCP SDK renamed FastMCP to MCPServer in 2.0. Both are supported so
18
+ # the package keeps working whichever version a user's environment resolves.
19
+ try: # SDK >= 2.0
20
+ from mcp.server.mcpserver import Image
21
+ from mcp.server.mcpserver import MCPServer as _Server
22
+ except ModuleNotFoundError: # SDK 1.x
23
+ from mcp.server.fastmcp import FastMCP as _Server # type: ignore[assignment]
24
+ from mcp.server.fastmcp import Image # type: ignore[assignment]
25
+
26
+ from . import doctor as doctor_mod
27
+ from .config import Config
28
+ from .library import PhotosLibrary
29
+ from .previews import PreviewRenderer
30
+ from .safety import DESTRUCTIVE_WRITE, READ_ONLY_TOOL, REVERSIBLE_WRITE
31
+ from .search import Filters
32
+ from .search import search as run_search
33
+ from .writes import Writer
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ INSTRUCTIONS = """\
38
+ This is the user's own Apple Photos library, read directly on this Mac. Nothing is
39
+ uploaded anywhere.
40
+
41
+ How to actually find something:
42
+
43
+ 1. `search_photos` first. It searches Apple's own on-device index, what a photo
44
+ looks like (scene labels), text Apple read inside it, the activity, the venue,
45
+ the place, and any faces the user has named.
46
+ 2. Then `look_at_photos` on the top few results. Search returns candidates, not
47
+ answers, and the filenames tell you nothing. Look before you answer.
48
+ 3. Only then reply, naming the file and the date so the user can find it.
49
+
50
+ Two things will save you from confident wrong answers:
51
+
52
+ * Apple's visual vocabulary is closed, about 1,500 words. If a result carries
53
+ `unmatched_terms`, Apple has never heard of that word and no rephrasing of the
54
+ same idea will help. Read `did_you_mean`, or call `list_vocabulary`.
55
+ * Almost every asset in a modern library lives in iCloud, not on the Mac.
56
+ Previews still work. Exporting an original downloads it first, which is slow.
57
+
58
+ Nothing here can delete a photo. macOS does not permit it. `archive_photos` moves
59
+ items into an album for the user to empty by hand, and it is the one tool that
60
+ asks for `confirm: true` before it acts.
61
+ """
62
+
63
+
64
+ def build_server(config: Config | None = None) -> _Server:
65
+ config = config or Config.from_env()
66
+ lib = PhotosLibrary(config)
67
+ previews = PreviewRenderer(config, lib)
68
+ writer = Writer(config, lib)
69
+
70
+ mcp = _Server("apple-photos", instructions=INSTRUCTIONS)
71
+
72
+ def _err(exc: Exception) -> str:
73
+ return json.dumps({"error": str(exc)}, indent=2)
74
+
75
+ def _json(data: Any) -> str:
76
+ return json.dumps(data, indent=2, default=str)
77
+
78
+ # ------------------------------------------------------------------ reads
79
+
80
+ @mcp.tool(annotations=READ_ONLY_TOOL)
81
+ def search_photos(
82
+ query: str = "",
83
+ limit: int = 12,
84
+ kind: str | None = None,
85
+ person: str | None = None,
86
+ album: str | None = None,
87
+ place: str | None = None,
88
+ year: int | None = None,
89
+ date_from: str | None = None,
90
+ date_to: str | None = None,
91
+ favorites_only: bool = False,
92
+ screenshots: str = "include",
93
+ include_hidden: bool = False,
94
+ ) -> str:
95
+ """Search your entire Apple Photos library using Apple's own on-device index.
96
+
97
+ Natural visual phrases work best: 'sunset beach', 'receipt', 'dinner in
98
+ Stockholm', 'whiteboard'. Filters narrow before ranking.
99
+
100
+ Args:
101
+ query: What to find. Leave empty to browse by filter alone.
102
+ limit: Maximum results (1-100).
103
+ kind: 'photo' or 'video'.
104
+ person: Only photos with this named face.
105
+ album: Only photos in albums whose name contains this.
106
+ place: Only photos taken somewhere matching this.
107
+ year: Only photos from this calendar year.
108
+ date_from: ISO date, inclusive lower bound (YYYY-MM-DD).
109
+ date_to: ISO date, inclusive upper bound (YYYY-MM-DD).
110
+ favorites_only: Only items hearted in Photos.
111
+ screenshots: 'include', 'exclude', or 'only'.
112
+ include_hidden: Include items in the Hidden album.
113
+ """
114
+ try:
115
+ filters = Filters(
116
+ kind=kind if kind in ("photo", "video") else None,
117
+ favorite=True if favorites_only else None,
118
+ person=person,
119
+ album=album,
120
+ place=place,
121
+ year=year,
122
+ date_from=date_from,
123
+ date_to=date_to,
124
+ screenshots={"include": None, "exclude": False, "only": True}.get(screenshots),
125
+ include_hidden=include_hidden,
126
+ )
127
+ return _json(run_search(lib, query, filters, max(1, min(limit, 100))))
128
+ except Exception as exc:
129
+ log.exception("search failed")
130
+ return _err(exc)
131
+
132
+ @mcp.tool(annotations=READ_ONLY_TOOL)
133
+ def look_at_photos(refs: list[str], size: int = 640) -> list[str | Image]:
134
+ """Actually look at photos: renders each one and returns it as an image.
135
+
136
+ Use this on the top few results of every search before answering a "find
137
+ my photo of X" question. Works even for photos that live only in iCloud,
138
+ because it reads Apple's own local thumbnails rather than the original.
139
+
140
+ Args:
141
+ refs: Photo uuids from search results, or exact filenames.
142
+ size: Longest edge in pixels (128-2048).
143
+ """
144
+ out: list[str | Image] = []
145
+ size = max(128, min(size, 2048))
146
+ for ref in refs[: config.preview_max]:
147
+ asset = lib.get(ref)
148
+ if asset is None:
149
+ out.append(f"{ref}: not found in this library")
150
+ continue
151
+ preview = previews.render(asset, px=size)
152
+ caption = f"{asset.filename} · {(asset.date or '')[:10]} · uuid {asset.uuid}"
153
+ if asset.place:
154
+ caption += f" · {asset.place}"
155
+ out.append(caption)
156
+ if preview.ok and preview.path is not None:
157
+ out.append(Image(path=str(preview.path)))
158
+ else:
159
+ out.append(f" (no preview: {preview.error})")
160
+ if len(refs) > config.preview_max:
161
+ out.append(
162
+ f"Only the first {config.preview_max} of {len(refs)} were rendered. "
163
+ f"Call again for the rest."
164
+ )
165
+ return out
166
+
167
+ @mcp.tool(annotations=READ_ONLY_TOOL)
168
+ def photo_info(refs: list[str]) -> str:
169
+ """Everything known about specific photos: metadata, ML labels, place,
170
+ albums, faces, and any text Apple read inside the image.
171
+
172
+ Args:
173
+ refs: Photo uuids or exact filenames.
174
+ """
175
+ try:
176
+ rows = []
177
+ for ref in refs[:50]:
178
+ asset = lib.get(ref)
179
+ rows.append(
180
+ asset.details() if asset else {"ref": ref, "error": "not found"}
181
+ )
182
+ return _json({"count": len(rows), "photos": rows})
183
+ except Exception as exc:
184
+ return _err(exc)
185
+
186
+ @mcp.tool(annotations=READ_ONLY_TOOL)
187
+ def library_stats() -> str:
188
+ """Size and shape of the library: totals, albums, named people, how much
189
+ is indexed, and how much lives only in iCloud."""
190
+ try:
191
+ return _json(lib.stats())
192
+ except Exception as exc:
193
+ return _err(exc)
194
+
195
+ @mcp.tool(annotations=READ_ONLY_TOOL)
196
+ def list_vocabulary(starts_with: str = "", limit: int = 200) -> str:
197
+ """The visual words this library actually knows.
198
+
199
+ Apple's classifier has a closed vocabulary. When a search finds nothing,
200
+ this is how to discover the word it does understand instead.
201
+
202
+ Args:
203
+ starts_with: Only terms beginning with this prefix.
204
+ limit: Maximum terms to return.
205
+ """
206
+ try:
207
+ counts = lib.label_counts()
208
+ terms = lib.vocabulary()
209
+ if starts_with:
210
+ terms = [t for t in terms if t.startswith(starts_with.lower())]
211
+ ranked = sorted(terms, key=lambda t: -counts.get(t.title(), 0))[:limit]
212
+ return _json(
213
+ {
214
+ "total_terms": len(lib.vocabulary()),
215
+ "returned": len(ranked),
216
+ "terms": ranked,
217
+ }
218
+ )
219
+ except Exception as exc:
220
+ return _err(exc)
221
+
222
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
223
+ def export_originals(refs: list[str], directory: str | None = None) -> str:
224
+ """Export full-quality originals to a folder on this Mac.
225
+
226
+ Slow for iCloud-only assets: each one is downloaded first. Nothing is
227
+ uploaded anywhere.
228
+
229
+ Args:
230
+ refs: Photo uuids or exact filenames.
231
+ directory: Absolute destination folder. Defaults to ~/Downloads/Photos Exports.
232
+ """
233
+ try:
234
+ import osxphotos
235
+
236
+ dest = Path(directory).expanduser() if directory else config.export_dir
237
+ dest.mkdir(parents=True, exist_ok=True)
238
+ uuids = []
239
+ missing = []
240
+ for ref in refs[: config.write_batch_max]:
241
+ asset = lib.get(ref)
242
+ (uuids.append(asset.uuid) if asset else missing.append(ref))
243
+ if not uuids:
244
+ return _json({"error": "none of those refs resolved", "unresolved": missing})
245
+
246
+ db = osxphotos.PhotosDB(str(lib._library_path()))
247
+ written: list[str] = []
248
+ failed: list[dict[str, str]] = []
249
+ for photo in db.photos(uuid=uuids):
250
+ try:
251
+ written.extend(photo.export(str(dest), use_photos_export=not photo.path))
252
+ except Exception as exc:
253
+ failed.append({"uuid": photo.uuid, "reason": str(exc)})
254
+ return _json(
255
+ {
256
+ "directory": str(dest),
257
+ "exported": len(written),
258
+ "files": written,
259
+ "failed": failed,
260
+ "unresolved": missing,
261
+ }
262
+ )
263
+ except Exception as exc:
264
+ log.exception("export failed")
265
+ return _err(exc)
266
+
267
+ @mcp.tool(annotations=READ_ONLY_TOOL)
268
+ def doctor() -> str:
269
+ """Diagnose setup: macOS, Full Disk Access, the library, the index, and
270
+ whether writes are enabled. Run this first when anything misbehaves."""
271
+ try:
272
+ return _json(doctor_mod.run(config, lib))
273
+ except Exception as exc:
274
+ return _err(exc)
275
+
276
+ # ----------------------------------------------------------------- writes
277
+ #
278
+ # In read-only mode these are never registered, so they do not appear in the
279
+ # tool list at all. A model cannot call a tool it cannot see, and an error
280
+ # message is an invitation to retry differently.
281
+ if config.read_only:
282
+ return mcp
283
+
284
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
285
+ def favorite_photos(refs: list[str], favorite: bool = True) -> str:
286
+ """Heart photos in Photos, or remove the heart.
287
+
288
+ Args:
289
+ refs: Photo uuids or exact filenames.
290
+ favorite: False to un-favorite.
291
+ """
292
+ try:
293
+ return _json(writer.set_favorite(refs, favorite))
294
+ except Exception as exc:
295
+ return _err(exc)
296
+
297
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
298
+ def set_photo_title(ref: str, title: str) -> str:
299
+ """Set one photo's title."""
300
+ try:
301
+ return _json(writer.set_title(ref, title))
302
+ except Exception as exc:
303
+ return _err(exc)
304
+
305
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
306
+ def set_photo_description(ref: str, description: str) -> str:
307
+ """Set one photo's description/caption."""
308
+ try:
309
+ return _json(writer.set_description(ref, description))
310
+ except Exception as exc:
311
+ return _err(exc)
312
+
313
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
314
+ def add_keywords(refs: list[str], keywords: list[str]) -> str:
315
+ """Add keywords to photos, keeping the ones already there."""
316
+ try:
317
+ return _json(writer.add_keywords(refs, keywords))
318
+ except Exception as exc:
319
+ return _err(exc)
320
+
321
+ @mcp.tool(annotations=REVERSIBLE_WRITE)
322
+ def add_to_album(album: str, refs: list[str]) -> str:
323
+ """Add photos to an album, creating it if needed.
324
+
325
+ Args:
326
+ album: Album name.
327
+ refs: Photo uuids or exact filenames.
328
+ """
329
+ try:
330
+ return _json(writer.add_to_album(album, refs))
331
+ except Exception as exc:
332
+ return _err(exc)
333
+
334
+ @mcp.tool(annotations=DESTRUCTIVE_WRITE)
335
+ def archive_photos(refs: list[str], confirm: bool = False) -> str:
336
+ """The closest thing to deleting that macOS allows.
337
+
338
+ Moves photos into an archive album so they leave the main library view.
339
+ No app, including this one, can permanently delete photos by script, so
340
+ the user empties that album by hand.
341
+
342
+ Args:
343
+ refs: Photo uuids or exact filenames.
344
+ confirm: The user reads this as deleting their photos. Set true only
345
+ when they have actually asked for these specific items to go.
346
+ """
347
+ try:
348
+ return _json(writer.archive(refs, confirm=confirm))
349
+ except Exception as exc:
350
+ return _err(exc)
351
+
352
+ return mcp