Compare commits

...

2 Commits

Author SHA1 Message Date
Ryan Chen 609ee5aa7e Record Immich 3.3.1 and make the conductor sole owner of tickets.md
CI / backend (push) Successful in 43s
CI / frontend (push) Successful in 30s
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Successful in 29s
Production Immich is reachable at immich.ryanjchen.com and runs 3.3.1,
so the filter API style is the default and the public URL is known.
Parallel ticket PRs editing one status file would conflict, so only the
conductor updates tickets.md, once per wave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:20:35 -04:00
Ryan Chen 29200e49db Add Immich MCP tickets and execution order
CI / backend (push) Successful in 43s
CI / frontend (push) Successful in 31s
CI / backend (pull_request) Successful in 41s
CI / frontend (pull_request) Successful in 29s
Breaks the merged design into eight tickets (IMM-01..08) with files,
steps, tests and acceptance criteria, the wave order with what can run
in parallel, and the Herdr conductor/worker rules used to build them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:08:15 -04:00
+789
View File
@@ -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
`<scratchpad>/plans/<ticket-id>.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 <branch> <scratchpad>/wt-<ticket-id> origin/main
ln -s ~/Programs/raggr/.venv <scratchpad>/wt-<ticket-id>/.venv
ln -s ~/Programs/raggr/raggr-frontend/node_modules <scratchpad>/wt-<ticket-id>/raggr-frontend/node_modules # only IMM-05
herdr pane run <pane> "claude --model sonnet \"Implement the plan in <plan path>. 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 `<scratchpad>/panes.md`.
4. **Watch.** Run a Monitor loop over `herdr agent get <pane>` 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/<cwd-slug>/*.jsonl`). Pane scrollback is too short to rely on.
- Read the diff yourself: `git -C <wt> 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 <pane> "<feedback>"`. 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 <branch>` and
`tea pr create -t "<title>" -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).