Skip to main content

photos-mcp

MCP server for Google Photos libraries. An assistant can find photos with Google Photos' own search (people, places, things, text in images, dates), look at them, show them to the person as a gallery, and organise albums (create one, add photos, take them out again after the person confirms). It never deletes, archives, edits or shares a photo (Safety). It serves one library, or one per person behind an authenticating proxy (Accounts), where each person connects their own Google account through a sign-in link (Signing in with a link).

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 and the PyPI package are google-photos-mcp; its commands are google-photos-mcp (also installed as photos-mcp) and photos-mcp-session. 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 cache
                                              │ sign-in links (optional, further accounts)
                                    photos-mcp-login: one browser profile per account ──▶ their snapshots

Quick start

  1. Install (the session extra adds the browser exporter):

    uv tool install "google-photos-mcp[session]"
    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-mcp for the server.

  2. 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/session
      

      Delete the exported cookies.txt afterwards. Signing out of that browser ends the session, and the snapshot is never refreshed: import again when photos-mcp reports session_expired (its photos_canary calls an imported snapshot snapshot_stale after 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.com
      

      Both 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, for login and export alike. One command at a time per profile (the browser locks it). export never signs in; when the profile is signed out, run login again.

  3. 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's PATH):

    {
      "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; /health is 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-mcp
    

    Clients send Authorization: Bearer <token> to http://127.0.0.1:8000/mcp. Put a TLS reverse proxy in front before exposing it beyond localhost. Without Docker, photos-mcp serves HTTP on 127.0.0.1:8000 (--host, --port).

  4. Keep the session fresh. Google rotates session cookies. With a browser profile, re-run browser export regularly, 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.com
    

    photos-mcp picks up each new generation by itself. photos_status shows the snapshot's age.

Privacy

  • The images and metadata the tools return go to your MCP client and, through it, to the model provider it uses.
  • With sign-in links, the login service keeps each person's browser profile, which stays signed in to their whole Google account (not only Photos), and their snapshot. Whoever runs the server can use both: tell people so before they connect. They can end it any time in their Google Account (Security, Your devices).
  • The state directory keeps a cache of thumbnails of your photos (up to PHOTOS_CACHE_MAX_BYTES, 2 GiB by default; 0 disables 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) except the album changes (photos_album_create, photos_album_add, and photos_album_remove, the only destructiveHint one) and photos_connect, which makes a sign-in link and exists only where sign-in links are configured; all but photos_status and photos_connect 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
photos_search query (≤200), media all|photos|videos, details, limit 1–50 (20), cursor items + next_cursor
photos_list source library|archive|album|favourites|recently_added, album_id (iff album), from_date/to_date (library and archive), media, details, limit, cursor items + next_cursor (Google pages of 100, sliced); with source=album also album: {id, url, title, count}
photos_albums limit, cursor {id, url, title, count}, plus shared: true for shared albums
photos_get ids ≤10 (one batchexecute for all) the item shape plus uploaded_at (UTC), lat/lon, camera, original width/height, size, duration, albums, source, original_quality
photos_on_this_day date (today), years 1–20 (10), per_year 1–20 (6) {date, years: [{year, items, more}], years_checked}
photos_album_create title (≤200), ids ≤50 the new album {id, url, title, count, shared}, added
photos_album_add album_id, ids ≤50 (from search or list) the album afterwards, requested, added (from its counts)
photos_album_remove album_id, ids ≤50 (as the album's own listing gives them), confirm without confirm: a preview (remove, not_in_album, confirm_token, expires_at) and no change; with it: the album afterwards and removed
photos_show items ≤12 {id, note}, title a gallery card for the person (Photo cards); the same data as JSON for other clients
photos_contact_sheet ids ≤24 legend n → id, date + one JPEG grid ≤1568 px, numbered tiles, video badge
photos_view ids ≤4, max_px 256–1568 (1024) per id a label block (width/height of the returned JPEG) + a JPEG block (videos: poster frame)
photos_status – account, generation, snapshot age, breaker, budgets left, cache, last error
photos_canary – {ok, code, checked_at, cached, snapshot_age_s, generation} (monitoring)
photos_connect – a one-time sign-in link for the caller's own account: {url, expires_at, account_id, signed_in, next_step} (Signing in with a link)

Items have one shape in search, list, album and get, every key present (null when unknown): {id, url, type, taken_at, tz_known, filename, description, place, favourite, archived}. url opens the item on photos.google.com (inside its album when listed from one); the person's own browser signs in, so it is also how they get an original. 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 unless details=true, which reads each item's details (one item-info RPC per item, ten to a request; limit up to 20) and adds lat, lon and camera, filling place where the listing had none. photos_get adds source (how the item reached the library: mobile, web, shared, partnerShared, drive, pc, gmail, and android or ios) and original_quality. 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_list sources: library (newest first by taken date, archive excluded), archive, album, favourites and recently_added (newest upload first; both are the web app's own filtered views of the search RPC and include archived items). from_date / to_date (YYYY-MM-DD, library and archive) bound the items' own local dates: the listing starts Google's timeline at the end of the range and stops once it has passed the start. media=photos|videos keeps one kind. A filtered listing reads up to 5 Google pages per call to find matches, so a page can hold fewer than limit items while next_cursor goes on.

photos_on_this_day reads one library page per earlier year, starting at that day (up to 100 items; more says the day holds more than shown or than one page could tell: list it with from_date=to_date), stops at the first year with nothing that old, and looks up all filenames in one RPC. "Today" is in PHOTOS_BUDGET_TZ.

Photo cards

photos_show gives claude.ai, ChatGPT and other MCP Apps clients a gallery to draw for the person: thumbnail, date, the agent's note and a link that opens each photo in Google Photos. The thumbnails (320 px, from the same paced and cached image path as photos_view) travel inline in the result's _meta["photos/view"] as JPEG data: URLs, which the model is not sent; the card (ui://photos/view.html, src/photos_mcp/view.html) loads nothing else and opens links through the client. Clients without MCP Apps get the same items as JSON.

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
session_unavailable no meta.json, unreadable or malformed snapshot publish a snapshot
session_expired redirect to accounts.google.com or Google's signed-out google.com/photos/about (the generation is marked dead: no Google calls until a newer one); a bare 401 (reported, generation kept) wait for a newer generation
account_mismatch meta.email or oPEP7c ≠ GOOGLE_PHOTOS_EXPECTED_EMAIL (the generation is marked dead) refuse; never auto-corrected
blocked /sorry, 429, captcha breaker: no Google calls for 30 min, then 2 h, then 8 h (persisted)
consent_required redirect to consent.google.com from the app page or batchexecute reported and retryable later; the generation is kept
stale_token batchexecute 400/403 after one globals rebuild and retry retry later
schema_changed no wrb.fr envelope, all er, undecodable data, parser invariant that tool fails
media_session_required image request redirected to sign-in or consent (or HTML/401); never affects the session that item fails
item_unavailable 403/404 for one item, not an image, bomb, unknown id that item only
budget_exhausted daily RPC or thumbnail budget spent until midnight in PHOTOS_BUDGET_TZ
busy waited 30 s for the single request slot retryable
upstream_error 5xx or network after 2 retries (a body must finish within 30 s), an app page without _gd, HTML or a redirect inside Google from batchexecute retryable; the generation is kept
invalid_argument, invalid_cursor, cursor_expired, internal_error our own checks as named
not_signed_in a further account without a snapshot (never connected, or its browser was signed out), or not yet bound to a Google address photos_connect gives the person a sign-in link
account_not_connected the proxy named an account that is not configured, named none, or another bearer named one the operator adds the account; never served from another account
connect_unavailable photos_connect for the default account (its session is the operator's) as named
connect_limited more than 6 links an hour for one account give the newest link
outcome_unknown an album change failed after it was sent (5xx or network): it is never repeated read the album to see what happened
refused Google refused an album change (an album this account cannot edit) nothing changed
confirm_invalid a removal's token is unknown, expired, used, or for other arguments preview again and ask the person

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

  • Album changes only. Reads go through GoogleTransport.rpc, which refuses any RPC id outside lcxiM EzkLib EWgK9e Z5xsfc snAcKc VrseUb fDcn4b EzwWhf. Changes go through GoogleTransport.write, which refuses any outside OXvT9d E1Cajb laUYf ycV3Nd LjmOue (create an album; add items to an album or a shared one; remove items from an album or a shared one) and never retries a request that may have reached Google (outcome_unknown, logged). No builder exists for deleting, trashing, archiving, favouriting, editing dates, places or descriptions, sharing, the locked folder or downloads; tests/test_safety.py checks the source for other RPC ids and builders, other hosts and other HTTP libraries.
  • Removing is confirmed. photos_album_remove first finds the ids in the album's own listing (Google's remove RPC names no album, so a key from another album would act there) and returns a preview and a single-use token, valid 10 minutes, for exactly that album and those ids; only that token removes them. Every change is read back from the album (added / removed from its counts), counts against PHOTOS_DAILY_WRITE_BUDGET (each account's own, 500 a day) and is appended to audit.jsonl in the account's state directory (ids and counts only).
  • 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 /sorry redirects 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) and PHOTOS_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 and accept-encoding, all in Chrome's per-kind wire order (session.CANONICAL_ORDER). curl_cffi impersonates the newest chromeN profile with N ≤ chromium_major (the nearest one when none is older) with default_headers=False, so it adds no headers of its own. tests/test_curl_sender.py captures what goes on the wire and fails when the sample's Chrome is more than 4 majors past curl_cffi's newest profile (then make lock a 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 before load().
  • Hygiene: tool output never carries exception text; logs carry RPC ids, host, status, bytes and ms only. Cookies stay in memory: allowlisted Set-Cookie updates 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's log_level, which FastMCP and the tool-call log follow). An unexpected crash logs tool crashed: <type> at ERROR and answers internal_error.

Session snapshot

PHOTOS_SESSION_DIR (default /session, mounted read-only) holds:

  • cookies.txt, Netscape format. Host-only rows have include-subdomains FALSE and no leading dot (the CDN's own OSID on photos.fife.usercontent.google.com); domain rows have TRUE and a leading dot. Rows for domains other than google.com, photos.google.com and photos.fife.usercontent.google.com are 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. Each headers.<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-agent equal to user_agent, and only user-agent, accept, accept-language, content-type (rpc only), origin, x-same-domain, upgrade-insecure-requests, priority, sec-ch-ua* and sec-fetch-*. Other names are ignored, structural errors make the snapshot session_unavailable, and chromium_major must match the User-Agent. A kind without sec-fetch-site, sec-fetch-mode and sec-fetch-dest, or whose dest contradicts the kind (page document, rpc empty, image image), uses the synthesised template below for that kind only, logged once per generation without values; photos-mcp-session applies 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.json keeps those names). The order covers the headers the transport adds too: referer, accept-encoding and cookie go last, before priority, as Chrome sends them; an unknown name goes just before sec-fetch-site.
  • Schema 1 (no headers; what photos-mcp-session import writes) is read too: the templates are then synthesised from the User-Agent (its Chrome major in sec-ch-ua, its platform, Chrome's per-kind sec-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
GOOGLE_PHOTOS_EXPECTED_EMAIL – required; the (default) account served
MCP_BEARER_TOKEN – required for --transport http; several tokens comma-separated (the first is the proxy's, Accounts)
PHOTOS_SESSION_DIR /session the snapshot, read-only
PHOTOS_STATE_DIR /state state.json (budgets, breaker, dead generation, last error) and cache/
PHOTOS_DAILY_RPC_BUDGET 3000 RPCs plus app-page loads per day
PHOTOS_DAILY_THUMB_BUDGET 2000 image fetches per day (cache hits are free)
PHOTOS_DAILY_WRITE_BUDGET 500 album changes per account and day (items added or removed, albums created)
PHOTOS_BUDGET_TZ UTC IANA time zone whose midnight resets the budgets
PHOTOS_CACHE_MAX_BYTES 2 GiB thumbnail cache, LRU; 0 disables it
LOG_LEVEL INFO 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_TRUSTED_ACCOUNT_HEADER – the header the authenticating proxy names the account in (Accounts); unset: every request is the default account's
PHOTOS_DEFAULT_ACCOUNT default the default account's id (what the proxy's header says for it)
PHOTOS_ACCOUNT_SESSIONS_DIR /sessions the further accounts' snapshots, one directory per account (written by the login service), read-only
PHOTOS_PUBLIC_URL – where people reach this server (the parent of /mcp, e.g. https://example.com/photos); with the next two it turns on photos_connect
PHOTOS_LOGIN_URL – the login service on a private network, e.g. http://photos-mcp-login:8080 (host and port only)
PHOTOS_LOGIN_TOKEN – the login service's bearer (32+ characters), the same on both

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).

Accounts

One server can keep several people's libraries apart behind an authenticating reverse proxy that knows who is calling. Each account has its own snapshot, state (budgets, last error, the generation Google rejected), thumbnail cache, cursors and canary; Google's pacing and the breaker after pushback are shared, because Google sees one client.

  • The default account is the setup above: the snapshot in PHOTOS_SESSION_DIR, GOOGLE_PHOTOS_EXPECTED_EMAIL, its state right in PHOTOS_STATE_DIR. A one-account server needs nothing new. Its snapshot is the operator's to keep (with photos-mcp-session or anything else that writes the format); sign-in links are not offered for it.
  • Further accounts live in PHOTOS_STATE_DIR/accounts/<id>/ (ids are 1-32 lower-case letters, digits or dashes), with their snapshots in PHOTOS_ACCOUNT_SESSIONS_DIR/<id>/, which the login service writes after the person signed in through a link.
  • Budgets are split, not added. PHOTOS_DAILY_RPC_BUDGET and PHOTOS_DAILY_THUMB_BUDGET stay the server's daily totals. Each further account has its own share; the default account gets what is left. A change applies on the next call. If the totals are lowered below the shares, every share shrinks in proportion (the default account then gets nothing).
  • Which account a request is for comes from PHOTOS_TRUSTED_ACCOUNT_HEADER, read only on requests that carry the first token in MCP_BEARER_TOKEN: that one is the proxy's, so only the proxy may hold it, and the proxy must remove any copy of the header a client sends. With that token the header is required (a request the proxy did not name is refused, never served as the default account). Any other token acts for the default account, and a header with it is refused. A header naming an account that is not set up, or naming it twice, gets account_not_connected. Over stdio there is no header: always the default account.
  • An account serves one Google address. It is bound by the first sign-in through its link (or given with --email when the account is added) and never changes: a sign-in as another Google account, or as one another account here serves, is refused. Until it is bound, the account serves nothing (not_signed_in). Only that address's library is ever served, even if the profile also holds another Google sign-in.
  • Removing and re-adding an id gives a new account: nothing of the old one (state, cache, browser profile, snapshot) carries over; admin account add asks the login service to clear any leftover profile first.
docker exec photos-mcp photos-mcp admin account add second --rpc-budget 1500 --thumb-budget 1000
docker exec photos-mcp photos-mcp admin account budget second --rpc-budget 1000 --thumb-budget 800
docker exec photos-mcp photos-mcp admin account list      # addresses, shares, session and keeper state
docker exec photos-mcp photos-mcp admin account remove second   # also asks the login service to delete
                                                                # its browser profile and snapshot

With the login service and a public address, a person connects their own Google account without the operator: they ask their assistant to connect Google Photos, the assistant calls photos_connect (the not_signed_in error points it there) and gives them the link, and they sign in on Google's own page in their browser, phone or computer.

  • The login service (photos-mcp-login, image ghcr.io/benjamin-di/google-photos-mcp-login, built from Dockerfile.login) keeps one browser profile per account in PHOTOS_LOGIN_PROFILES and writes the snapshots to PHOTOS_LOGIN_SESSIONS (the server's PHOTOS_ACCOUNT_SESSIONS_DIR, mounted read-only there). Run it beside the server on a private network with the same PHOTOS_LOGIN_TOKEN; it needs outbound access to Google. One browser runs at a time.
  • The window. The page shows Google's sign-in page, running in that account's profile on a virtual display, through noVNC relayed by the server. Nothing on the sign-in is automated: the person does every step, Google's own checks included. Passkeys do not work in a remote window (they are turned off there): sign in with the password and the code or prompt Google asks for.
  • The window is locked down, because the login service keeps every further account's profile and snapshot: Chromium runs under a managed policy (src/photos_login/chromium-policy.json, installed by the image) that refuses file://, chrome://, view-source:, data: and DevTools pages, file dialogs, downloads, printing and saved passwords, and top-level pages are limited to Google's sign-in hosts. The service opens no window without the policy (PHOTOS_LOGIN_UNMANAGED_BROWSER=1 overrides that, for trying it out on a computer of your own only). The live view works only while the profile is shown signed out on Google's sign-in page: a profile that is still signed in is checked, and exported, with nobody watching, and an account notice of a signed-in profile ends the window instead of showing it. Ctrl+V (Cmd+V) types the clipboard's text into the window (src/photos_mcp/paste.js, a small add-on to noVNC's viewer; up to 1000 characters the window's keyboard has, which covers ASCII); on a phone, noVNC's keyboard button opens the phone's keyboard.
  • Once Google Photos shows a signed-in account, the window checks that this account may use it (its bound address, or any address no other account here serves), then writes the snapshot from the same browser, as photos-mcp-session browser export does (it loads the library and opens one photo, so the library must not be empty), and closes. The server then binds the account to that address (also when the person closed the page meanwhile). A refused sign-in wipes the profile.
  • The keeper opens each signed-in profile every hour (PHOTOS_LOGIN_REFRESH_SECONDS) and writes a fresh snapshot, so Google's rotating cookies stay current. Only the browser decides that a session ended, and only on evidence seen on two refreshes in a row: Google's sign-in page, its signed-out page or a request to prove it is them again. Then the snapshot is withdrawn, and the person's next call says not_signed_in (a new link). Another Google account in the profile withdraws it at once. Anything else (an account notice, a slow page) keeps the snapshot and tries again after 10 minutes. When Google rejects a generation, the server asks the keeper to look again soon (at most every 5 minutes per account). The keeper never signs in: it holds no password. keeper.json beside each snapshot says what it last saw (photos_status: keeper).
  • For the caller's account only. The link is made for the account the request is for; there is no way to ask for another account's link. A newer link replaces the older one; at most 6 are made per account an hour.
  • One sign-in, 20 minutes. <PHOTOS_PUBLIC_URL>/connect/<id> (192 random bits) works until a sign-in succeeds or 20 minutes pass; a reload or "try again" opens a new window, up to 4 times. The page names the account it connects and opens Google's sign-in only when the person continues (so a chat app's link preview starts nothing). Its pages are never cached and send no referrer. Requests for ids that do not exist are counted per address (the last X-Forwarded-For entry, which the proxy must add): after 20 in ten minutes that address gets 429 instead of 404 (a real link keeps working). Nothing under /connect shows or returns a session.
  • Your proxy decides who can reach it. /connect/... (the page, its calls, noVNC's files and the live view's WebSocket) is served without the bearer, because people open it in a browser; the link id is its credential. Route only <prefix>/connect/... to the server without authentication, never anything else, and do not add your bearer to it.

Login service settings: PHOTOS_LOGIN_TOKEN (required), PHOTOS_LOGIN_PROFILES (/profiles), PHOTOS_LOGIN_SESSIONS (/sessions), PHOTOS_LOGIN_MINUTES (20, a window's time), PHOTOS_LOGIN_REFRESH_SECONDS (3600), PORT (8080), PHOTOS_LOGIN_UNMANAGED_BROWSER (see above). Its API (behind the bearer) never returns a session; /health is open. As a compose file, beside the server:

services:
  photos-mcp:
    image: ghcr.io/benjamin-di/google-photos-mcp:latest
    read_only: true
    volumes:
      - ./session:/session:ro          # the default account's snapshot
      - ./state:/state
      - ./sessions:/sessions:ro        # the further accounts' snapshots
    environment:
      MCP_BEARER_TOKEN: ${PROXY_TOKEN}           # held by the authenticating proxy only
      GOOGLE_PHOTOS_EXPECTED_EMAIL: you@example.com
      PHOTOS_TRUSTED_ACCOUNT_HEADER: X-Account   # set by the proxy, never by clients
      PHOTOS_PUBLIC_URL: https://example.com/photos
      PHOTOS_LOGIN_URL: http://photos-mcp-login:8080
      PHOTOS_LOGIN_TOKEN: ${LOGIN_TOKEN}
    networks: [default, login]
  photos-mcp-login:
    image: ghcr.io/benjamin-di/google-photos-mcp-login:latest
    read_only: true
    shm_size: 512m
    tmpfs: [/tmp, /home/login]
    volumes:
      - ./profiles:/profiles           # each account's Google sign-in: keep it private
      - ./sessions:/sessions
    environment:
      PHOTOS_LOGIN_TOKEN: ${LOGIN_TOKEN}
    networks: [login]                  # plus a network that reaches Google
networks:
  login: {}

Operations

Symptom Action
session_expired / session_unavailable export a fresh snapshot (photos-mcp-session browser export); if the profile is signed out, browser login again. photos-mcp recovers by itself on the next generation
blocked wait out the breaker (photos_status); look for runaway use; lower PHOTOS_DAILY_RPC_BUDGET
schema_changed Google changed a response: open an issue with the tool and photos_status' last error (the fix is a parser update in rpc.py, checked against GPTK's latest parser.ts)
media_session_required repeating the snapshot lacks the image host's cookies: open a photo in the profile, export again
account_mismatch stop; the snapshot or GOOGLE_PHOTOS_EXPECTED_EMAIL names the wrong account; never auto-corrected
not_signed_in for a further account the person connects again with photos_connect; photos_status.keeper (or admin account list) says whether the keeper saw a signed-out profile or an error
account_not_connected the proxy named an account that is not set up (admin account add), or sent no header with its token

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). For a further account: admin account remove <id> (its profile and snapshot go too), or the person signs the server's browser out under Security, Your devices.

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, the account registry), 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), accounts.py (further accounts), connect.py (sign-in links), login_client.py (the login service's API), admin.py, 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). src/photos_login/ — the login service: app.py (API), windows.py (sign-in windows), window.py (the browser), keeper.py, profiles.py. docs/PAYLOADS.md maps every RPC and field path to its source. scripts/login_e2e.py checks sign-in links end to end with real browsers and a stand-in page (the release runs it in the login image).

Contributions: see CONTRIBUTING.md. 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.

Metadata

Release files for google-photos-mcp 0.6.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for google-photos-mcp 0.6.1
File Size Uploaded Provenance
google_photos_mcp-0.6.1.tar.gz 386.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for google-photos-mcp 0.6.1
File Interpreter ABI Platform
google_photos_mcp-0.6.1-py3-none-any.whl Python 3 none any Details

Total release size: 531.9 kB

Release files / google_photos_mcp-0.6.1.tar.gz

Download URL google_photos_mcp-0.6.1.tar.gz
Size 386.9 kB
Tags Source
SHA-256 checksum
How to use checksums
1265bc2d88d2bf0dd0986f15962f1bd4dc011481cf18b0146539e7db28fd73a3
BLAKE2b-256 checksum
How to use checksums
5842552f9f45eb24504b696ef3b7c3bc6617a4f39f6d1b5a66fd49788b7914a5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / google_photos_mcp-0.6.1-py3-none-any.whl

Download URL google_photos_mcp-0.6.1-py3-none-any.whl
Size 145.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
074ef3dd55300002185e564df74f3baeae08c8e268d361fddce56f86c41e09d1
BLAKE2b-256 checksum
How to use checksums
d046e9d20f855b5ff299d1327bf8ca8bd49b4da4fb2960e9c2d914dba5999011
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for google-photos-mcp 0.6.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page