Skip to main content
Glama

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, add photos and videos (from the person's phone or computer through a one-time upload page, from a link, or a file the assistant has; Uploading), delete them (to the trash, where Google keeps them 60 days, and back again) and organise albums (create one, add photos, take them out, delete it). The tools that take something away are marked destructive, so MCP clients ask the person before each call; nothing empties the trash, 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 an authenticated owner page (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
                                              │ hosted owner connections
                                    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. Before starting HTTP, prepare the private state root and its four deployment records using first-time HTTP setup, with scope reader. An empty directory alone is insufficient. Keep existing records when upgrading; do not rerun first-time setup on retained 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) and needs the same provisioned reader root at PHOTOS_STATE_DIR. Stdio does not require these HTTP deployment records.

  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.

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.

  • photos_download (when PHOTOS_PUBLIC_URL is configured) makes JPEG copies for transfer to another tool. Each URL is a credential for one download within 10 minutes. Give it directly to the importing tool; opening it first consumes it. Copies default to 2048 px (up to 4096), contain no original EXIF metadata, and are not originals. Videos are refused. Outstanding copies are held in bounded memory (64 MiB); restarting revokes all links. A reverse proxy must forward only GET/HEAD of /downloads/<43-character base64url token>, without a query or Authorization header. WhatsApp clients with URL import can call send_file(recipient=..., source_url=<returned url>); a plain Google Photos item URL opens a sign-in page and cannot substitute for a file download.

  • 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"), except to fetch a file from a public link a caller gives photos_upload, and its logs carry no photo ids, URLs or cookies.

  • A file being uploaded is held in an anonymous file under the state directory's uploads/ only until it is sent (nothing is left there, even after a crash).

Permissions

Human browser sign-in authenticates the owner who may manage a Google connection. MCP admission separately authenticates a client or workload and limits its audience, scopes and permitted tools. Photos then binds calls to the configured account and applies its own provider-operation allowlists, budgets and write rules. A successful Google sign-in grants no agent permission. Pocket ID is an example standard OIDC provider; another compatible provider can be configured. The private backend bearer and account header belong only to trusted application infrastructure.

See permission boundaries, revocation and remaining project checks for browser-session expiry, native default/further-account custody and recovery.

Tools

All are read-only (readOnlyHint) except photos_album_create, photos_album_add, photos_album_remove, photos_album_delete, photos_delete, photos_restore, photos_upload and photos_upload_link. Album removal/deletion and moving items to trash are destructive. photos_connect is a read-only local entry URL: it creates no sign-in attempt and changes no access. Upload links allocate bounded file-transfer capabilities; they are separate from browser identity and agent authorization. Gateway tool ceilings and client confirmation remain independent of these annotations. 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|trash, 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)

the album afterwards, removed (from its counts) and not_in_album (ids it does not hold, left alone)

photos_album_delete

album_id (one of the account's own albums)

the album and deleted (read back: the album no longer opens). Its photos stay in the library

photos_delete

ids ≤50 (from search or list)

trashed, not_found (not items of this library) and per id in_trash (read back from the trash)

photos_restore

ids ≤50 (as photos_list(source=trash) gives them)

restored, not_in_trash and per id restored (read back from the trash)

photos_upload

url (a public https link) or data (base64, ≤10 MiB), filename, album_id

the new item {id, url, type, filename, taken_at, tz_known, width, height, uploaded_at}, already_in_library, size_bytes, the album afterwards (added)

photos_upload_link

album_id

a one-time upload page for the caller's own account: {url, expires_at, files_max, file_max_bytes, album, put_example, next_step} (Uploading)

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_download

ids ≤10, max_px 256–4096 (2048); requires PHOTOS_PUBLIC_URL

one-time JPEG copy links, expiry, dimensions and size; no originals or video downloads

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

–

the authenticated owner's connections entry: {url, expires_at: null, 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. Inline images are at most 1568 px; photos_download exports JPEG copies up to 4096 px for file transfer. Original files are not available. 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), and trash (most recently deleted first; what photos_restore can bring back). 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.

Uploading

A photo or video reaches the library the way photos.google.com's own uploader sends it (docs/PAYLOADS.md): photos-mcp first asks whether the library already holds a file with the same SHA-1 (swbisb) and then sends nothing, else it sends the bytes to Google's resumable upload endpoint in 16 MiB chunks and makes the item from the upload token (mdpdU). Only photo and video formats Google Photos keeps are taken, told by their first bytes (JPEG, PNG, GIF, WebP, HEIC, AVIF, BMP, TIFF and the raw formats built on it; MP4, MOV, 3GP, AVI, MKV, WebM, WMV, MPEG), anything else is refused before it is stored. Each file is at most PHOTOS_UPLOAD_MAX_BYTES (2 GiB) and costs one unit of the daily write budget (two into an album) and three of the RPC budget whatever its size (the hash check, the upload endpoint, making the item); the item keeps the file's own date (EXIF), else its last-modified time. When the album step fails after the item was made, the answer still has the item, and its album carries that error. At most 2 files are received or sent at once on a server, by links and photos_upload alike: another one waits for a slot (up to 5 minutes on a link, 1 minute for the tool) before its bytes are read.

  • The person's own files: photos_upload_link. With PHOTOS_PUBLIC_URL set, the tool gives the caller a link, PHOTOS_PUBLIC_URL/uploads/<id>, to a page where they pick photos and videos on their phone or computer; each goes straight into their own library (and into the album the link names, checked when it is made) and the page says what became of it. The same address takes one file per PUT from a client that has it on disk (an image an assistant made in a code sandbox, say): curl -T <file> -H 'X-File-Name: <name>' <url>, which answers with the new item. A link works for 60 minutes and 100 files, adds only to the account that asked for it (not one removed and added again since), and needs no bearer: its 256-bit id is the credential, so the reverse proxy must pass /uploads/<id> (GET and PUT, no query) through without one, as for sign-in links. A body that sends nothing for a minute is dropped. Links live in memory: a restart ends them.

  • A link: photos_upload(url=...). The server fetches the file itself, guarded so a link can never reach into the network the server runs in: https on port 443 only; every address the host resolves to must be public, and the request goes to that address only (curl's resolve pin, no proxy, not even one from the environment); redirects, at most 3, are checked the same way; no cookies or credentials are sent, and the whole fetch, headers included, must finish within 10 minutes.

  • Bytes the assistant has: photos_upload(data=...), base64 (standard or URL-safe, a data: URL prefix allowed), up to 10 MiB.

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

connection_disconnected

the account is disconnected or deletion needs confirmation

open My connections and reconnect or retry disconnect

connect_unavailable

a connection request has no configured account

configure the account before connecting; the default uses the same owner page

connect_limited

more than 6 links an hour for one account

give the newest link

upload_limited

more than 30 upload links an hour for one account

give the newest link

fetch_failed

photos_upload(url=...): the link did not answer, answered with an error, or took too long

nothing added

outcome_unknown

a change (album change, upload, trash) failed after it was sent (5xx or network): it is never repeated

read the album, photos_albums, photos_list(source=recently_added) or photos_list(source=trash) to see what happened

refused

Google refused a change (an album this account cannot edit, an upload it would not take, items it would not move)

nothing changed

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, uploads and the trash only. Reads go through GoogleTransport.rpc, which refuses any RPC id outside lcxiM EzkLib EWgK9e Z5xsfc snAcKc VrseUb fDcn4b EzwWhf swbisb zy0IHe (swbisb: does the library hold a file with this hash; zy0IHe: the trash). Changes go through GoogleTransport.write, which refuses any outside OXvT9d E1Cajb laUYf ycV3Nd LjmOue nV6Qv mdpdU XwAOJf (create an album; add items to an album or a shared one; remove items from an album or a shared one; delete an album; make an item from an upload; move items to the trash or back) and never retries a request that may have reached Google (outcome_unknown, logged). Nothing deletes for good: no builder empties the trash or deletes from it, and XwAOJf is built only into the trash and back. An upload's bytes go through GoogleTransport.upload_bytes to Google's upload endpoint only (/_/upload/uploadmedia/interactive, and the session address it answers with, which must be the same endpoint); nothing is in the library until mdpdU, so a failure there changes nothing. No builder exists for emptying the trash, 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 and deleting are the client's to confirm. photos_album_remove, photos_album_delete and photos_delete are annotated destructiveHint, and the instructions tell the model to ask the person first and show what goes; configure your MCP clients to ask before each call of these three (their approval setting per tool). Each acts in one call. photos_album_remove touches only the ids the album's own listing holds (Google's remove RPC names no album, so a key from another album would act there); photos_delete only the items of this library, by the dedup keys the trash RPC takes; photos_album_delete only the account's own albums (a shared one is left to the app, since deleting it changes it for everyone in it). Every change is read back (added / removed from the album's counts, in_trash / restored from the trash, deleted from the album no longer opening), counts against PHOTOS_DAILY_WRITE_BUDGET (each account's own, 500 a day; one per item, upload or album) 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. The one exception is photos_upload(url=...), which fetches from the public host a caller names through photos_mcp.fetch (Uploading), never with Google's cookies.

  • 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; uploads up to 2 files at a time in slots of their own (a long upload never holds the RPC slot); 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

changes per account and day (items added, removed, trashed or restored, albums created or deleted, files uploaded)

PHOTOS_UPLOAD_MAX_BYTES

2 GiB

the largest file photos_upload and upload links take (1 MiB to 10 GiB)

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 are plain text; HTTP access logs are disabled to keep browser grants out of logs

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); turns on upload links (photos_upload_link), and with the next two photos_connect

PHOTOS_LOGIN_URL

–

the login service on a private network, e.g. http://photos-mcp-login:8080 (host and port only)

PHOTOS_DEFAULT_LOGIN_URL

–

required private native-default custodian when hosted connections are configured, e.g. http://photos-default-login:8081

PHOTOS_LOGIN_TOKEN

–

the login service's bearer (32+ characters), the same on both

PHOTOS_CONNECTIONS_ISSUER

–

required fixed HTTPS OIDC issuer when sign-in links are configured

PHOTOS_CONNECTIONS_PUBLIC_URL

PHOTOS_PUBLIC_URL

browser entry origin; upload/download links retain PHOTOS_PUBLIC_URL

PHOTOS_CONNECTIONS_CLIENT_ID

–

public OIDC client with an exact <PHOTOS_CONNECTIONS_PUBLIC_URL>/connections/callback and S256 PKCE

PHOTOS_CONNECTIONS_GROUP

–

required group in the verified ID token

PHOTOS_CONNECTIONS_BINDINGS_FILE

–

private JSON object mapping immutable OIDC subjects to existing Photos account ids; choose this or the next setting

PHOTOS_CONNECTIONS_ACCOUNT_CLAIM

–

administrator-controlled signed ID-token claim containing an existing Photos account id; never an editable profile field

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

Hosted account management uses POSIX process locks to serialize account adoption and control changes. Single-account local stdio remains independent of hosted OIDC.

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 keeps that layout. With hosted connections, its owner connects and reconnects through the same authenticated page as other accounts. Its native profile stays with its existing custodian (setup and recovery).

  • 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 through My connections, at <PHOTOS_CONNECTIONS_PUBLIC_URL>/connections/. They sign in to their identity provider in their own browser, then connect, reconnect or disconnect their own Google Photos account. Pocket ID works as a standard OIDC provider; another provider with the same signed claims can use the same interface.

photos_connect returns the permanent My connections URL with expires_at: null. Requesting it never starts, replaces or pauses a connection; only the signed-in owner can start a sign-in. Browser sign-in is mandatory whenever sign-in links are configured: missing OIDC configuration refuses startup, and a forwarded link cannot select another person's account. Existing snapshots and their formats stay valid. See Connections setup and recovery.

  • The login service (photos-mcp-login, image ghcr.io/benjamin-di/google-photos-mcp-login, built from Dockerfile.login) keeps each further account's browser profile 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 default account uses its existing maintenance custodian, profile and lease, exposed privately through PHOTOS_DEFAULT_LOGIN_URL; Connections setup describes its fixed service contract.

  • 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: if Google offers one, choose another way, and 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 in kiosk mode (no address bar, tabs or menus) under signin-window's managed policy (installed by the image) that refuses file://, chrome://, view-source:, data: and DevTools pages, file dialogs, downloads, printing and offers to save the password, 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 (a small add-on to noVNC's viewer from signin-window, the library the sign-in window and its links are built on; 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 further-account sign-in removes its login profile. A refused default attempt discards only its private candidate and preserves accepted native state.

  • The further-account 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 tool returns the owner's permanent entry URL; there is no way to select another account. Only an explicit authenticated owner start creates/replaces an attempt, with at most 6 such starts per account an hour.

  • One sign-in, 20 minutes. <PHOTOS_PUBLIC_URL>/connect/<id> identifies an attempt, with up to four explicit starts. Visiting a link opens no provider browser. Reloading or closing the page leaves an authenticated attempt available to resume; Cancel closes a waiting window. Saving continues independently of the page. A newer link replaces the previous one, and the connections screen checks the saved snapshot after a restart.

  • Owner checks cover every route, including state, cancellation, noVNC assets and the ongoing WebSocket. Sessions are opaque server-side references, expire within 15 minutes and retain no provider OAuth tokens. Wrong users and expired sessions receive no account details; signing out also closes their existing viewer. POST and WebSocket requests require the exact public Origin. Pages are not cached and capability URLs are not sent as referrers.

  • Your proxy routes only the UI exceptions. Forward /connections/, its exact login, callback, status, start, disconnect and logout children, and the existing /connect/<id> shapes without injecting a backend bearer. Keep /mcp behind the existing MCP admission policy. The application validates its own browser identity; proxy identity headers cannot create a browser session. Preserve Cookie, Set-Cookie, Origin and WebSocket upgrades and the configured public path prefix.

  • Disconnect blocks application access first, fences old attempts, then drains work. Further-account login profiles/snapshots are deleted; the default's native profile and snapshot stay behind the durable barrier. Uncertain cleanup remains blocked across a restart and offers Retry disconnect. Reconnecting requires a fresh explicit start and the same bound Google address. Logging out leaves the saved provider connection alone.

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. Before starting the Compose example, prepare ./state (scope reader), ./profiles (scope login) and ./maintenance (scope maintenance) using deployment setup. Each root must be owned by its container's application UID, have mode 0700 and contain the four 0600 deployment records. Set the container user or directory ownership to match; do not let Docker create these bind-mount roots as root-owned directories. Preserve existing records during upgrades, including holds and work tickets.

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_DEFAULT_LOGIN_URL: http://photos-default-login:8081
      PHOTOS_LOGIN_TOKEN: ${LOGIN_TOKEN}
      PHOTOS_CONNECTIONS_ISSUER: https://identity.example.com
      PHOTOS_CONNECTIONS_CLIENT_ID: photos-ui
      PHOTOS_CONNECTIONS_GROUP: photos-users
      PHOTOS_CONNECTIONS_ACCOUNT_CLAIM: account_alias
    networks: [default, login]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
  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
  photos-default-login:
    image: ghcr.io/benjamin-di/google-photos-mcp-login:latest
    command: ["python", "-m", "photos_maintenance.human"]
    read_only: true
    shm_size: 512m
    tmpfs: [/tmp, /home/login]
    volumes:
      - ./maintenance:/maintenance     # the native profile and its existing lease
      - ./session:/export              # the same default snapshot read by the server
      - ./state/connections:/connections-controls
    environment:
      PHOTOS_LOGIN_TOKEN: ${LOGIN_TOKEN}
      PHOTOS_DEFAULT_LOGIN_ACCOUNT: default
      PHOTOS_DEFAULT_LOGIN_ROOT: /maintenance
      PHOTOS_DEFAULT_LOGIN_EXPORT_DIR: /export
      PHOTOS_DEFAULT_LOGIN_CONTROLS_DIR: /connections-controls
      PHOTOS_DEFAULT_LOGIN_EXPECTED_EMAIL: you@example.com
    depends_on:
      photos-mcp:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"]
    networks: [login]                  # plus a network that reaches Google
networks:
  login: {}

The example default custodian runs the human service alone. If native credential maintenance already owns the default profile, run both processes in that existing custodian's container and PID namespace, under one supervisor; retain its absolute paths, locks, holds and attempt ledger. Never copy a running browser profile. The reader must initialize its private controls before the custodian starts.

Operations

Symptom

Action

session_expired / session_unavailable

hosted owners use Reconnect in My connections, including for the default account. Local operators can export a fresh snapshot (photos-mcp-session browser export), or use browser login if signed out. The next verified generation replaces the rejected one

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

Use My connections' Disconnect to block new account operations immediately and retain that barrier across restart. The default's accepted profile/snapshot remain private; further-account login state is removed. For provider-wide revocation, use Google's account security settings separately. Privileged local operators can stop the server or remove a further account with admin account remove <id>; preserve current durable controls through recovery. These actions do not revoke an agent's gateway grant.

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, on signin-window's relay), login_client.py (the login service's API: signin-window's client plus the account routes), 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, on signin-window's: app.py (API), windows.py (the sign-in window's runner), 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.

Optional operator-owned account upkeep is documented in Account maintenance.

Available Tools

17 tools
photos_album_addPhotos Album AddA
Idempotent

Add up to 50 items (ids from search or list) to an album, also a shared one this account may add to. Items already in it stay once. Returns the album as Google shows it afterwards and added (from its counts before and after).

Inputs: ids (required), album_id (required). Returns: object

photos_album_createPhotos Album CreateA

Create an album, with up to 50 items in it. Returns the album {id, url, title, count, shared} and how many items it holds. Do not repeat a call that failed with outcome_unknown: photos_albums shows whether the album exists.

Inputs: title (required), ids. Returns: object

photos_album_deletePhotos Album DeleteA
Destructive

Delete one of the person's own albums; its photos stay in the library, but the album cannot be brought back. Ask the person first, naming the album and how many photos it holds. Returns the album and deleted (read back: the album no longer opens). Shared albums are deleted or left in the app.

Inputs: album_id (required). Returns: object

photos_album_removePhotos Album RemoveA
Destructive

Take up to 50 items out of an album; they stay in the library. Only ids the album's own listing holds are removed (not_in_album: the others). Ask the person first, showing which photos leave the album. Returns the album afterwards and removed.

Inputs: ids (required), album_id (required). Returns: object

photos_albumsPhotos AlbumsA
Read-onlyIdempotent

List albums: {id, title, count, shared}. Use an id with photos_list(source="album").

Inputs: limit, cursor. Returns: object

photos_canaryPhotos CanaryA
Read-onlyIdempotent

Monitoring: one real check of the path agents use (a library page of 1 item and its thumbnail); the verdict is cached 55 min (transient failures: 5 min) and calls in between return it (cached: true). {ok, code, checked_at, cached, snapshot_age_s, generation}; ok is false on any error code, or with code snapshot_stale when the snapshot is older than 3 h or dated in the future. Never fails.

Inputs: no named parameters. Returns: object

photos_contact_sheetPhotos Contact SheetA
Read-onlyIdempotent

One JPEG grid (at most 1568 px) of up to 24 items with numbered tiles and a play badge on videos, plus a legend mapping each number to its id and date.

Inputs: ids (required).

photos_deletePhotos DeleteA
Destructive

Move up to 50 photos or videos to the trash, where Google keeps them 60 days (photos_restore brings them back). Ask the person first, showing them the photos (photos_show). Returns trashed, not_found (ids that are not items of this library) and, per id, in_trash (read back from the trash).

Inputs: ids (required). Returns: object

photos_getPhotos GetA
Read-onlyIdempotent

Details per item, all ids in one Google request: the item shape of photos_search plus uploaded_at (UTC), lat/lon, camera, width/height (original), size_bytes, duration_s and albums. Failed items carry their own error.

Inputs: ids (required). Returns: object

photos_listPhotos ListA
Read-onlyIdempotent

List the library newest-first by taken date (archive excluded; from_date/to_date for an exact range of the items' own local dates), the archive, one album, the favourites, or what was uploaded most recently. Same item shape as photos_search; place is set where Google's listing carries it (details=true adds it, with lat, lon and camera).

Inputs: limit, media, cursor, source, +4 more. Returns: object

photos_on_this_dayPhotos On This DayA
Read-onlyIdempotent

Photos and videos taken on this calendar day in earlier years ("on this day"), newest year first: {date, years: [{year, items, more}], years_checked}. more: that day holds more than shown (photos_list with from_date=to_date=that day lists them all). One Google request per year, so keep years modest.

Inputs: date, years, per_year. Returns: object

photos_restorePhotos RestoreA
Idempotent

Bring up to 50 photos or videos back from the trash into the library. Returns restored, not_in_trash and, per id, restored (read back from the trash).

Inputs: ids (required). Returns: object

photos_showShow photosA
Read-onlyIdempotent

Show photos to the person as a gallery: thumbnail, date, your note and a link that opens each one in Google Photos (claude.ai and ChatGPT draw it; other clients get the same data as JSON). Use it whenever you present photos you picked or found.

Inputs: items (required), title.

photos_statusPhotos StatusA
Read-onlyIdempotent

Session account and generation, snapshot age, breaker, daily budgets left, cache size and the last error. Never calls Google.

Inputs: no named parameters. Returns: object

photos_uploadPhotos UploadA
Idempotent

Add one photo or video to the library (and to an album): the file at a public https link (url), or one you have as base64 (data). A file the library already holds is not added again (already_in_library). Returns the item {id, url, type, filename, taken_at, tz_known, width, height, uploaded_at} and the album afterwards. For the person's own files, or a file on disk, use photos_upload_link.

Inputs: url, data, album_id, filename. Returns: object

photos_viewPhotos ViewA
Read-onlyIdempotent

Look at up to 4 items: per id a label (n, id, taken_at, type, width/height of the returned JPEG) followed by the JPEG, at most max_px (≤1568) on the longest edge; no originals. A video gives its poster frame (video_poster: true).

Inputs: ids (required), max_px.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 17 tools

    Disambiguation4/5

    Most tools target clearly distinct resources or actions, especially the album CRUD, trash/restore, upload, and search/list/get families. Minor overlap exists among the visual presentation tools (photos_contact_sheet, photos_view, photos_show) and between photos_status and photos_canary, though descriptions mostly disambiguate them.

    Naming Consistency4/5

    All tool names use the photos_ prefix and snake_case, which makes the set predictable and readable. The pattern is mostly action-oriented but includes noun-first resource forms like photos_album_add and noun tools like photos_status, so it is not a pure verb_noun convention throughout.

    Tool Count4/5

    Seventeen tools is slightly above the ideal lightweight range, but the Google Photos domain is broad enough to justify search, listing, albums, trash, upload, display, and health checks. Each tool appears to earn its place rather than being redundant filler.

    Completeness4/5

    The surface covers core photo lifecycle operations: search/list/get, album create/add/remove/delete/list, trash/restore, upload, presentation, and monitoring. Minor gaps remain, such as not obviously listing trashed items and no album rename/update operation, but agents can work around these for most workflows.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables 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.
      1
      AGPL 3.0
    • A
      license
      A
      quality
      B
      maintenance
      Enables 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.
      29
      29 npm
      1
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      Enables read-only access to Google Search Console data, including property discovery, search analytics queries, and URL inspection, through a security-focused interface.
      3
      2,172 PyPI
      1
      MIT
    • F
      license
      Not graded
      quality
      C
      maintenance
      Enables read-only access to a personal Google Photos library, allowing listing, searching, inspecting, and viewing photos and albums via MCP tools.
      -