diff --git a/docs/immich-mcp/tickets.md b/docs/immich-mcp/tickets.md new file mode 100644 index 0000000..ad196d0 --- /dev/null +++ b/docs/immich-mcp/tickets.md @@ -0,0 +1,789 @@ +# Immich MCP: tickets + +Source of truth: [`design.md`](design.md). Section references like "§7.2" +point there. When a ticket and the design disagree, the design wins: stop +and ask the conductor rather than guessing. + +Line numbers are from `main` at `bae2e75` (2026-10-13). They will drift, so +search for the quoted code when they don't match. + +--- + +## 1. How this work is run (Herdr rules) + +All of these tickets are built with the **Herdr fan-out workflow** (`/herdr-fanout`). +The roles do not change from ticket to ticket. + +### Roles + +- **Conductor pane** (the main Claude session, normally Opus). It plans, hands + out work, reviews, and ships. It does **not** write the feature code for + worker tickets. The exceptions are tickets marked *Owner: conductor*, and a + second-round review that still finds a real bug, which the conductor fixes directly. +- **Worker panes** (sub panes, one per ticket, smaller model: + `claude --model sonnet`). Each one implements exactly one ticket in its own git + worktree and then reports back. Workers never ship. + +### Conductor duties, in order + +1. **Preconditions.** `test "$HERDR_ENV" = 1`; read `CLAUDE.md`; `git fetch`; note + `origin/main`. Pick each ticket's **base branch**: `main` once its + dependencies are merged. A ticket is never stacked on an unmerged branch + unless this file says so. +2. **Plan file per ticket.** Copy the ticket section below into + `/plans/.md`, add the current line anchors, and paste + the **worker ground rules** (below) at the bottom. +3. **Worktree + pane per ticket.** + ```bash + git worktree add -b /wt- origin/main + ln -s ~/Programs/raggr/.venv /wt-/.venv + ln -s ~/Programs/raggr/raggr-frontend/node_modules /wt-/raggr-frontend/node_modules # only IMM-05 + herdr pane run "claude --model sonnet \"Implement the plan in . Read it fully first. Your worktree is \$PWD.\"" + ``` + Open one tab per wave, split into one pane per ticket (`--no-focus`, `--cwd` + set to the worktree). Keep the ticket→pane mapping in `/panes.md`. +4. **Watch.** Run a Monitor loop over `herdr agent get ` that reports when a + pane becomes `idle|done|blocked`. Review each pane as soon as it finishes; + don't wait for the whole wave. +5. **Review twice per ticket.** + - Read the worker's final report from its transcript (newest + `~/.claude/projects//*.jsonl`). Pane scrollback is too short to rely on. + - Read the diff yourself: `git -C diff origin/main`, plus new files. Check it + against the ticket's acceptance criteria and the design. Look at correctness, + edge cases, security (the write guards in §8), anything changed silently, + scope creep, and whether each test **fails without the change** (spot-check by + reverting the fix). + - Run the suite yourself in the worktree; a worker saying "tests pass" is not enough. + - Send feedback as a numbered list with `file:line` and the fix you expect: + `herdr pane run ""`. Confirm the pane flips to `working`. + - **Round two:** confirm every round-one item was actually fixed. If round two + still finds a real bug, the conductor fixes it directly. There is no round three. +6. **Ship, one PR per ticket.** In the worktree: re-run tests and lint; `git fetch`; + rebase onto `origin/main` if it moved and re-test. Then **confirm nothing main gained + was reverted** (for files main changed since your base, diff and check main's + changes are still there). Commit with a message that explains *why*, plus the + session's attribution lines. Then `git push -u origin ` and + `tea pr create -t "" -d "<body>"`. The body covers the problem, the change, + what review caught, and test numbers. Merge in ticket order when the user has + asked for merging (`tea pr merge --style merge <n>`, after CI is green). If a + merge is blocked, list the PRs and their order for the user and don't work around it. +7. **Clean up.** Exit the workers, `herdr pane close` / `herdr tab close`, + `git worktree remove` each worktree, and delete merged local branches. +8. **Report.** Give a table of ticket → PR → state, what each review round + caught, and anything left undone. + +### Ownership of this file (sheepdog rule) + +Several panes run at once, and each opens its own PR, so this file would be a +merge-conflict magnet if everyone edited it. One pane owns it: + +- **The conductor is the sheepdog, and the only writer of `tickets.md`.** + Workers never edit it, and ticket PRs never touch it. A worker's diff that touches + this file is sent back in review. +- The conductor records status **once per wave**, after that wave's PRs merge. + It makes one small commit on `docs/immich-mcp-tickets-wave-<n>` (tick checkboxes, + add the PR numbers to the order table, file follow-ups as IMM-09+) and merges that + PR before starting the next wave. No two branches edit the file at the same time, + so there's nothing to race on. +- Workers who find something ticket-worthy (a bug outside their scope, a design gap) + put it under "open doubts" in their report. The conductor decides whether it becomes a ticket. +- Live notes during a wave (pane mapping, review state) go in `<scratchpad>/panes.md`, + not here. + +### Worker ground rules (paste into every plan) + +- Work **only** inside your worktree (your cwd). Don't touch other worktrees or + `~/Programs/raggr`. No `git stash`, commits, pushes or PRs; the conductor ships. +- Read `CLAUDE.md` and `docs/immich-mcp/design.md` first. Match the surrounding + style: comment density, naming, and async everywhere. +- Change only the files your ticket lists. If you think another file needs to + change, say so in your report instead of editing it. +- **Never edit `docs/immich-mcp/tickets.md`.** The conductor owns it (sheepdog rule). +- Run `.venv/bin/pytest tests/unit -q` and `uvx ruff==0.8.2 check .`; both must + pass. For IMM-05 also run `cd raggr-frontend && yarn tsc --noEmit`. +- Never hand-write DB migrations (none of these tickets need one). If you think + one is needed, stop and report. +- New env vars go in **three places**: `.env.example`, the `x-raggr-common` + environment block in `docker-compose.yml`, and the conductor's note to update `.env`. +- Don't call a real Immich server from tests. Use `httpx.MockTransport` or stubs. +- When you're done, reply with: files changed, what you did, the test and lint output + (counts), and any open doubts. Feedback will arrive in this pane. Address it and + reply in the same format. + +### Repo conventions the conductor enforces + +- Gitea with `tea` (not `gh`). PR flow: push, then `tea pr create`. "Kermit" means commit. +- One logical change per branch/PR. Branch names follow the tickets below. +- CI runs `ruff check .`, `pytest tests/unit`, `python -m scripts.evaluate_context`, + and the frontend typecheck and build. All must be green before merging. +- Never commit `.DS_Store`, `chroma_db/`, `chromadb/`, `.planning/`. + +--- + +## 2. Ticket order + +``` + IMM-01 (conductor: make keys, check Maine/partner facts) + │ (must finish before IMM-04 merges) + ▼ + ┌───────── Wave 1 ─────────┐ + │ IMM-02 server foundation │──┐ + │ IMM-03 summarize (pure) │──┤ + └──────────────────────────┘ │ + ▼ ▼ + ┌───────── Wave 2 ─────────────────────┐ + │ IMM-04 read tools (needs 02 + 03) │ + │ IMM-05 raggr wiring (needs 02) │ + └──────────────────────────────────────┘ + ▼ + Wave 3: IMM-06 write tools (needs 04) + ▼ + Wave 4: IMM-07 docs + Claude Code (needs 05 + 06) + ▼ + Wave 5: IMM-08 live verification + rollout (conductor; needs everything deployed) +``` + +| Order | Ticket | Owner | Depends on | Runs alongside | Merge order | +|---|---|---|---|---|---| +| 0 | IMM-01 Create scoped API keys, check Maine/partner facts (version already known: 3.3.1) | conductor | – | Wave 1 | (no PR; findings go in the wave-1 status commit) | +| 1 | IMM-02 Server foundation: config, client, criteria, package skeleton | worker | – | IMM-03 | 1st | +| 1 | IMM-03 Trip summaries: clustering, places, samples (pure functions) | worker | – | IMM-02 | 2nd (rebase on IMM-02) | +| 2 | IMM-04 Phase 1 read tools in the MCP server | worker | 02, 03 | IMM-05 | 3rd | +| 2 | IMM-05 Wire the server into raggr (admin agent, routing, UI, config) | worker | 02 | IMM-04 | 4th | +| 3 | IMM-06 Phase 2 write tools behind `IMMICH_ENABLE_WRITES` | worker | 04 | – | 5th | +| 4 | IMM-07 Usage docs, Claude Code registration, design follow-ups | worker | 05, 06 | – | 6th | +| 5 | IMM-08 Live verification and rollout | conductor | all merged + deployed | – | – | + +Why this order: + +- **IMM-02 and IMM-03 don't share files.** IMM-03 works on plain dicts and never + imports the client. IMM-03 must **not** create `immich_mcp/__init__.py`; IMM-02 owns + it. Until then the package imports fine as a namespace package. Merge IMM-02 first, + then rebase IMM-03. +- **IMM-04 and IMM-05 don't share files.** IMM-04 only touches `immich_mcp/server.py` and + its test. IMM-05 only touches raggr. Tool names are fixed by the design (§7), so + IMM-05 can allowlist them before they exist. Its tests use stubs, as + `test_homebox_mcp.py` does. +- **IMM-06 waits for IMM-04** because both edit `immich_mcp/server.py`. +- **IMM-07 comes last among the code tickets** so the docs describe what was merged, + not what was planned. +- **Phase 1 can deploy after IMM-05** with `IMMICH_ENABLE_WRITES` unset. Scenarios A, + D, and the read halves of B and C work from then on. + +--- + +## 3. Tickets + +### [ ] IMM-01: Verify production Immich and create scoped API keys + +- **Owner:** conductor (needs the Immich UI and an API key; no worker, no PR) +- **Depends on:** nothing · **Blocks:** IMM-04 merge, IMM-08 +- **Why:** The API changed shape in v3.2.0 (§11), and the design has an open + question (§13.2) that only the real server can answer. + +**Already known (checked 2026-10-13):** + +- `GET https://immich.ryanjchen.com/api/server/version` → + `{"major":3,"minor":3,"patch":1}`. **Immich 3.3.1**, so the `filter` API style + (nested `filter` plus `cursor` paging) is available and is the **default**. The legacy + flat fields still work on 3.3.x, so keep both code paths and their golden tests. + Production doesn't need `IMMICH_API_STYLE` set. +- **Public URL:** `IMMICH_PUBLIC_URL=https://immich.ryanjchen.com`. Links in tool + output use this. `IMMICH_URL` can stay on the LAN/compose address + (`http://192.168.1.5:2283`) for the server's own traffic. If that address isn't + reachable from the raggr container, use the public URL for both. + +**Steps** + +1. ~~Get the server version~~: done above (3.3.1). Re-run + `curl -s https://immich.ryanjchen.com/api/server/version` right before IMM-08 + in case Immich was upgraded in between; a 4.x major means re-checking §11. +2. In Immich → Account Settings → API Keys, create two keys for **Ryan's user**: + - `simba-immich-read` with exactly `album.read`, `asset.read`, + `asset.statistics`, `person.read`. + - `simba-immich-write`: the same four plus `album.create` and `albumAsset.create`. + Do **not** grant `all`, any `*.delete`, `album.update`, `album.share`, + `asset.update` or `asset.share`. + Store both in the password manager. Production `.env` starts with the **read** + key (Phase 1). +3. Using the read key, check the facts the design depends on (curl or httpie): + - `GET /api/search/suggestions?type=state` contains `"Maine"` (confirms the + reverse-geocoding spelling). + - `POST /api/search/statistics` with `{"filter":{"state":{"eq":"Maine"}}}` + returns a total, and the same total as legacy `{"state":"Maine"}`, which proves + both paths agree on 3.3.1. + - `GET /api/albums` returns albums including the existing Maine album. Record + its name, `assetCount`, `startDate` and `endDate`; IMM-08 uses them. + - **Partner photos (§13.2):** if a partner shares with Ryan with "show in + timeline" on, check whether partner assets come back from + `POST /api/search/metadata`. Write the answer down. Adding them to an album + gets tested in IMM-08 with the write key. +4. Check which `IMMICH_URL` the raggr container can reach: + `docker compose exec raggr-<color> curl -s $IMMICH_URL/api/server/ping`. +5. Put the results (partner answer, Maine album facts, chosen `IMMICH_URL`) in + `<scratchpad>/imm-01-findings.md`, paste them into the IMM-04 and IMM-07 + plan files, and post them to the user. The conductor records them in the + wave-1 status commit (sheepdog rule). + +**Done when:** both keys exist with exactly the listed permissions; the Maine facts +and the partner answer are recorded; the container-reachable `IMMICH_URL` is known. + +--- + +### [ ] IMM-02: Server foundation (config, client, criteria, package skeleton) + +- **Owner:** worker · **Branch:** `feat/immich-mcp-foundation` · **Base:** `origin/main` +- **Depends on:** nothing · **Runs alongside:** IMM-03 · **Blocks:** IMM-03 merge, IMM-04, IMM-05 +- **PR title:** "Add the Immich MCP server package: config, API client and search criteria" +- **Design:** §5, §6, §7.1, §8.5, §10, §11 + +**Goal.** Build everything the tools need except the tools themselves. When this +ticket is done, `python -m immich_mcp` starts a FastMCP stdio server called +`immich` with **no tools yet**, and the HTTP client and criteria model are fully +tested. + +**Files to create** + +| File | Contents | +|---|---| +| `immich_mcp/__init__.py` | Module docstring only: what the package is, and that it runs as `python -m immich_mcp` over stdio. | +| `immich_mcp/__main__.py` | `from immich_mcp.server import main` then `main()`. | +| `immich_mcp/config.py` | Frozen `ImmichConfig` dataclass and `load_config(env=os.environ)`. | +| `immich_mcp/client.py` | `ImmichClient` and `ImmichError`. | +| `immich_mcp/criteria.py` | `PhotoCriteria`, `CriteriaError`, `local_day_bounds`, `to_search_body`, `to_statistics_body`, `resolve_place`. | +| `immich_mcp/server.py` | `mcp = FastMCP("immich")`, `main()` that calls `mcp.run()` (stdio). No tools. | +| `tests/unit/test_immich_config.py` | Config tests. | +| `tests/unit/test_immich_client.py` | Client tests. | +| `tests/unit/test_immich_criteria.py` | Criteria and body tests. | + +**Files to modify** + +- `Dockerfile`: add `COPY immich_mcp ./immich_mcp` after `COPY utils ./utils` + (line 53). Without it the module is missing from the image; the Dockerfile copies + directories explicitly. + +**Do not change:** anything under `utils/`, `blueprints/` or `app.py`, or any existing test. + +**Details** + +1. **`config.py`** + - Fields: `url` (from `IMMICH_URL`, trailing `/` stripped), `api_key` + (`IMMICH_API_KEY`), `public_url` (`IMMICH_PUBLIC_URL`, default `url`), + `timezone` (`ZoneInfo(IMMICH_TIMEZONE or TZ or "America/New_York")`), + `writes_enabled` (`IMMICH_ENABLE_WRITES == "true"`), and `api_style` + (`IMMICH_API_STYLE` in `{"legacy", "filter"}`, or `None` meaning auto-detect). + - Missing `url` or `api_key` raises `ValueError` with a message that names the + variable but **never prints the key**. + - Constants live here: `MAX_RESULT_CHARS = 2500`, `MAX_SCAN = 5000`, + `MAX_WRITE = 2000`, `PAGE_SIZE = 1000`, `WRITE_CHUNK = 500`. +2. **`client.py`** + - `ImmichClient(config, transport: httpx.AsyncBaseTransport | None = None)` + wraps one `httpx.AsyncClient(base_url=f"{url}/api", headers={"x-api-key": key, + "Accept": "application/json"}, timeout=30)`. Tests inject the transport. It is + an async context manager. + - `ALLOWED_ENDPOINTS: tuple[tuple[str, re.Pattern], ...]`, exactly: + `GET /server/version`, `GET /albums`, `GET /albums/{uuid}`, + `GET /search/suggestions`, `GET /search/person`, `POST /search/metadata`, + `POST /search/smart`, `POST /search/statistics`, `POST /albums`, + `PUT /albums/{uuid}/assets`. A private `_request(method, path, **kw)` raises + `PermissionError` for anything else **before** sending. All public methods go + through it. + - Public methods (all `async`, returning parsed JSON or small dataclasses): + `server_version() -> tuple[int, int, int]`; + `list_albums(owned_only=False) -> list[dict]` (with no filter it returns owned + and shared albums; `owned_only=True` sends `isOwned=true`); + `get_album(album_id)`; `suggestions(kind, **filters) -> list[str]`; + `search_person(name) -> list[dict]`; + `search_metadata(body) -> dict` (one page); `search_smart(body) -> dict`; + `statistics(body) -> int` (returns `total`); + `create_album(name, description, asset_ids) -> dict`; + `add_assets(album_id, asset_ids) -> list[dict]` (raw `BulkIdResponseDto` list). + - `iter_metadata(body, *, limit) -> AsyncIterator[dict]` pages until `limit` + assets or there are no more pages. It must support **both** paging styles: legacy + (`page` numbers, `assets.nextPage`) and filter (`cursor`, `assets.nextCursor`). + Choose based on whether the body has a `"filter"` key. + - Errors: any non-2xx becomes `ImmichError(status, message)`, with `message` + taken from Immich's JSON `message` field when present (truncated to 200 chars). + A connection error becomes `ImmichError(0, "Immich is unreachable")`. Never put + the API key in an exception or log line. + - There are no `delete_*`, `update_*`, `remove_*` or `share_*` methods, and no + generic `request()` method is public. +3. **`criteria.py`** + - `PhotoCriteria` (pydantic v2 `BaseModel`, `extra="forbid"`), fields exactly as + in §7.1: `taken_after: date | None`, `taken_before: date | None`, `state`, + `city`, `country`, `person: str | None`, `media: Literal["photo","video","all"] = "all"`, + `favorites_only: bool = False`. + - Validators: `taken_before >= taken_after` when both are set. Strip blank strings to + `None`. `is_empty()` is true when no filter field is set (media `"all"` and + favorites `False` count as unset). + - `local_day_bounds(c, tz) -> (gte_utc | None, lt_utc | None)`: + `taken_after` → local midnight → UTC. `taken_before` → **next day's** local + midnight → UTC (exclusive). Return ISO strings with `Z`. Test across the DST + changes on 2026-03-08 and 2026-11-01 in `America/New_York`. + - `to_search_body(c, *, style, tz, person_ids=None, album_ids=None, + exclude_album_id=None, size=PAGE_SIZE, with_exif=True) -> dict`: + - `legacy`: `takenAfter`, `takenBefore` (use the exclusive bound minus 1 ms, because + legacy `takenBefore` is inclusive), `state`, `city`, `country`, `personIds`, + `albumIds`, `type` (`IMAGE`/`VIDEO`, omitted for `all`), `isFavorite`, `size`, + `withExif`. Set only what's given; never send `null`. `exclude_album_id` + is **not** supported in legacy mode: raise `ValueError` and let callers diff locally. + - `filter`: `{"filter": {"takenAt": {"gte", "lt"}, "state": {"eq"}, "city": {"eq"}, + "country": {"eq"}, "personIds": {"all": [...]}, "albumIds": {"any": [...]}, + ..."type": {"eq"}, "isFavorite": {"eq": true}}, "size", "withExif"}`. + With `exclude_album_id`, use `"albumIds": {"none": [id]}`, and combine it + correctly with `album_ids` (raise if both are given). + - `to_statistics_body(...)`: same mapping without `size` or `withExif`. + - `async resolve_place(client, kind, value) -> str`: case-insensitive exact match + against `client.suggestions(kind)` returns Immich's spelling. If nothing matches, + raise `CriteriaError("No <kind> called 'X'. Closest: A, B, C")` using + `difflib.get_close_matches(value, options, n=3, cutoff=0.5)`. +4. **`server.py`**: only the `FastMCP("immich")` instance, `main()`, and a module + docstring that points at the design. IMM-04 adds the tools. + +**Tests (each must fail without the code it covers)** + +- Config: missing URL; missing key, with the message not containing the key value; + `public_url` falls back to `url`; time zone falls back from `IMMICH_TIMEZONE` to `TZ` + to the default; writes flag accepts only `"true"`; bad `IMMICH_API_STYLE` raises. +- Client: the `x-api-key` header is sent; `DELETE` and `PATCH` raise + `PermissionError` and the transport is **never called**; `/trash/empty` and + `/assets` raise; `iter_metadata` stops at `limit` mid-page; it follows `nextPage` + (legacy) and `nextCursor` (filter); a 400 with `{"message": "x"}` becomes + `ImmichError(400, "x")`; a connect error becomes status 0; the key never appears + in `str(error)`. +- Criteria: golden-body tests for **both** styles for (a) a state only, (b) a date + range only, (c) everything set; inclusive `taken_before` round-trips; DST bounds; + empty criteria → `is_empty()`; reversed dates raise; `exclude_album_id` in legacy + raises; `resolve_place` returns the canonical spelling, and gives three closest + matches on a miss. +- Smoke: `python -c "import immich_mcp.server"` imports, and `mcp.name == "immich"`. + +**Acceptance** + +- `.venv/bin/pytest tests/unit -q` and `uvx ruff==0.8.2 check .` are green. +- `IMMICH_URL=http://x IMMICH_API_KEY=y .venv/bin/python -m immich_mcp` starts and + waits on stdin (Ctrl-C to exit). With either var missing it exits non-zero with a + clear message. +- `grep -rn "delete\|DELETE\|patch\|PATCH" immich_mcp/` matches only the allowlist + test helpers or comments. + +--- + +### [ ] IMM-03: Trip summaries (clustering, places, samples) + +- **Owner:** worker · **Branch:** `feat/immich-mcp-summaries` · **Base:** `origin/main` +- **Depends on:** nothing · **Runs alongside:** IMM-02 · **Merge:** after IMM-02, rebased +- **PR title:** "Summarize Immich search results into trip date clusters" +- **Design:** §7.2 (output fields), §7.4 (clustering), §7 (size cap) + +**Goal.** Pure, synchronous functions that turn a list of Immich asset dicts +(shaped like `AssetResponseDto` with `exifInfo`) into the compact summary that +`immich_search_photos` and `immich_album_gaps` return. They do no I/O and don't import +`client.py` or `criteria.py`. + +**Files to create** + +- `immich_mcp/summarize.py` +- `tests/unit/test_immich_summarize.py` +- `tests/fixtures/immich/assets_maine_trip.json`: about 60 hand-built assets (see the tests below). + +**Do not create** `immich_mcp/__init__.py`; IMM-02 owns it. Without it the package +imports as a namespace package, which is fine for tests. + +**Functions** + +1. `asset_day(asset, tz) -> date | None`: the date part of `asset["localDateTime"]` + parsed **as-is**, with no time-zone conversion (it is wall-clock time; §7.4). If + that's missing, convert `fileCreatedAt` to `tz` and take the date. `None` if both are missing. +2. `asset_place(asset) -> tuple[str | None, str | None]`: `(city, state)` from + `exifInfo`, with empty strings treated as `None`. +3. `cluster_by_day(assets, tz, gap_days=2, keep=8) -> list[dict]`: sort the distinct + days, and start a new cluster when `(day - prev_day).days > gap_days`. Each cluster is + `{"start","end","count","top_place"}`, where `top_place` is the most common + `"City, State"` (or just the state, or `null`). Sort by `count` descending, then + `start` descending; keep `keep`. +4. `top_places(assets, n=5) -> list[{"place","count"}]`, plus a `no_location` count + (returned as a tuple). +5. `media_counts(assets) -> {"photo": n, "video": m}` (`type` is `IMAGE`/`VIDEO`). +6. `pick_samples(assets, n, public_url, tz) -> list[dict]`: `n` evenly spaced assets + after sorting by day, each `{"date","place","file","url"}` with + `url = f"{public_url}/photos/{id}"`. `n=0` → `[]`. +7. `summarize(assets, *, total, tz, public_url, samples=5, truncated=False) -> dict` + with the keys `total, scanned, truncated, first, last, clusters, top_places, + no_location, by_media, samples`. `criteria` is **not** included here; the tool adds it. +8. `fit(result: dict, max_chars=2500) -> dict`: if `json.dumps(result, + separators=(",",":"), ensure_ascii=False)` is over `max_chars`, trim lists in this + order until it fits: `samples` (one at a time down to 0), `top_places` (down to 1), + `clusters` (down to 1). Then set `"trimmed": true`. Never trim scalars. Return a + new dict and don't mutate the input. + +**Tests** + +- The fixture has a trip Aug 2–9, 2026 (with **no photos on Aug 5**, to show a quiet + day doesn't split the trip), a single day in 2023, and one asset with neither + `localDateTime` nor `exifInfo`. Then `cluster_by_day` returns exactly two clusters, + with the trip first: `start=2026-08-02`, `end=2026-08-09`. +- `gap_days=2` boundary: days 1, 4 → two clusters; days 1, 3 → one. +- `localDateTime` `"2026-08-09T23:30:00.000Z"` stays Aug 9 under + `tz=America/Los_Angeles` and `tz=Asia/Tokyo` (no conversion). +- The `fileCreatedAt` fallback converts: `2026-08-10T02:00:00Z` with New York → Aug 9. +- `pick_samples` spreads samples evenly (first and last included when `n >= 2`) and + builds correct URLs. +- `fit` on a large synthetic result is at most 2500 chars, sets `trimmed`, trims samples + before places before clusters, and leaves its input unchanged. +- Empty input → `total` passes through, `first`/`last` are `null`, and lists are empty. + +**Acceptance:** tests and ruff green; `summarize.py` imports only the standard library. + +--- + +### [ ] IMM-04: Phase 1 read tools in the MCP server + +- **Owner:** worker · **Branch:** `feat/immich-mcp-read-tools` · **Base:** `origin/main` + **after IMM-02 and IMM-03 are merged** +- **Depends on:** IMM-02, IMM-03 (production is 3.3.1, so `filter` is the style exercised live; both styles must still be tested) +- **Runs alongside:** IMM-05 · **Blocks:** IMM-06 +- **PR title:** "Add read-only Immich tools: find albums, search photos, smart search, album gaps" +- **Design:** §3 (scenarios A, B-read, C-read, D), §7, §11 + +**Goal.** Register the four Phase 1 tools on the FastMCP server, with exactly the +names, parameters and outputs in §7.2. The server must be stateless: every call +builds its own `ImmichClient`, because raggr starts a new process per call (§5). + +**Files to modify:** `immich_mcp/server.py`. +**Files to create:** `immich_mcp/tools_read.py` (the tool bodies, kept out of `server.py` +so IMM-06 can add `tools_write.py` without conflicts) and `tests/unit/test_immich_server.py`. +**Do not change:** `client.py`, `criteria.py` and `summarize.py`, except for bug +fixes, which must be listed in the report. + +**Details** + +1. **A shared call context** `async with call_context() as ctx:` loads config, + opens the client, and works out `ctx.style`: `config.api_style` if set, otherwise + `filter` when `server_version() >= (3, 2, 0)` and `legacy` below that. It catches + `ImmichError`, `CriteriaError` and `ValueError` and turns them into + `{"error": "<sentence>"}`. Tools never raise to the MCP layer. +2. **Tool names are registered without a prefix**: `find_albums`, + `search_photos`, `smart_search`, `album_gaps` (and in IMM-06 `create_album`, + `add_to_album`). raggr's `select_tools` adds `immich_`, as it does for Homebox, + so Simba sees the `immich_*` names from design §7. Claude Code shows them as + `mcp__immich__find_albums`. Descriptions that mention another tool use the + raggr name (`immich_add_to_album`). +3. **The criteria parameters are flat** on every tool that takes them (§7.1): the + tool function signatures declare `taken_after: str | None = None` (ISO date + string), `taken_before`, `state`, `city`, `country`, `person`, `media`, + `favorites_only` directly. Build `PhotoCriteria` inside. **Test that the + generated input schema has no `$defs` or `$ref`.** +4. **`normalize(ctx, criteria) -> (criteria, person_ids)`** resolves `state`, `city` + and `country` through `resolve_place`, and `person` through `search_person`: + exactly one case-insensitive name match is required, otherwise an error listing + up to 5 candidate names. +5. **`find_albums(query="", limit=10)`**: `list_albums()`, then rank: exact + (case-insensitive) match first, then substring matches, then + `difflib.SequenceMatcher` ratio ≥ 0.6, ties broken by `updatedAt` descending. + An empty query returns the newest `limit` by `updatedAt`. Output is + `{"albums": [{"id","name","asset_count","start","end","shared","owned","url"}]}`. + `owned` comes from a second call, `GET /albums?isOwned=true` (add an + `owned_only: bool = False` parameter to `client.list_albums`): an album is owned + if its ID is in that set. This needs no extra API-key permission. `start`/`end` + are converted to local dates in `config.timezone`. `limit` is clamped to 1–25. +6. **`search_photos(<criteria>, samples=5)`**: reject empty criteria with + `{"error": "Give at least a date range, place, or person."}`. Then + `statistics()` for the total, `iter_metadata(limit=MAX_SCAN)`, and + `fit(summarize(...) | {"criteria": <normalized flat criteria dict, dates as ISO strings>})`. +7. **`smart_search(query, <criteria>, limit=10)`**: requires `query`; the + criteria are optional. `search_smart` with the body from `to_search_body` plus + `query`, and `size=limit` (clamped 1–25). Output is + `{"results": [{"date","place","file","url"}]}` in rank order, passed through `fit`. +8. **`album_gaps(album_id, pad_days=1)`**: `get_album`. With no + `startDate` → error. The window is `[start - pad, end + pad]` in local dates. + Then a **shared helper** `resolve_missing(ctx, album_id, criteria, limit)` that + IMM-06 will reuse: + - `filter` style: one search with `exclude_album_id=album_id`. + - `legacy` style: collect the album's asset IDs (`iter_metadata` with + `album_ids=[id]`, no limit up to `MAX_WRITE * 5`), then the window's assets, + minus that set. + - Returns `(missing_assets, truncated)`. + Output: `summarize(missing)` plus `"album": {"id","name","url"}`, + `"missing_count"`, and `"criteria"` (the window). +9. **Tool descriptions** are one or two sentences each and at most 300 chars, written for the + model, for example: *"Find photo albums by (partial) name. Call this first when the user + mentions a trip or event."* The total schema size across all four tools, measured as + `sum(len(json.dumps(t.inputSchema)) + len(t.description))`, must be **at most 4000 + chars** (about 1000 tokens), which leaves room for the two Phase 2 tools within the + ~1500-token budget in §2.6. + +**Tests (`test_immich_server.py`)** + +Use `mcp.shared.memory.create_connected_server_and_client_session(server._mcp_server)` +and inject a fake Immich through a module-level hook such as +`server.TRANSPORT_FACTORY = lambda: httpx.MockTransport(handler)`. Cover: + +- `list_tools()` returns exactly the four names, with no `$ref` in any schema, within + the schema budget. +- Scenario A: albums "Maine 2026" and "Acadia / Maine" are both found for "maine", + with the exact match ranked first. An empty query returns the newest first. +- Scenario B (read): state "maine" resolves to "Maine"; the summary has two clusters; + `criteria.state == "Maine"`; the total comes from statistics, not from the scan length. +- A misspelled state ("Main") → an error with "Closest: Maine". +- An ambiguous person → an error listing the candidates. +- Empty criteria → the error, and **no HTTP call** is made beyond what's needed for setup. +- Scenario C (read), in **both** styles: the missing photos are the window minus the + album, and `missing_count` matches. +- Scenario D: smart search passes `query`, clamps `limit` to 25, and keeps rank order. +- Version detection: 3.1.9 → legacy bodies; 3.2.0 → filter bodies; the + `IMMICH_API_STYLE` override skips the version call. +- An Immich 500 or a connection error → `{"error": ...}`, not an exception. +- Every tool result is at most 2500 chars, even with a 5000-asset fixture. + +**Acceptance:** tests and ruff green. Manually, `npx @modelcontextprotocol/inspector +.venv/bin/python -m immich_mcp` (with real env, optional) lists four tools. Report the +measured per-call process start time: `time` around one `call_tool` through +`langchain_mcp_adapters` with a fake URL is fine. + +--- + +### [ ] IMM-05: Wire the server into raggr (admin agent, routing, UI, config) + +- **Owner:** worker · **Branch:** `feat/immich-mcp-raggr` · **Base:** `origin/main` + after IMM-02 merged +- **Depends on:** IMM-02 · **Runs alongside:** IMM-04 · **Merge:** after IMM-04 +- **PR title:** "Load the Immich MCP tools into Simba's admin agent" +- **Design:** §6 (raggr-side list), §8.2, §9, §10 + +**Goal.** raggr starts `python -m immich_mcp` the way it starts Homebox and +BookStack: admin-only, prefixed `immich_`, allowlisted, routed, labelled and +configured. Nothing user-visible happens unless `IMMICH_URL` and `IMMICH_API_KEY` are set. + +**Files and exact changes** + +1. `utils/mcp_tools.py`: `load_stdio_tools` (line 83) gains keyword-only + `args: list[str] | None = None` and `cwd: str | None = None`. Pass + `"args": args or []` (line 100) and, only when given, `"cwd": cwd`. Homebox and + BookStack callers stay unchanged. Add a test that the connection dict gets `args`/`cwd`. +2. **New** `utils/immich_mcp.py`, mirroring `utils/homebox_mcp.py`: + - `IMMICH_READ_TOOLS = ("find_albums", "search_photos", "smart_search", + "album_gaps")` and `IMMICH_WRITE_TOOLS = ("create_album", "add_to_album")`. + The server registers these **unprefixed** names (IMM-04 detail 2) and + `select_tools` adds the `immich_` prefix, like Homebox. + - `immich_allowlist() -> tuple`: read tools, plus write tools when + `IMMICH_ENABLE_WRITES == "true"`. This keeps `select_tools` from warning about + "missing tools" when writes are off. + - `load_immich_tools()`: returns `[]` with an info log if `IMMICH_URL` or + `IMMICH_API_KEY` is unset. Otherwise + `load_stdio_tools(server="immich", command=os.getenv("IMMICH_MCP_COMMAND", sys.executable), + args=[] if os.getenv("IMMICH_MCP_COMMAND") else ["-m", "immich_mcp"], + cwd=str(Path(__file__).resolve().parents[1]), env={IMMICH_URL, IMMICH_API_KEY, + IMMICH_PUBLIC_URL, IMMICH_TIMEZONE, IMMICH_ENABLE_WRITES, IMMICH_API_STYLE — only + the ones that are set}, allowlist=immich_allowlist(), prefix="immich", label="Immich")`. +3. `app.py` line 134: `admin_tools = await load_homebox_tools() + await + load_bookstack_tools() + await load_immich_tools()`, plus the import next to + lines 45–46. +4. `blueprints/conversation/tool_routing.py`: + - `GROUP_TERMS["photos"] = "immich photo photos picture pictures pic pics album + albums trip trips vacation"`. + - `tool_group`: add `("immich_", "photos")` to the prefix list. + - `ToolRouter.__init__` (line 87): change `startswith(("homebox", "bookstack"))` to + `startswith(("homebox", "bookstack", "immich"))`. + - Check that adding "album" or "trip" doesn't change any existing routing test. + ("page"/"pages" belong to `knowledge`; there's no overlap.) +5. `blueprints/conversation/tool_labels.py` line 50: add + `("immich_", "Looking through photos…")` to `TOOL_PREFIX_LABELS`. +6. `raggr-frontend/src/components/ActivityTimeline.tsx`: import `Images` from + `lucide-react` (line 23 import block), and add `["immich", Images]` to + `TOOL_ICONS` (after `bookstack`). +7. `blueprints/conversation/prompts.py` line 39: insert the §9 sentence right after + the BookStack sentence, word for word. Keep `test_prompts.py` green; if it pins exact text, + update the expectation in the same PR. +8. `blueprints/integrations/__init__.py` line 92 admin list: add + `("Immich", ("IMMICH_URL", "IMMICH_API_KEY"))` after BookStack. +9. `config/validation.py`: add `"IMMICH_URL"` and `"IMMICH_PUBLIC_URL"` to the + URL list (line 168), `"IMMICH_ENABLE_WRITES"` to the true/false list (line 159), + and validate `IMMICH_API_STYLE ∈ {legacy, filter}` when set. +10. `.env.example` (lines 66–70, the existing Immich block): keep `IMMICH_URL` and + `IMMICH_API_KEY`, and add commented `IMMICH_PUBLIC_URL=`, `IMMICH_TIMEZONE=`, + `IMMICH_ENABLE_WRITES=false`, `IMMICH_API_STYLE=`, with a one-line comment + on the scoped-key permissions (§8.6). Keep `SEARCH_QUERY`/`DOWNLOAD_DIR` for the indexer. +11. `docker-compose.yml` `x-raggr-common` environment block (near + `HOMEBOX_URL` at line 91): `IMMICH_URL`, `IMMICH_API_KEY`, `IMMICH_PUBLIC_URL`, + `IMMICH_TIMEZONE`, `IMMICH_ENABLE_WRITES`, `IMMICH_API_STYLE`, written as + `- NAME=${NAME}` (or `${NAME:-}` to match neighbours). + +**Tests** + +- **New** `tests/unit/test_immich_mcp.py`, modelled on `test_homebox_mcp.py`: allowlist + filtering and prefixing; write tools absent unless the flag is on; the admin gate + refuses non-admins without calling the server; missing env → `[]` with no subprocess; + the env passed to `load_stdio_tools` contains only `IMMICH_*` keys (patch it and inspect); + default command is `sys.executable` with args `-m immich_mcp` and `cwd` is the repo root; + `IMMICH_MCP_COMMAND` override → `args == []`. +- `test_tool_routing.py`: "find my maine trip photos" routes to `photos` for admins; + `immich_*` tools are hidden from non-admins in both static and dynamic modes. +- `test_configuration.py`: a bad `IMMICH_URL` and a bad `IMMICH_ENABLE_WRITES` are + rejected; an empty Immich config is fine (optional integration). +- `tool_labels`: `tool_label("immich_find_albums") == "Looking through photos…"`. +- Integration status: admins see an `Immich` row, non-admins don't. + +**Acceptance:** backend tests, ruff, `yarn tsc --noEmit` and `yarn build` all green. +With `IMMICH_*` unset the app boots exactly as before (log line "Immich MCP not +configured"). + +--- + +### [ ] IMM-06: Phase 2 write tools behind `IMMICH_ENABLE_WRITES` + +- **Owner:** worker · **Branch:** `feat/immich-mcp-write-tools` · **Base:** `origin/main` + after IMM-04 merged (IMM-05 merged too, ideally) +- **Depends on:** IMM-04 · **Blocks:** IMM-07 +- **PR title:** "Let Simba create Immich albums and add photos after confirmation" +- **Design:** §3 (B and C end to end), §7.3, §8 (every layer) + +**Goal.** Add `create_album` and `add_to_album` (prefixed to `immich_*` by raggr) +with **every** write guard in §8. Nothing in this ticket can delete or remove anything. + +**Files to create:** `immich_mcp/tools_write.py`, `tests/unit/test_immich_write_tools.py`. +**Files to modify:** `immich_mcp/server.py` (register the write tools **only if** +`load_config().writes_enabled` is true at startup), and `immich_mcp/tools_read.py` only if +`resolve_missing` needs a small signature change (say so in the report). + +**Details** + +1. **`create_album(name, expected_count, <criteria>, description="")`** + 1. Strip `name`; if it's empty, error. + 2. `list_albums()`. If any album name matches case-insensitively → `{"error": + "An album named 'X' already exists (N photos). Use immich_add_to_album to add to it.", + "album": {id, name, url}}`. + 3. Normalize the criteria (reuse `normalize`). Empty criteria → the error from IMM-04. + 4. `total = statistics(...)`. If `total > MAX_WRITE` → error asking the user to narrow + the range. Then collect **all** IDs with `iter_metadata(limit=MAX_WRITE)`. Request + only IDs where the API allows; otherwise take `id` from each item. + 5. If `len(ids) != expected_count` → `{"error": "The search now matches N photos, + not the M you confirmed. Show the user the new count and ask again.", + "actual_count": N}`, and make **zero** write calls. + 6. `create_album(name, description, ids[:500])`, then `add_assets` for the rest in + chunks of 500. + 7. Return `{"album": {"id","name","url"}, "added": n}`. If chunk N fails partway, + return what was added so far plus `"error"`. Don't retry; the album exists and + the user can run `add_to_album`. +2. **`add_to_album(album_id, expected_new_count, <criteria>)`** + 1. Normalize the criteria, which can't be empty. `get_album` must succeed. If the + album isn't in `list_albums(owned_only=True)`, error before resolving anything + (Immich might reject it anyway, but this message is clearer). + 2. `missing, truncated = resolve_missing(ctx, album_id, criteria, limit=MAX_WRITE)`. + If `truncated` → the too-many error. + 3. If `len(missing) != expected_new_count` → the count-mismatch error, with no writes. + 4. `add_assets` in chunks of 500. Tally `success`, `error == "duplicate"` + (→ `already_present`), and other errors (→ `failed`, keeping at most 5 + `{"id","reason"}` items). + 5. Return `{"album": {...}, "added", "already_present", "failed"}`. +3. **Descriptions** must say plainly: *"Only call after the user has said yes to the + exact count you showed them. Pass that count as expected_count."* Keep both within + the remaining schema budget (§2.6; all six tools together are at most about 6000 chars). +4. **Registration.** With the flag off, `list_tools()` has exactly the four read tools. + With it on, six. + +**Tests (`test_immich_write_tools.py`, in-memory session + MockTransport)** + +- Flag off → no write tools listed, and calling one → MCP "unknown tool" error. +- Scenario B end to end: search → create with the matching count → one `POST /albums` + with 455 IDs split 500/… (use 1,200 IDs to check chunking: one POST + two PUTs). +- Count mismatch → no `POST`/`PUT` (assert on the recorded requests). +- Duplicate name (case-insensitive) → error, and **no** search or write calls after `list_albums`. +- `total > MAX_WRITE` → error, no writes. +- Scenario C end to end in both API styles: gaps → add with `missing_count` → + the PUT body has exactly the missing IDs. +- Results with `duplicate` and `no_permission` are tallied correctly. +- A partial failure on the second PUT → `added` reflects the first chunk, plus `error`. +- The recorded requests across the **whole test module** contain no `DELETE` or `PATCH` + (a module-scoped fixture asserts this at teardown). + +**Acceptance:** tests and ruff green. The report shows a sample request log from the +scenario B test. The conductor checks every §8 layer against the code during review and +lists them in the PR body. + +--- + +### [ ] IMM-07: Usage docs, Claude Code registration, design follow-ups + +- **Owner:** worker · **Branch:** `docs/immich-mcp-usage` · **Base:** `origin/main` + after IMM-05 and IMM-06 are merged +- **Depends on:** IMM-05, IMM-06 (and the IMM-01 findings) +- **PR title:** "Document the Immich MCP tools and how to use them from Claude Code" + +**Files** + +- **New** `docs/immich-mcp/usage.md`: + 1. What Simba can do with photos, using the four scenarios from design §3 as example + prompts. + 2. Setup: create the scoped key (exact permission lists from IMM-01), the env vars + table (copied from design §10, with the real defaults as merged), and how to turn + writes on. + 3. **Claude Code:** the `claude mcp add immich …` command from design §10, using the + real paths. Say that Claude Code keeps one long-lived process (unlike raggr's + per-call processes); nothing in the server depends on either. + 4. Troubleshooting: "Immich MCP not configured" log; `{"error": "Immich is unreachable"}`; + count-mismatch errors (expected: the library changed); a 403 on writes means the key + lacks `album.create`/`albumAsset.create`; `IMMICH_API_STYLE` override. + 5. The safety model in five bullets (design §8, short form). +- `docs/immich-mcp/design.md`: set the status to "implemented (Phase 1 + 2)"; in §13, + fill in the answers from IMM-01 (version, partner photos); note any departures from + the design that IMM-02–06 made (taken from their PR bodies). At minimum, add to §7 + that the server registers unprefixed names and raggr adds `immich_` (IMM-04 detail 2), + and add to §7.2 that `owned` comes from `GET /albums?isOwned=true`. +- `mkdocs.yml`: add `Immich MCP (usage): immich-mcp/usage.md` next to the design entry. +- `CLAUDE.md`: under `utils/`, add `immich_mcp.py` (the loader); under the architecture + list, add a short **`immich_mcp/`** entry (in-repo stdio MCP server for Immich albums + and photo search; admin-only in raggr; also usable from Claude Code; see + `docs/immich-mcp/`). Add `IMMICH_*` to the household-integrations env line. +- Do **not** edit `docs/immich-mcp/tickets.md`. The conductor ticks it (sheepdog rule). + +**Acceptance:** `mkdocs build --strict` passes, if mkdocs is available locally (otherwise +check the links by hand). Every command in `usage.md` was actually run once by the worker +or the conductor, except the ones that need production. + +--- + +### [ ] IMM-08: Live verification and rollout + +- **Owner:** conductor (needs production, the user's Immich, and judgment calls) +- **Depends on:** IMM-02 to IMM-07 merged; `make redeploy` done +- **Design:** §3, §14 + +**Phase 1 (read key, `IMMICH_ENABLE_WRITES` unset)** + +1. Put `IMMICH_URL`, the read `IMMICH_API_KEY`, `IMMICH_PUBLIC_URL` and, if IMM-01 + decided it, `IMMICH_API_STYLE` in production `.env`. Run `make redeploy`. `make logs` + should show "Immich MCP: loaded 4 tools". +2. In Simba's web chat **as an admin**, run scenarios A, B (stop at "Want an album?"), + C (stop at "Add them?") and D from design §3. For each one, record the tool calls + from the activity timeline, the answer, the links (they must open in a browser), and + rough latency. +3. As a **non-admin** user, ask "find my maine photos". The answer must not use + `immich_*` tools (check the timeline), and the integration status page has no Immich row. +4. If a result is wrong (bad clusters, a missed album, a link to the wrong host), file a + follow-up ticket here (IMM-09+) with the evidence. Don't patch production by hand. + +**Phase 2 (write key)** + +5. Swap in the write key and set `IMMICH_ENABLE_WRITES=true`, then `make redeploy`. + Expect "loaded 6 tools". +6. Scenario B end to end with the album name **"ZZ Simba test – delete me"** over a + small, known date range (one day). Check the album in Immich has exactly the confirmed + count. +7. Count-mismatch drill: get a count, then upload one photo from that day on a phone, + then confirm with the old count. Simba must refuse and re-show the new count. +8. Scenario C end to end on the test album: remove a few photos from it in the Immich UI, + ask what's missing, and confirm the add. +9. Partner photos (design §13.2): if partner sharing exists, include a partner photo's + day and record whether the add works or comes back in `failed`. +10. Delete the test album **by hand in the Immich UI**. Simba can't. + +**Claude Code** + +11. Register the server on the laptop with the `usage.md` command. In a fresh Claude Code + session, ask "do I have a Maine album?" and check it answers with the link. + +**Done when:** every step has recorded evidence (paste a short table into the IMM-08 PR +or a comment on the design PR); any follow-ups are filed as IMM-09+ in this file; the +production `.env` matches the final intended state (the write key only if the user wants +writes left on; ask them).