Skip to main content
Glama
README.md
# gphotos-mcp

A read-only [MCP](https://modelcontextprotocol.io) server that lets Claude list, search, inspect
and *look at* the photos in one personal Google Photos library. It talks to the same internal
`batchexecute` RPC that photos.google.com itself uses, through
[gpwc](https://github.com/xob0t/google_photos_web_client), and signs in with a `cookies.txt`
exported from your browser. Nothing in it can modify the library.

## Why the unofficial web API

On 31 March 2025 Google removed the Photos Library API scopes that gave apps access to a user's
existing media (`photoslibrary.readonly` and friends); what remains is the Picker API and access to
media an app uploaded itself. Reading your whole library programmatically today means using the
web app's private RPC, which is what this server does.

## Tools

| tool | arguments | returns |
|---|---|---|
| `list_library` | `page_token?`, `limit=50` | newest-first by taken date: `media_key`, `filename`, `taken_at` (ISO 8601 with the item's own UTC offset), `type` (`photo`/`video`), `width`, `height`; plus `next_page_token` |
| `search` | `query`, `limit=50`, `page_token?` | same item shape, using Google Photos' own search (people, places, things, text in images, dates) |
| `list_albums` | — | `album_key`, `title`, `item_count`, `cover_thumbnail_url`, `is_shared` |
| `get_album` | `album_key`, `page_token?`, `limit=50` | album title / count and its items, same item shape |
| `get_media_info` | `media_key` | what the web UI's info panel shows: taken / uploaded time, dimensions, file size, camera (raw), location (decimal degrees + place name), description, albums it belongs to, upload source, favourite / archived flags, storage quality |
| `get_media` | `media_key`, `max_px=1024` | the picture as MCP image content (JPEG, longest side ≤ `max_px`, quality 80); for a video the poster frame plus a note that it is a video |
| `download_media` | `media_key`, `dest_dir` | saves the original file to disk, returns `path`, `filename`, `bytes` — **stdio mode only** |
| `get_storage_quota` | — | `used_bytes`, `total_bytes`, `photos_bytes` |

The item shape is identical across `list_library`, `search` and `get_album`. `docs/PAYLOADS.md`
maps every tool to the RPC behind it.

## Requirements

* Python 3.11+ and [uv](https://docs.astral.sh/uv/) — or Docker with Compose v2.
* A browser to export cookies from, and (for the claude.ai connector) a way to expose one port,
  e.g. [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
  or [Tailscale Funnel](https://tailscale.com/kb/1223/funnel).

## Cookies (authentication)

The server authenticates exactly like your browser does. Export the cookies from a
**dedicated incognito session that you never open again**, as described in the
[gpwc README](https://github.com/xob0t/google_photos_web_client#proper-way-to-extract-the-cookies):

1. Install the [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/Get%20cookies.txt%20LOCALLY/cclelndahbckbenkjhflpdbgdldlbecc) extension and allow it in private / incognito windows.
2. Open a new incognito window and sign in to Google.
3. Open [photos.google.com](https://photos.google.com/) and go to your library page.
4. Open a new empty tab, then close the Google Photos tab.
5. Click **Export All Cookies** in the extension and save the file as `cookies.txt`.
6. Close the incognito window and do not reopen that session in a browser.

Keep the file private (`chmod 600 cookies.txt`): it is equivalent to your signed-in browser.
When Google invalidates the session every tool fails with
`Google session expired — re-export cookies.txt`; export a fresh file to the same path and the
server picks it up on the next call (no restart needed).

If the browser session held several Google accounts, `GP_ACCOUNT_INDEX` selects one
(the `N` in `photos.google.com/u/N/`).

## Configuration

All settings are environment variables; copy `.env.example` to `.env`.

| variable | default | meaning |
|---|---|---|
| `GP_COOKIES_FILE` | *(required)* | path to the Netscape `cookies.txt` |
| `GP_ACCOUNT_INDEX` | `0` | account within the cookie session |
| `GP_CACHE_DIR` | `~/.cache/gphotos-mcp` | cache of downscaled JPEGs, keyed by `(media_key, max_px)` |
| `MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
| `MCP_HOST` | `127.0.0.1` | bind address in http mode (`0.0.0.0` inside Docker) |
| `MCP_PORT` | `8765` | port in http mode |
| `MCP_AUTH_TOKEN` | *(required in http mode)* | ≥ 16 URL-safe characters; `openssl rand -hex 32` |

In http mode the server refuses to start without `MCP_AUTH_TOKEN`. The token is accepted either as
`Authorization: Bearer <token>` or as a secret path prefix, `https://host/<token>/mcp`, for
connector UIs that cannot set headers. Cookies, tokens and request bodies are never logged
(uvicorn's access log is off for that reason).

## Running

### Native

```bash
uv sync
GP_COOKIES_FILE=/path/to/cookies.txt uv run gphotos-mcp          # stdio
MCP_TRANSPORT=streamable-http MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
GP_COOKIES_FILE=/path/to/cookies.txt uv run gphotos-mcp          # http on 127.0.0.1:8765/mcp
```

### Docker compose

```bash
cp .env.example .env           # set GP_COOKIES_FILE (host path) and MCP_AUTH_TOKEN
docker compose up -d --build   # Streamable HTTP on 127.0.0.1:8765
docker compose logs -f
```

The compose service always runs in http mode, publishes the port on localhost only, mounts the
cookies file read-only at `/config/cookies.txt`, keeps the image cache in a named volume and maps
`./downloads` to `/downloads`. The container runs as uid 1000, so on Linux the cookies file must be
readable by that uid.

For stdio through Docker (Claude Desktop / Claude Code on the same machine):

```bash
docker compose run --rm -i -e MCP_TRANSPORT=stdio gphotos-mcp
```

`download_media` then writes inside the container: pass `dest_dir=/downloads` to land in
`./downloads` on the host.

## Claude Desktop and Claude Code (stdio)

Claude Desktop: add to `claude_desktop_config.json`
(macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`).
Claude Code: the same object in `.mcp.json` (project) or `~/.claude.json` (user), or run
`claude mcp add gphotos -e GP_COOKIES_FILE=/abs/path/cookies.txt -- uv run --directory /abs/path/google-photos-mcp gphotos-mcp`.

```json
{
  "mcpServers": {
    "gphotos": {
      "command": "uv",
      "args": ["run", "--directory", "/abs/path/google-photos-mcp", "gphotos-mcp"],
      "env": { "GP_COOKIES_FILE": "/abs/path/cookies.txt" }
    }
  }
}
```

Docker variant (needs `.env` next to the compose file):

```json
{
  "mcpServers": {
    "gphotos": {
      "command": "docker",
      "args": ["compose", "-f", "/abs/path/google-photos-mcp/docker-compose.yml",
               "run", "--rm", "-i", "-e", "MCP_TRANSPORT=stdio", "gphotos-mcp"]
    }
  }
}
```

## claude.ai custom connector (http mode)

claude.ai can only reach a public HTTPS URL, so put a tunnel in front of the server. Keep the
port bound to localhost and let the tunnel do the exposure; the token is the only access control.

1. Set `MCP_AUTH_TOKEN` in `.env` and start the server: `docker compose up -d --build`.
2. Expose it, for example:
   * cloudflared quick tunnel: `cloudflared tunnel --url http://127.0.0.1:8765` prints a
     `https://….trycloudflare.com` hostname (it changes on every run; use a named tunnel for a
     stable one), or
   * Tailscale Funnel: `tailscale funnel 8765` gives `https://<machine>.<tailnet>.ts.net`.
3. In claude.ai open **Settings → Connectors → Add custom connector** and enter
   `https://<public-host>/<MCP_AUTH_TOKEN>/mcp`. Leave the OAuth client fields empty.
   Clients that can send headers may instead use `https://<public-host>/mcp` with
   `Authorization: Bearer <MCP_AUTH_TOKEN>`.

The secret is part of the URL, so it will appear in the tunnel provider's logs and in the
connector settings. Rotate it by changing `MCP_AUTH_TOKEN` and restarting. `download_media` is
not offered in http mode because the server's disk is not the client's.

## How it treats Google

Google's abuse detection is the main operational risk, so the server is deliberately slow:

* exactly one request in flight at a time, at least 300 ms apart, no parallelism anywhere;
* 429 / 5xx / network errors are retried at most 3 times with jittered exponential backoff;
* `get_media` results are cached on disk, so re-viewing an image costs nothing;
* list calls cost two RPCs per page (the list itself, then one batch call for filenames);
  `get_media_info` and `download_media` batch their two RPCs into one HTTP request.

## Risks

* **Terms of service.** This uses Google's private web API with your own session. It is a grey
  area; automated access can get an account rate-limited or flagged. Use it for yourself, keep
  volumes small, and do not run several instances against one account.
* **It will break.** The RPC ids and array layouts are undocumented and change without notice.
  When that happens the parsers in `src/gphotos_mcp/rpc.py` need updating; `docs/PAYLOADS.md`
  records where every index came from.
* **The cookies are your account.** Anyone holding `cookies.txt` can act as you on Google. Store
  it like a password and export it from a session you do not use for anything else.
* **The http token is a bearer secret.** Whoever has the URL has read access to your photos.

## Not supported

* Anything that writes: delete, trash, move, edit, favourite, album changes, uploads
  (uploads are [gpmc](https://github.com/xob0t/gphotos_mobile_client)'s job). Not even behind a flag.
* The official Library / Picker APIs and OAuth.
* `cover_media_key` for albums: the web API only exposes the cover as a thumbnail URL.
* Structured EXIF (aperture, exposure, ISO, focal length): the info RPC carries one opaque
  `camera` value whose layout is undocumented; it is passed through as-is.
* Trash, archive, locked folder, partner sharing, shared links, the video half of Live Photos.
* Bulk operations, more than one account per server instance, `download_media` in http mode.

## Development

```bash
uv sync                                          # includes the dev group (pytest)
uv run pytest                                    # unit tests, no network, recorded fixtures
GP_COOKIES_FILE=cookies.txt uv run python scripts/smoke.py --query dog   # live check of every tool
```

Layout: `src/gphotos_mcp/client.py` is the adapter (plain dataclasses in and out; gpwc is used
for the session and request payloads), `rpc.py` parses the raw arrays, `transport.py` holds the
pacing / retry / session-expiry logic, and `server.py` only maps tools onto the adapter.
`tests/fixtures/` are sanitized RPC responses (see the README there).

Dependencies: `mcp` 2.x (its high-level server class was called `FastMCP` in 1.x and is
`MCPServer` in 2.x; same decorator API), `gpwc` pinned to a git commit, and `pillow`. `httpx` is
not used directly: every HTTP call goes through gpwc's `requests` session so the cookie jar,
timeouts, pacing and retries live in one place (gpwc's own tests download the same way).

## Credits

[xob0t](https://github.com/xob0t) reverse-engineered the protocol in
[google_photos_web_client](https://github.com/xob0t/google_photos_web_client) and
[Google-Photos-Toolkit](https://github.com/xob0t/Google-Photos-Toolkit); the recorded fixtures
derive from the latter (MIT). Background on `batchexecute`:
[Deciphering Google's batchexecute](https://kovatch.medium.com/deciphering-google-batchexecute-74991e4e446c).