Add Immich MCP tickets and execution order #91
@@ -0,0 +1,760 @@
|
||||
# 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. Tick the ticket's checkbox in this file in the
|
||||
same PR or a follow-up docs PR.
|
||||
|
||||
### 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.
|
||||
- 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: verify prod Immich, make keys)
|
||||
│ (informs defaults; 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 Verify production Immich and create scoped API keys | conductor | – | Wave 1 | (no PR; updates design §13 in IMM-07) |
|
||||
| 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 LAN access and the Immich admin UI; no worker, no PR)
|
||||
- **Depends on:** nothing · **Blocks:** IMM-04 merge (default API style), IMM-08
|
||||
- **Why:** The design machine couldn't reach `IMMICH_URL`
|
||||
(`http://192.168.1.5:2283`), so the production version is unknown. The API changed
|
||||
shape in v3.2.0 (§11). The design also has two open questions (§13.1, §13.2) that
|
||||
only the real server can answer.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. `curl -s $IMMICH_URL/api/server/version` from the docker host. Record
|
||||
`major.minor.patch`.
|
||||
- **Below 2.0:** stop and tell the user. The design assumes v2+ endpoints.
|
||||
- **3.2 or later:** the default API style is `filter`. Below 3.2 it's `legacy`.
|
||||
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 `{"state":"Maine"}` (legacy) or
|
||||
`{"filter":{"state":{"eq":"Maine"}}}` (3.2+) returns a total.
|
||||
- `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. Note the public URL people use to open Immich in a browser. That becomes
|
||||
`IMMICH_PUBLIC_URL`.
|
||||
5. Put the results (version, API style, partner answer, Maine album facts, public
|
||||
URL) in `<scratchpad>/imm-01-findings.md`, paste them into the IMM-04 and IMM-07
|
||||
plan files, and post them to the user.
|
||||
|
||||
**Done when:** the version is known and the API style decided; both keys exist with
|
||||
exactly the listed permissions; the Maine facts and the partner answer are recorded.
|
||||
|
||||
---
|
||||
|
||||
### [ ] 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 (and the IMM-01 findings for the default API style)
|
||||
- **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.
|
||||
- `docs/immich-mcp/tickets.md` (this file): tick IMM-02 to IMM-07.
|
||||
|
||||
**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).
|
||||
Reference in New Issue
Block a user