photos-mcp
Read-only access to one Google Photos library via the private batchexecute RPCs used by photos.google.com: search photos by people, places, things, text in images or dates, list the library, archive or albums, fetch item details (dates, location, camera, size, albums), and view photos or videos as generated JPEGs, contact sheets and thumbnails. Includes session management tooling (cookie import or a dedicated browser profile), status/canary monitoring, thumbnails caching and cursor-based pagination. No write path: search and viewing only.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@photos-mcpfind photos of my dog from last summer"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
photos-mcp
Read-only MCP server for one Google Photos library. An assistant can find photos with Google Photos' own search (people, places, things, text in images, dates) and look at them, and nothing more: there is no write path.
Google's official Library API only sees media created by the calling app since
2025-03-31, so photos-mcp speaks the private batchexecute RPCs that photos.google.com
itself uses, with the cookies of a signed-in browser.
Unofficial. photos-mcp is not affiliated with or endorsed by Google. It relies on an undocumented web API that can change at any time, and automated access may conflict with Google's terms of service: use it only with your own account, at your own risk. The session snapshot it reads carries full authority over that Google account; keep it private (the tools create it 0600 in a 0700 directory).
The repository is google-photos-mcp; the Python package and its commands are
photos-mcp and photos-mcp-session. It is not on PyPI: install it from GitHub. It is
developed and tested on Linux (Python 3.12+).
your browser profile ──photos-mcp-session──▶ session dir {cookies.txt, meta.json}
│ read-only
MCP client (stdio, or HTTP + bearer) ◀──▶ photos-mcp ─┘ state dir: budgets, breaker, thumbnail cacheQuick start
Install (the
sessionextra adds the browser exporter):uv tool install "photos-mcp[session] @ git+https://github.com/benjamin-di/google-photos-mcp" photos-mcp-session browser install # once: Playwright's Chromium (--with-deps also installs # its Linux system libraries, which needs root)or use the image
ghcr.io/benjamin-di/google-photos-mcpfor the server.Create a session snapshot in a private directory, one of two ways:
Cookies from your own browser (schema 1). In Chrome or Chromium, open photos.google.com and one photo, export the cookies as a Netscape
cookies.txt(any cookie-export extension), then:photos-mcp-session import --cookies cookies.txt --email you@example.com \ --user-agent "<navigator.userAgent of that browser>" --out ~/.photos-mcp/sessionDelete the exported
cookies.txtafterwards. Signing out of that browser ends the session, and the snapshot is never refreshed: import again when photos-mcp reportssession_expired(itsphotos_canarycalls an imported snapshotsnapshot_staleafter 3 h; that is expected).Dedicated browser profile (experimental: schema 2, the browser's real request headers, refreshable unattended). Sign in by hand once, then export:
photos-mcp-session browser login --profile ~/.photos-mcp/profile photos-mcp-session browser export --profile ~/.photos-mcp/profile --out ~/.photos-mcp/session \ --expected-email you@example.comBoth commands open a browser window, so they need a display (on a server:
xvfb-run -a photos-mcp-session ...). If Google refuses the sign-in in Playwright's Chromium, use an installed Google Chrome with--channel chrome, forloginandexportalike. One command at a time per profile (the browser locks it).exportnever signs in; when the profile is signed out, runloginagain.
Run the server. For a local client (Claude Desktop, Claude Code, ...) over stdio, with the absolute path of
photos-mcp(command -v photos-mcp; desktop apps may not share your shell'sPATH):{ "mcpServers": { "photos": { "command": "/home/you/.local/bin/photos-mcp", "args": ["--transport", "stdio"], "env": { "GOOGLE_PHOTOS_EXPECTED_EMAIL": "you@example.com", "PHOTOS_SESSION_DIR": "/home/you/.photos-mcp/session", "PHOTOS_STATE_DIR": "/home/you/.photos-mcp/state" } } } }Or over HTTP (
/mcp, bearer required;/healthis open). The image listens on port 8000; publish it on localhost and run it as your own uid so it can read the snapshot:mkdir -p ~/.photos-mcp/state TOKEN="$(openssl rand -hex 32)"; echo "bearer token: $TOKEN" docker run -d --name photos-mcp -p 127.0.0.1:8000:8000 --read-only --user "$(id -u):$(id -g)" \ -e MCP_BEARER_TOKEN="$TOKEN" -e GOOGLE_PHOTOS_EXPECTED_EMAIL=you@example.com \ -v ~/.photos-mcp/session:/session:ro -v ~/.photos-mcp/state:/state \ ghcr.io/benjamin-di/google-photos-mcpClients send
Authorization: Bearer <token>tohttp://127.0.0.1:8000/mcp. Put a TLS reverse proxy in front before exposing it beyond localhost. Without Docker,photos-mcpserves HTTP on 127.0.0.1:8000 (--host,--port).Keep the session fresh. Google rotates session cookies. With a browser profile, re-run
browser exportregularly, for example hourly from cron:0 * * * * flock -n ~/.photos-mcp/export.lock xvfb-run -a photos-mcp-session browser export --profile ~/.photos-mcp/profile --out ~/.photos-mcp/session --expected-email you@example.comphotos-mcp picks up each new generation by itself.
photos_statusshows the snapshot's age.
Related MCP server: Google Photos MCP
Privacy
The images and metadata the tools return go to your MCP client and, through it, to the model provider it uses.
The state directory keeps a cache of thumbnails of your photos (up to
PHOTOS_CACHE_MAX_BYTES, 2 GiB by default;0disables it). Treat it like the photos.The session directory and the browser profile give full access to the Google account.
The server talks to nothing but Google's own photo hosts (see "Safety and Google posture"), and its logs carry no photo ids, URLs or cookies.
Tools
All are read-only (readOnlyHint); all but photos_status are openWorldHint.
Arguments that fail the schema come back as the typed invalid_argument error naming the
argument, never pydantic's raw text.
Tool | Input | Output |
|
| items + |
|
| items + |
|
|
|
|
| the item shape plus |
|
| legend |
|
| per id a label block (width/height of the returned JPEG) + a JPEG block (videos: poster frame) |
| – | account, generation, snapshot age, breaker, budgets left, cache, last error |
| – |
|
Items have one shape in search, list, album and get, every key present (null when
unknown): {id, type, taken_at, tz_known, filename, description, place, favourite, archived}. taken_at carries the item's UTC offset when tz_known; without a known
offset it is the instant in UTC with Z (never a fake +00:00). Search records carry no
place, so place is null there. archived comes from the source for photos_list
library (false) and archive (true), because live library records end before the flag;
search and photos_get read it from the record; it is null where the record does not
say (album items). Filenames come from a second RPC (EWgK9e); if it fails for any reason other than
the session, the items go out with filename: null. Images are at most 1568 px; there is
no original or download path. Listings carry coverage (pagination_complete,
shared_partner: "not_covered", archive_included — false only for
source=library — and cursor_recovered / dedupe_required / loop_detected when they
apply) and freshness (generation, session_verified_at, served_at).
photos_canary is the real-path check for monitoring: one real check (an
upstream library page of 1 item plus that item's thumbnail, bypassing the thumbnail
cache, through the normal RPC, parser and image path, budgets and breaker); calls in
between return the cached verdict with cached: true, unless a newer snapshot was published
since: a verdict belongs to the generation it checked. An ok result and lasting failures
(session, block, consent, budget, schema, media_session_required) are cached 55 min; transient ones
(upstream_error, busy, stale_token, item_unavailable, internal_error) are
re-checked after 5 min. ok is false on any error code, and with code snapshot_stale
when the snapshot is older than 3 h (snapshot_age_s > 10800) or its verified_at lies
more than 5 min in the future (negative snapshot_age_s); it never raises and never
logs ids.
photos_status.last_error records real failures, item-level ones included (for example
media_session_required on one thumbnail), but not the agent's own invalid_argument or
invalid_cursor.
Cursors are server-side, scope-bound (query, source or album) and live 1 h. An expired
cursor, or one from before a restart, is recovered by re-reading from the start and
skipping what was delivered (at most 5 upstream pages: 500 items for the library and the
archive, which use fixed Google pages of 100); the result says dedupe_required.
Errors
Errors are tool errors whose text is {"error": {"code", "next_step", "retryable"}}:
Code | Detected by | Reaction |
| no | publish a snapshot |
| redirect to | wait for a newer generation |
|
| refuse; never auto-corrected |
|
| breaker: no Google calls for 30 min, then 2 h, then 8 h (persisted) |
| redirect to | reported and retryable later; the generation is kept |
| batchexecute 400/403 after one globals rebuild and retry | retry later |
| no | that tool fails |
| image request redirected to sign-in or consent (or HTML/401); never affects the session | that item fails |
| 403/404 for one item, not an image, bomb, unknown id | that item only |
| daily RPC or thumbnail budget spent | until midnight in |
| waited 30 s for the single request slot | retryable |
| 5xx or network after 2 retries (a body must finish within 30 s), an app page without | retryable; the generation is kept |
| our own checks | as named |
Liveness
Only positive evidence marks a session dead: a redirect to accounts.google.com or to
Google's signed-out product page (www.google.com/photos/about), or another account than
GOOGLE_PHOTOS_EXPECTED_EMAIL. That generation is then never sent to Google again;
photos-mcp waits for a newer one. Everything else (a consent screen, an error page, a 5xx,
a bare 401, an app page without its signed-in data) is reported and retried later with
the same generation.
Safety and Google posture
No write path. Only the read builders of gpwc are vendored; the transport refuses any RPC id outside
lcxiM EzkLib EWgK9e Z5xsfc snAcKc VrseUb fDcn4b EzwWhf;tests/test_safety.pychecks the source for other RPC ids, write builders, other hosts and other HTTP libraries.Hosts: HTTPS only,
photos.google.com,photos.fife.usercontent.google.com,lh[0-9].googleusercontent.com. Redirects are followed by hand, at most 3, inside that list; sign-in, consent and/sorryredirects are classified, never followed.Pacing: RPCs and the app page one at a time, ≥350 ms apart plus jitter; image fetches up to 4 at a time, starts ≥100 ms apart; 10 s connect / 20 s read, ≤2 retries on 5xx and network errors only. Daily budgets
PHOTOS_DAILY_RPC_BUDGET(3000) andPHOTOS_DAILY_THUMB_BUDGET(2000).Request shape: the browser behind the snapshot is the source of truth. Each request uses
meta.json's header values for its kind (page,rpc,image; or the UA-synthesised template where that kind fell back, see below), plus the request's cookie, referer andaccept-encoding, all in Chrome's per-kind wire order (session.CANONICAL_ORDER). curl_cffi impersonates the newestchromeNprofile with N ≤chromium_major(the nearest one when none is older) withdefault_headers=False, so it adds no headers of its own.tests/test_curl_sender.pycaptures what goes on the wire and fails when the sample's Chrome is more than 4 majors past curl_cffi's newest profile (thenmake locka newer curl_cffi).Untrusted bytes: RPC bodies ≤8 MiB and images ≤15 MiB, streamed; magic bytes before Pillow; a decompression-bomb warning is an error; JPEGs are
draft()-reduced beforeload().Hygiene: tool output never carries exception text; logs carry RPC ids, host, status, bytes and ms only. Cookies stay in memory: allowlisted
Set-Cookieupdates are merged there and never written back. photos-mcp never rotates cookies. Tool errors are logged as one line, never with a traceback: INFO for the agent's own mistakes (invalid_argument,invalid_cursor), WARNING otherwise (the ToolError'slog_level, which FastMCP and the tool-call log follow). An unexpected crash logstool crashed: <type>at ERROR and answersinternal_error.
Session snapshot
PHOTOS_SESSION_DIR (default /session, mounted read-only) holds:
cookies.txt, Netscape format. Host-only rows have include-subdomainsFALSEand no leading dot (the CDN's ownOSIDonphotos.fife.usercontent.google.com); domain rows haveTRUEand a leading dot. Rows for domains other thangoogle.com,photos.google.comandphotos.fife.usercontent.google.comare ignored.meta.json(schema 2):{"schema": 2, "generation": <int, monotonic>, "email", "account_index": 0, "user_agent", "chromium_major", "verified_at": <ISO 8601>, "headers": {"page": [[name, value], …], "rpc": […], "image": […]}}. It is written last, so it is the commit point. Eachheaders.<kind>is the ordered header list Chrome sent for one real request of that kind while the library loaded: names lower-case, at most 32 pairs, single-line values ≤ 512 chars,user-agentequal touser_agent, and onlyuser-agent,accept,accept-language,content-type(rpc only),origin,x-same-domain,upgrade-insecure-requests,priority,sec-ch-ua*andsec-fetch-*. Other names are ignored, structural errors make the snapshotsession_unavailable, andchromium_majormust match the User-Agent. A kind withoutsec-fetch-site,sec-fetch-modeandsec-fetch-dest, or whose dest contradicts the kind (pagedocument, rpcempty, imageimage), uses the synthesised template below for that kind only, logged once per generation without values;photos-mcp-sessionapplies the same rule when it writes a snapshot. Values come from the browser, order from photos-mcp: a CDP capture reports names sorted, so every template is put in Chrome's per-kind wire order (session.CANONICAL_ORDER, recorded from a real Chrome with--log-net-log;tests/fixtures/chrome_wire_order.jsonkeeps those names). The order covers the headers the transport adds too:referer,accept-encodingandcookiego last, beforepriority, as Chrome sends them; an unknown name goes just beforesec-fetch-site.Schema 1 (no
headers; whatphotos-mcp-session importwrites) is read too: the templates are then synthesised from the User-Agent (its Chrome major insec-ch-ua, its platform, Chrome's per-kindsec-fetch-*values), so they never contradict it.
meta.json is stat-ed on every call; a newer generation rebuilds the HTTP session and
re-reads the page globals. A generation Google rejected is not retried. A checked-in
sample lives in tests/fixtures/session/ (meta.json schema 2, meta.schema1.json
schema 1). Anything that writes this format can keep the session for photos-mcp; nothing
flows back from photos-mcp to it.
Configuration
Variable | Default | |
| – | required; the only account served |
| – | required for |
|
| the snapshot, read-only |
|
|
|
| 3000 | RPCs plus app-page loads per day |
| 2000 | image fetches per day (cache hits are free) |
|
| IANA time zone whose midnight resets the budgets |
| 2 GiB | thumbnail cache, LRU; 0 disables it |
|
| photos-mcp's own log lines, JSON on stderr; FastMCP's startup lines and, over HTTP, the web server's access lines (with client addresses) are plain text |
photos-mcp [--transport http|stdio] [--host 127.0.0.1] [--port 8000] (the image passes
--host 0.0.0.0): HTTP serves
stateless streamable HTTP with JSON replies on /mcp and liveness on /health (it never
calls Google).
Operations
Symptom | Action |
| export a fresh snapshot ( |
| wait out the breaker ( |
| Google changed a response: open an issue with the tool and |
| the snapshot lacks the image host's cookies: open a photo in the profile, export again |
| stop; the snapshot or |
Kill switch: stop the server and delete the session directory; sign the profile out of Google (or revoke the session in your Google account's security settings).
Development
make bootstrap, then make check (ruff, format, lock consistency, pytest; see
AGENTS.md). make lock after changing dependencies in pyproject.toml. Tests never
reach Google.
Recorded fixtures: python -m photos_mcp.capture OUT_DIR [--query Q]
(scripts/capture-fixtures is the same entry point) records anonymised real answers
through the normal transport, budgets and breaker, including the no-cookie signed-out
answer. Run it with the server's configuration and state directory (for example
docker exec photos-mcp python -m photos_mcp.capture /state/recorded; both keep budgets
and the breaker in state.json, so keep the run short), review the output, check it in under
tests/fixtures/recorded/ (tests/test_recorded.py and tests/test_fixture_privacy.py
pick it up), then delete the output directory. The anonymiser replaces every string and
every number about the account (see capture.py).
Layout: src/photos_mcp/ — server.py (tools, transports), client.py (tool logic,
paging, thumbnails), transport.py (HTTP policy), session.py (snapshot reader, cookies),
session_export.py (photos-mcp-session), state.py (budgets, breaker), config.py
(settings), errors.py (error codes), cursors.py, cache.py, images.py, rpc.py
(response parsers), capture.py (recorded fixtures), auth.py, logs.py, vendor/gpwc/
(request framing, read payloads). docs/PAYLOADS.md maps every RPC and field path to its
source.
Report security problems privately as described in SECURITY.md, not in public issues.
Licence and provenance
MIT (LICENSE). Based on vsmelov/google-photos-mcp,
used under the MIT licence with its author's permission;
vendors a modified subset of gpwc and
derives its parser fixtures from Google-Photos-Toolkit,
both MIT. See NOTICE and THIRD_PARTY_NOTICES.
This server cannot be deployed
Maintenance
Related MCP Connectors
Search photos and video moments by visual meaning, people, dates, and folders across local libraries, Apple Photos, and OneDrive. Manage collections, groups, favorites, people, and duplicate review. Synced metadata remains searchable while the desktop is offline; local and Apple Photos media access needs the owning device online with remote access enabled. OneDrive media can be accessed directly from the cloud. OAuth or Bearer API keys. Paid Cloud Access required; hosted visual search uses prepaid credits. Text-in-image search is semantic matching, not guaranteed exact OCR. Setup: https://organizewith.ai/en/guides/connect-ai-assistants
Read-only Search Console analytics, URL inspection, indexing diagnostics, and sitemaps.
Read-only Google Search Console and Google Analytics for AI agents.
Permissioned access to Gmail, Drive and Calendar via the user's own Google account
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceEnables AI assistants to search a self-hosted photo and video library, view photos, and manage albums through MCP, with all actions scoped to the connected account and vault items hidden.1AGPL 3.0
- AlicenseAqualityAmaintenanceEnables AI agents to work with a user's Google Photos through Google's picker, letting users choose exactly what the agent sees while providing tools for uploading media, creating and sharing albums, writing captions and enrichments, searching app-created media, and managing multiple Google accounts.2941 npm1MIT
- AlicenseAqualityBmaintenanceEnables read-only access to Google Search Console data, including property discovery, search analytics queries, and URL inspection, through a security-focused interface.388 PyPI1MIT
- FlicenseNot gradedqualityCmaintenanceEnables read-only access to a personal Google Photos library, allowing listing, searching, inspecting, and viewing photos and albums via MCP tools.-