apple-photos-mcp 0.1.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 +516 -0
- package/build/__tests__/photosManager.test.js +111 -0
- package/build/index.js +220 -0
- package/build/services/photosManager.js +127 -0
- package/build/types.js +1 -0
- package/build/utils/python.js +89 -0
- package/package.json +89 -0
- package/requirements.txt +1 -0
- package/scripts/setup.sh +43 -0
- package/src/utils/photos_reader.py +400 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Sweet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# Apple Photos MCP Server
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants like Claude to query and export from the macOS Apple Photos library, backed by the [osxphotos](https://github.com/RhetTbull/osxphotos) library.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/apple-photos-mcp)
|
|
6
|
+
[](https://github.com/sweetrb/apple-photos-mcp/actions/workflows/ci.yml)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
> **Read-only against the Photos library.** Exports write files to a directory you choose, but the library itself is never modified.
|
|
10
|
+
|
|
11
|
+
## What is This?
|
|
12
|
+
|
|
13
|
+
This server acts as a bridge between AI assistants and Apple Photos. Once configured, you can ask Claude (or any MCP-compatible AI) to:
|
|
14
|
+
|
|
15
|
+
- "Find all my photos from our trip to Spain in 2023"
|
|
16
|
+
- "Show me my favorite sunset photos"
|
|
17
|
+
- "How many photos do I have? What are my top keywords?"
|
|
18
|
+
- "Find photos of Sarah from last summer and export them to ~/Desktop/sarah-summer"
|
|
19
|
+
- "List my albums"
|
|
20
|
+
- "Tell me everything about photo UUID ABC-123"
|
|
21
|
+
|
|
22
|
+
The AI assistant communicates with this server, which uses [osxphotos](https://github.com/RhetTbull/osxphotos) to read the Photos library SQLite database directly. All data stays local on your machine.
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### Using Claude Code (Easiest)
|
|
27
|
+
|
|
28
|
+
If you're using [Claude Code](https://claude.com/product/claude-code) (in Terminal or VS Code), just ask Claude to install it:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Install the sweetrb/apple-photos-mcp MCP server so you can help me query my Apple Photos library
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Claude will handle the installation and configuration automatically. After install, you'll need to install `osxphotos` (Python) and grant Full Disk Access — see [Requirements](#requirements) below.
|
|
35
|
+
|
|
36
|
+
### Using the Plugin Marketplace
|
|
37
|
+
|
|
38
|
+
Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
/plugin marketplace add sweetrb/apple-photos-mcp
|
|
42
|
+
/plugin install apple-photos
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This method also installs a **skill** that teaches Claude when and how to use Apple Photos effectively.
|
|
46
|
+
|
|
47
|
+
### Manual Installation
|
|
48
|
+
|
|
49
|
+
**1. Install the server:**
|
|
50
|
+
```bash
|
|
51
|
+
npm install -g github:sweetrb/apple-photos-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**2. Install osxphotos** (the Python library this server depends on):
|
|
55
|
+
```bash
|
|
56
|
+
pip3 install osxphotos
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or, if you cloned the repo, run `npm run setup` to create a project-local Python venv with `osxphotos` pre-installed.
|
|
60
|
+
|
|
61
|
+
**3. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"apple-photos": {
|
|
66
|
+
"command": "npx",
|
|
67
|
+
"args": ["apple-photos-mcp"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**4. Grant Full Disk Access** to the app hosting the MCP server (Claude Desktop, Terminal, VS Code, etc.) — see [Full Disk Access](#full-disk-access) below.
|
|
74
|
+
|
|
75
|
+
**5. Restart Claude Desktop** and start using natural language:
|
|
76
|
+
```
|
|
77
|
+
"How many photos are in my library?"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- **macOS** - The Photos library is macOS-only
|
|
83
|
+
- **Node.js 20+** - Required for the MCP server
|
|
84
|
+
- **Python 3.9+ with osxphotos** - The server uses [osxphotos](https://github.com/RhetTbull/osxphotos) under the hood. Install via `pip3 install osxphotos` or via `npm run setup` if installing from source.
|
|
85
|
+
- **Apple Photos** - Must have a Photos library (default location: `~/Pictures/Photos Library.photoslibrary`)
|
|
86
|
+
- **Full Disk Access** - The Photos library lives in a protected directory. The host app needs Full Disk Access — see [below](#full-disk-access).
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
### Querying
|
|
91
|
+
|
|
92
|
+
| Feature | Description |
|
|
93
|
+
|---------|-------------|
|
|
94
|
+
| **Library Stats** | Total counts of photos, movies, albums, folders, keywords, persons |
|
|
95
|
+
| **Query** | Search by date range, album, keyword, person, favorite/hidden flags, photo/movie type, title/description substring |
|
|
96
|
+
| **Photo Details** | Full metadata for one photo: dimensions, location, place, EXIF-derived flags (HDR, live, portrait, panorama, raw, edited, etc.) |
|
|
97
|
+
| **List Albums** | All albums with their folder paths and photo counts |
|
|
98
|
+
| **List Folders** | All folders with parent and album/subfolder counts |
|
|
99
|
+
| **List Keywords** | Keywords sorted by usage count |
|
|
100
|
+
| **List Persons** | People detected by Photos face recognition, sorted by photo count |
|
|
101
|
+
|
|
102
|
+
### Export
|
|
103
|
+
|
|
104
|
+
| Feature | Description |
|
|
105
|
+
|---------|-------------|
|
|
106
|
+
| **Export Originals** | Copy original photos to a destination directory |
|
|
107
|
+
| **Export Edited** | Copy the edited version instead of the original |
|
|
108
|
+
| **Live Photos** | Optionally include the live-photo video alongside the still |
|
|
109
|
+
| **Raw Files** | Optionally include the raw (NEF, CR2, etc.) sidecar |
|
|
110
|
+
| **Multi-photo Export** | Export multiple UUIDs in a single call |
|
|
111
|
+
|
|
112
|
+
### Diagnostics
|
|
113
|
+
|
|
114
|
+
| Feature | Description |
|
|
115
|
+
|---------|-------------|
|
|
116
|
+
| **Health Check** | Verify osxphotos is installed and the library can be opened |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Tool Reference
|
|
121
|
+
|
|
122
|
+
This section documents all available tools. AI agents should use these tool names and parameters exactly as specified.
|
|
123
|
+
|
|
124
|
+
### Discovery
|
|
125
|
+
|
|
126
|
+
#### `health-check`
|
|
127
|
+
|
|
128
|
+
Verify osxphotos is installed and the Photos library can be opened.
|
|
129
|
+
|
|
130
|
+
**Parameters:** None
|
|
131
|
+
|
|
132
|
+
**Returns:** osxphotos version, library path, and total photo count — or an error if the library is inaccessible.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
#### `library-info`
|
|
137
|
+
|
|
138
|
+
High-level stats about the Photos library.
|
|
139
|
+
|
|
140
|
+
| Parameter | Type | Required | Description |
|
|
141
|
+
|-----------|------|----------|-------------|
|
|
142
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` (defaults to system library) |
|
|
143
|
+
|
|
144
|
+
**Returns:** Library path, Photos DB version, Photos.app version, counts of photos / movies / albums / folders / keywords / persons.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### Query
|
|
149
|
+
|
|
150
|
+
#### `query`
|
|
151
|
+
|
|
152
|
+
Search the library with combinable filters. Returns photo summaries with UUIDs — use `get-photo` for full details on a specific match.
|
|
153
|
+
|
|
154
|
+
| Parameter | Type | Required | Description |
|
|
155
|
+
|-----------|------|----------|-------------|
|
|
156
|
+
| `uuid` | string[] | No | Specific UUIDs to fetch |
|
|
157
|
+
| `album` | string[] | No | Album name(s); ANY-match |
|
|
158
|
+
| `keyword` | string[] | No | Keyword(s); ANY-match |
|
|
159
|
+
| `person` | string[] | No | Person name(s); ANY-match |
|
|
160
|
+
| `fromDate` | string | No | ISO 8601 lower bound on photo date (e.g. `"2025-06-01"`) |
|
|
161
|
+
| `toDate` | string | No | ISO 8601 upper bound on photo date |
|
|
162
|
+
| `favorite` | boolean | No | Only favorites |
|
|
163
|
+
| `notFavorite` | boolean | No | Exclude favorites |
|
|
164
|
+
| `hidden` | boolean | No | Only hidden photos |
|
|
165
|
+
| `notHidden` | boolean | No | Exclude hidden photos (default behavior) |
|
|
166
|
+
| `photos` | boolean | No | Include still photos |
|
|
167
|
+
| `movies` | boolean | No | Include movies |
|
|
168
|
+
| `title` | string | No | Substring match on title |
|
|
169
|
+
| `description` | string | No | Substring match on description |
|
|
170
|
+
| `limit` | number | No | Cap the number of results |
|
|
171
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
172
|
+
|
|
173
|
+
**Example - Recent favorites of Sarah:**
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"person": ["Sarah"],
|
|
177
|
+
"favorite": true,
|
|
178
|
+
"fromDate": "2025-06-01",
|
|
179
|
+
"limit": 50
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Example - Sunset keyword across two albums:**
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"keyword": ["sunset"],
|
|
187
|
+
"album": ["Vacation 2024", "Beach Trips"]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Returns:** Photo summaries (UUID, filename, date, dimensions, favorite/hidden flags, albums, keywords, persons).
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
#### `get-photo`
|
|
196
|
+
|
|
197
|
+
Get full metadata for a single photo by UUID.
|
|
198
|
+
|
|
199
|
+
| Parameter | Type | Required | Description |
|
|
200
|
+
|-----------|------|----------|-------------|
|
|
201
|
+
| `uuid` | string | Yes | Photo UUID |
|
|
202
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
203
|
+
|
|
204
|
+
**Example:**
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"uuid": "33AC0410-D367-43AE-A839-12C7EF482020"
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Returns:** All metadata for the photo: dimensions, original dimensions, dates (taken/added/modified), title, description, location (lat/lon), place (name/country), albums, keywords, persons, labels, type flags (HDR / live / raw / edited / portrait / panorama / selfie / screenshot / slow-mo / time-lapse / burst), file paths (original, edited, raw, live-photo video), file size, UTI.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
### Browse
|
|
216
|
+
|
|
217
|
+
#### `list-albums`
|
|
218
|
+
|
|
219
|
+
List all albums in the library.
|
|
220
|
+
|
|
221
|
+
| Parameter | Type | Required | Description |
|
|
222
|
+
|-----------|------|----------|-------------|
|
|
223
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
224
|
+
|
|
225
|
+
**Returns:** Each album's title, folder path, photo count, shared status, and UUID.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
#### `list-folders`
|
|
230
|
+
|
|
231
|
+
List all folders in the library.
|
|
232
|
+
|
|
233
|
+
| Parameter | Type | Required | Description |
|
|
234
|
+
|-----------|------|----------|-------------|
|
|
235
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
236
|
+
|
|
237
|
+
**Returns:** Each folder's title, parent folder, album count, and subfolder count.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
#### `list-keywords`
|
|
242
|
+
|
|
243
|
+
List keywords sorted by usage count.
|
|
244
|
+
|
|
245
|
+
| Parameter | Type | Required | Description |
|
|
246
|
+
|-----------|------|----------|-------------|
|
|
247
|
+
| `limit` | number | No | Cap to top-N keywords |
|
|
248
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
249
|
+
|
|
250
|
+
**Returns:** Keywords with their photo counts, sorted descending.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
#### `list-persons`
|
|
255
|
+
|
|
256
|
+
List people detected by Photos face recognition, sorted by photo count.
|
|
257
|
+
|
|
258
|
+
| Parameter | Type | Required | Description |
|
|
259
|
+
|-----------|------|----------|-------------|
|
|
260
|
+
| `limit` | number | No | Cap to top-N persons |
|
|
261
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
262
|
+
|
|
263
|
+
**Returns:** Persons with their photo counts, sorted descending. Unidentified faces appear as `_UNKNOWN_`.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Export
|
|
268
|
+
|
|
269
|
+
#### `export`
|
|
270
|
+
|
|
271
|
+
Export one or more photos by UUID to a destination directory.
|
|
272
|
+
|
|
273
|
+
| Parameter | Type | Required | Description |
|
|
274
|
+
|-----------|------|----------|-------------|
|
|
275
|
+
| `uuid` | string[] | Yes | Photo UUID(s) to export (non-empty) |
|
|
276
|
+
| `dest` | string | Yes | Destination directory (created if missing) |
|
|
277
|
+
| `edited` | boolean | No | Export the edited version instead of the original |
|
|
278
|
+
| `live` | boolean | No | Also export the live-photo video |
|
|
279
|
+
| `raw` | boolean | No | Also export the raw image |
|
|
280
|
+
| `overwrite` | boolean | No | Overwrite existing files at the destination |
|
|
281
|
+
| `library` | string | No | Path to a non-default `.photoslibrary` |
|
|
282
|
+
|
|
283
|
+
**Example - Originals to a folder:**
|
|
284
|
+
```json
|
|
285
|
+
{
|
|
286
|
+
"uuid": ["33AC0410-...", "EEFCEF1D-..."],
|
|
287
|
+
"dest": "~/Desktop/exports"
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Example - Edited versions plus raw and live-photo video:**
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"uuid": ["33AC0410-..."],
|
|
295
|
+
"dest": "~/Desktop/exports",
|
|
296
|
+
"edited": true,
|
|
297
|
+
"raw": true,
|
|
298
|
+
"live": true,
|
|
299
|
+
"overwrite": true
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Returns:** Destination path, count of files exported, count skipped, list of exported file paths, and any errors per UUID.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Usage Patterns
|
|
308
|
+
|
|
309
|
+
### Basic Workflow
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
User: "How many photos do I have?"
|
|
313
|
+
AI: [calls library-info]
|
|
314
|
+
"You have 30,968 items: 30,435 photos and 533 movies across 46 albums..."
|
|
315
|
+
|
|
316
|
+
User: "Find my favorite sunset photos"
|
|
317
|
+
AI: [calls query with keyword=["sunset"], favorite=true]
|
|
318
|
+
"Found 12 favorite sunset photos. Here are the most recent..."
|
|
319
|
+
|
|
320
|
+
User: "Tell me about the first one"
|
|
321
|
+
AI: [calls get-photo with uuid="..."]
|
|
322
|
+
"Taken on 2025-09-14 at 19:47, in Big Sur..."
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Two-step: Query then Export
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
User: "Export all photos of Mollee from the beach to ~/Desktop/mollee-beach"
|
|
329
|
+
AI: [calls query with person=["Mollee"], keyword=["beach"]]
|
|
330
|
+
"Found 109 photos."
|
|
331
|
+
AI: [calls export with the UUIDs and dest="~/Desktop/mollee-beach"]
|
|
332
|
+
"Exported 109 files to ~/Desktop/mollee-beach."
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Browsing Library Structure
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
User: "What are my top 10 keywords?"
|
|
339
|
+
AI: [calls list-keywords with limit=10]
|
|
340
|
+
"Photo Stream (1561), Mollee (109), beach (109), 2015 Feb Keweenaw..."
|
|
341
|
+
|
|
342
|
+
User: "Who appears most in my photos?"
|
|
343
|
+
AI: [calls list-persons with limit=10]
|
|
344
|
+
"Rita Sweet (29), Robert B Sweet (28), Jennifer Sweet (24)..."
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Targeting a Different Library
|
|
348
|
+
|
|
349
|
+
By default, all operations use the system Photos library. To work with a different `.photoslibrary`:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
User: "Show albums in my old archive at /Volumes/Archive/Photos.photoslibrary"
|
|
353
|
+
AI: [calls list-albums with library="/Volumes/Archive/Photos.photoslibrary"]
|
|
354
|
+
"32 albums in the archive..."
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Installation Options
|
|
360
|
+
|
|
361
|
+
### npm (Recommended)
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
npm install -g github:sweetrb/apple-photos-mcp
|
|
365
|
+
pip3 install osxphotos
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### From Source (with Project-Local venv)
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
git clone https://github.com/sweetrb/apple-photos-mcp.git
|
|
372
|
+
cd apple-photos-mcp
|
|
373
|
+
npm install
|
|
374
|
+
npm run setup # creates ./venv and installs osxphotos
|
|
375
|
+
npm run build
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
If installed from source, use this configuration:
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"mcpServers": {
|
|
382
|
+
"apple-photos": {
|
|
383
|
+
"command": "node",
|
|
384
|
+
"args": ["/path/to/apple-photos-mcp/build/index.js"]
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The server prefers a project-local venv at `./venv/bin/python3` if present, and otherwise falls back to system `python3`. This means a global npm install works as long as `osxphotos` is on the system Python.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Full Disk Access
|
|
395
|
+
|
|
396
|
+
The Photos library SQLite database lives in a protected directory (`~/Pictures/Photos Library.photoslibrary/database/`). osxphotos reads this database directly — it does **not** go through Photos.app — so the host process needs **Full Disk Access**.
|
|
397
|
+
|
|
398
|
+
### How to Grant Full Disk Access
|
|
399
|
+
|
|
400
|
+
1. Open **System Settings** (or System Preferences on older macOS)
|
|
401
|
+
2. Go to **Privacy & Security > Full Disk Access**
|
|
402
|
+
3. Click the **+** button
|
|
403
|
+
4. Add the application that hosts the MCP server:
|
|
404
|
+
- **Claude Desktop**: Add `/Applications/Claude.app`
|
|
405
|
+
- **Terminal**: Add `/Applications/Utilities/Terminal.app`
|
|
406
|
+
- **VS Code**: Add `/Applications/Visual Studio Code.app`
|
|
407
|
+
- **iTerm**: Add `/Applications/iTerm.app`
|
|
408
|
+
5. Restart the application after granting access
|
|
409
|
+
|
|
410
|
+
### Without Full Disk Access
|
|
411
|
+
|
|
412
|
+
The `health-check` tool will fail and report a permissions error. No tool will be able to open the library.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Architecture
|
|
417
|
+
|
|
418
|
+
This package is a **TypeScript MCP server with a Python sidecar**:
|
|
419
|
+
|
|
420
|
+
- The MCP server (Node) speaks the Model Context Protocol over stdio.
|
|
421
|
+
- A bundled Python script (`src/utils/photos_reader.py`) uses `osxphotos` to read the Photos library and returns JSON.
|
|
422
|
+
- The TypeScript side spawns the Python script via `child_process.execFileSync`.
|
|
423
|
+
|
|
424
|
+
This is the same pattern used by [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) for the `numbers-parser` Python library.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Security and Privacy
|
|
429
|
+
|
|
430
|
+
- **Local only** — All operations happen locally via osxphotos. No data is sent to external servers.
|
|
431
|
+
- **Read-only** against the Photos library — the library is never modified.
|
|
432
|
+
- **Exports write to disk** — `export` writes files to the destination directory you specify. Confirm destinations before running on shared machines.
|
|
433
|
+
- **No credential storage** — The server doesn't store any passwords or authentication tokens.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## Known Limitations
|
|
438
|
+
|
|
439
|
+
| Limitation | Reason |
|
|
440
|
+
|------------|--------|
|
|
441
|
+
| macOS only | Apple Photos and osxphotos are macOS-specific |
|
|
442
|
+
| Read-only | osxphotos reads the Photos library; this server does not modify it |
|
|
443
|
+
| Full Disk Access required | The Photos library SQLite database is in a protected directory |
|
|
444
|
+
| No iCloud-only photos | Photos that exist only in iCloud (not downloaded) report `isMissing: true` and can't be exported |
|
|
445
|
+
| Photos.app may lock the library | If Photos.app is mid-write, opening the library can fail; close Photos.app and retry |
|
|
446
|
+
| Person filter requires named faces | osxphotos cannot filter by unnamed/unrecognized faces |
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Troubleshooting
|
|
451
|
+
|
|
452
|
+
### "osxphotos not installed. Run: npm run setup"
|
|
453
|
+
- Run `pip3 install osxphotos` (global install) or `npm run setup` (project-local venv).
|
|
454
|
+
- If you used a virtualenv, make sure it's the one at `./venv/` in the project directory.
|
|
455
|
+
|
|
456
|
+
### "Library not found" or permission errors
|
|
457
|
+
- Grant Full Disk Access to the host app — see [Full Disk Access](#full-disk-access).
|
|
458
|
+
- Verify the library path: default is `~/Pictures/Photos Library.photoslibrary`.
|
|
459
|
+
|
|
460
|
+
### Photo not found / "Photo not found: <uuid>"
|
|
461
|
+
- The UUID may be wrong — re-run `query` to get current UUIDs.
|
|
462
|
+
- The photo may have been deleted from the library.
|
|
463
|
+
|
|
464
|
+
### Exports skip files with "missing"
|
|
465
|
+
- The photo exists in iCloud but is not downloaded locally. Open the photo in Photos.app to trigger a download, then retry.
|
|
466
|
+
|
|
467
|
+
### Photos.app errors when running
|
|
468
|
+
- Closing Photos.app may resolve database-lock errors. osxphotos opens the library in read-only mode but still requires that no writer holds an exclusive lock.
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Development
|
|
473
|
+
|
|
474
|
+
```bash
|
|
475
|
+
npm install # Install dependencies
|
|
476
|
+
npm run setup # Create ./venv with osxphotos
|
|
477
|
+
npm run build # Compile TypeScript
|
|
478
|
+
npm test # Run unit tests
|
|
479
|
+
npm run typecheck # Type-check without emitting
|
|
480
|
+
npm run lint # Check code style
|
|
481
|
+
npm run format # Format code
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
The Python sidecar is a thin CLI that the TypeScript layer shells out to:
|
|
485
|
+
|
|
486
|
+
```bash
|
|
487
|
+
./venv/bin/python3 src/utils/photos_reader.py library-info
|
|
488
|
+
./venv/bin/python3 src/utils/photos_reader.py query --keyword sunset --limit 5
|
|
489
|
+
./venv/bin/python3 src/utils/photos_reader.py export --uuid <uuid> --dest /tmp/out
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Author
|
|
495
|
+
|
|
496
|
+
**Rob Sweet** - President, [Superior Technologies Research](https://www.superiortech.io)
|
|
497
|
+
|
|
498
|
+
A software consulting, contracting, and development company.
|
|
499
|
+
|
|
500
|
+
- Email: rob@superiortech.io
|
|
501
|
+
- GitHub: [@sweetrb](https://github.com/sweetrb)
|
|
502
|
+
|
|
503
|
+
## License
|
|
504
|
+
|
|
505
|
+
MIT License - see [LICENSE](LICENSE) for details. This project is not affiliated with Apple Inc. or the [osxphotos](https://github.com/RhetTbull/osxphotos) project.
|
|
506
|
+
|
|
507
|
+
## Contributing
|
|
508
|
+
|
|
509
|
+
Contributions are welcome! Please open an issue or PR at [github.com/sweetrb/apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp).
|
|
510
|
+
|
|
511
|
+
## Related Projects
|
|
512
|
+
|
|
513
|
+
- [apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp) — MCP server for Apple Mail
|
|
514
|
+
- [apple-notes-mcp](https://github.com/sweetrb/apple-notes-mcp) — MCP server for Apple Notes
|
|
515
|
+
- [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers spreadsheets
|
|
516
|
+
- [osxphotos](https://github.com/RhetTbull/osxphotos) — The Python library that powers this server
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../utils/python.js", () => ({
|
|
3
|
+
runPhotosReader: vi.fn(),
|
|
4
|
+
checkDependencies: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
import { PhotosManager } from "../services/photosManager.js";
|
|
7
|
+
import { runPhotosReader, checkDependencies } from "../utils/python.js";
|
|
8
|
+
const runMock = vi.mocked(runPhotosReader);
|
|
9
|
+
const checkMock = vi.mocked(checkDependencies);
|
|
10
|
+
describe("PhotosManager", () => {
|
|
11
|
+
let manager;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
manager = new PhotosManager();
|
|
14
|
+
runMock.mockReset();
|
|
15
|
+
checkMock.mockReset();
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
describe("healthCheck", () => {
|
|
21
|
+
it("returns failure when osxphotos isn't installed", () => {
|
|
22
|
+
checkMock.mockReturnValue({ ok: false, message: "not installed" });
|
|
23
|
+
const result = manager.healthCheck();
|
|
24
|
+
expect(result.ok).toBe(false);
|
|
25
|
+
expect(runMock).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
it("returns success summary when osxphotos works", () => {
|
|
28
|
+
checkMock.mockReturnValue({ ok: true, message: "0.69.0 available" });
|
|
29
|
+
runMock.mockReturnValue({
|
|
30
|
+
data: {
|
|
31
|
+
ok: true,
|
|
32
|
+
osxphotosVersion: "0.69.0",
|
|
33
|
+
libraryPath: "/Library.photoslibrary",
|
|
34
|
+
photoCount: 1234,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const result = manager.healthCheck();
|
|
38
|
+
expect(result.ok).toBe(true);
|
|
39
|
+
expect(result.message).toContain("0.69.0");
|
|
40
|
+
expect(result.message).toContain("1234");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("query", () => {
|
|
44
|
+
it("translates filters to CLI flags", () => {
|
|
45
|
+
runMock.mockReturnValue({ data: { count: 0, photos: [] } });
|
|
46
|
+
manager.query({
|
|
47
|
+
album: ["Vacation", "Family"],
|
|
48
|
+
keyword: ["sunset"],
|
|
49
|
+
favorite: true,
|
|
50
|
+
fromDate: "2025-01-01",
|
|
51
|
+
limit: 50,
|
|
52
|
+
});
|
|
53
|
+
const [, args] = runMock.mock.calls[0];
|
|
54
|
+
expect(args).toEqual([
|
|
55
|
+
"--album",
|
|
56
|
+
"Vacation",
|
|
57
|
+
"--album",
|
|
58
|
+
"Family",
|
|
59
|
+
"--keyword",
|
|
60
|
+
"sunset",
|
|
61
|
+
"--from-date",
|
|
62
|
+
"2025-01-01",
|
|
63
|
+
"--favorite",
|
|
64
|
+
"--limit",
|
|
65
|
+
"50",
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
it("includes --library when provided", () => {
|
|
69
|
+
runMock.mockReturnValue({ data: { count: 0, photos: [] } });
|
|
70
|
+
manager.query({}, "/tmp/Other.photoslibrary");
|
|
71
|
+
const [, args] = runMock.mock.calls[0];
|
|
72
|
+
expect(args.slice(0, 2)).toEqual(["--library", "/tmp/Other.photoslibrary"]);
|
|
73
|
+
});
|
|
74
|
+
it("throws when the python script returns an error", () => {
|
|
75
|
+
runMock.mockReturnValue({ error: "library locked" });
|
|
76
|
+
expect(() => manager.query({})).toThrow("library locked");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("exportPhotos", () => {
|
|
80
|
+
it("rejects empty uuid list before invoking python", () => {
|
|
81
|
+
expect(() => manager.exportPhotos([], "/tmp")).toThrow(/at least one uuid/i);
|
|
82
|
+
expect(runMock).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
it("forwards each uuid as a repeated --uuid flag", () => {
|
|
85
|
+
runMock.mockReturnValue({
|
|
86
|
+
data: {
|
|
87
|
+
destination: "/tmp/out",
|
|
88
|
+
exportedCount: 2,
|
|
89
|
+
skippedCount: 0,
|
|
90
|
+
exported: ["a.jpg", "b.jpg"],
|
|
91
|
+
skipped: [],
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
manager.exportPhotos(["A", "B"], "/tmp/out", { edited: true });
|
|
95
|
+
const [, args] = runMock.mock.calls[0];
|
|
96
|
+
expect(args).toContain("--uuid");
|
|
97
|
+
expect(args.filter((a) => a === "--uuid")).toHaveLength(2);
|
|
98
|
+
expect(args).toContain("A");
|
|
99
|
+
expect(args).toContain("B");
|
|
100
|
+
expect(args).toContain("--edited");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("listKeywords", () => {
|
|
104
|
+
it("passes --limit when supplied", () => {
|
|
105
|
+
runMock.mockReturnValue({ data: { count: 0, keywords: [] } });
|
|
106
|
+
manager.listKeywords(20);
|
|
107
|
+
const [, args] = runMock.mock.calls[0];
|
|
108
|
+
expect(args).toEqual(["--limit", "20"]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|