@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
|
@@ -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
|