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>
44 KiB
Immich MCP: tickets
Source of truth: 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
- Preconditions.
test "$HERDR_ENV" = 1; readCLAUDE.md;git fetch; noteorigin/main. Pick each ticket's base branch:mainonce its dependencies are merged. A ticket is never stacked on an unmerged branch unless this file says so. - 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. - Worktree + pane per ticket.
Open one tab per wave, split into one pane per ticket (
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.\""--no-focus,--cwdset to the worktree). Keep the ticket→pane mapping in<scratchpad>/panes.md. - Watch. Run a Monitor loop over
herdr agent get <pane>that reports when a pane becomesidle|done|blocked. Review each pane as soon as it finishes; don't wait for the whole wave. - 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:lineand the fix you expect:herdr pane run <pane> "<feedback>". Confirm the pane flips toworking. - 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.
- Read the worker's final report from its transcript (newest
- Ship, one PR per ticket. In the worktree: re-run tests and lint;
git fetch; rebase ontoorigin/mainif 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. Thengit push -u origin <branch>andtea 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. - Clean up. Exit the workers,
herdr pane close/herdr tab close,git worktree removeeach worktree, and delete merged local branches. - 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. Nogit stash, commits, pushes or PRs; the conductor ships. - Read
CLAUDE.mdanddocs/immich-mcp/design.mdfirst. 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 -qanduvx ruff==0.8.2 check .; both must pass. For IMM-05 also runcd 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, thex-raggr-commonenvironment block indocker-compose.yml, and the conductor's note to update.env. - Don't call a real Immich server from tests. Use
httpx.MockTransportor 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(notgh). PR flow: push, thentea 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.pyand 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, astest_homebox_mcp.pydoes. - 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_WRITESunset. 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
curl -s $IMMICH_URL/api/server/versionfrom the docker host. Recordmajor.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'slegacy.
- In Immich → Account Settings → API Keys, create two keys for Ryan's user:
simba-immich-readwith exactlyalbum.read,asset.read,asset.statistics,person.read.simba-immich-write: the same four plusalbum.createandalbumAsset.create. Do not grantall, any*.delete,album.update,album.share,asset.updateorasset.share. Store both in the password manager. Production.envstarts with the read key (Phase 1).
- Using the read key, check the facts the design depends on (curl or httpie):
GET /api/search/suggestions?type=statecontains"Maine"(confirms the reverse-geocoding spelling).POST /api/search/statisticswith{"state":"Maine"}(legacy) or{"filter":{"state":{"eq":"Maine"}}}(3.2+) returns a total.GET /api/albumsreturns albums including the existing Maine album. Record its name,assetCount,startDateandendDate; 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.
- Note the public URL people use to open Immich in a browser. That becomes
IMMICH_PUBLIC_URL. - 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: addCOPY immich_mcp ./immich_mcpafterCOPY 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
config.py- Fields:
url(fromIMMICH_URL, trailing/stripped),api_key(IMMICH_API_KEY),public_url(IMMICH_PUBLIC_URL, defaulturl),timezone(ZoneInfo(IMMICH_TIMEZONE or TZ or "America/New_York")),writes_enabled(IMMICH_ENABLE_WRITES == "true"), andapi_style(IMMICH_API_STYLEin{"legacy", "filter"}, orNonemeaning auto-detect). - Missing
urlorapi_keyraisesValueErrorwith 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.
- Fields:
client.pyImmichClient(config, transport: httpx.AsyncBaseTransport | None = None)wraps onehttpx.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)raisesPermissionErrorfor 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=TruesendsisOwned=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(returnstotal);create_album(name, description, asset_ids) -> dict;add_assets(album_id, asset_ids) -> list[dict](rawBulkIdResponseDtolist). iter_metadata(body, *, limit) -> AsyncIterator[dict]pages untillimitassets or there are no more pages. It must support both paging styles: legacy (pagenumbers,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), withmessagetaken from Immich's JSONmessagefield when present (truncated to 200 chars). A connection error becomesImmichError(0, "Immich is unreachable"). Never put the API key in an exception or log line. - There are no
delete_*,update_*,remove_*orshare_*methods, and no genericrequest()method is public.
criteria.pyPhotoCriteria(pydantic v2BaseModel,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_afterwhen both are set. Strip blank strings toNone.is_empty()is true when no filter field is set (media"all"and favoritesFalsecount 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 withZ. Test across the DST changes on 2026-03-08 and 2026-11-01 inAmerica/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 legacytakenBeforeis inclusive),state,city,country,personIds,albumIds,type(IMAGE/VIDEO, omitted forall),isFavorite,size,withExif. Set only what's given; never sendnull.exclude_album_idis not supported in legacy mode: raiseValueErrorand 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"}. Withexclude_album_id, use"albumIds": {"none": [id]}, and combine it correctly withalbum_ids(raise if both are given).
to_statistics_body(...): same mapping withoutsizeorwithExif.async resolve_place(client, kind, value) -> str: case-insensitive exact match againstclient.suggestions(kind)returns Immich's spelling. If nothing matches, raiseCriteriaError("No <kind> called 'X'. Closest: A, B, C")usingdifflib.get_close_matches(value, options, n=3, cutoff=0.5).
server.py: only theFastMCP("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_urlfalls back tourl; time zone falls back fromIMMICH_TIMEZONEtoTZto the default; writes flag accepts only"true"; badIMMICH_API_STYLEraises. - Client: the
x-api-keyheader is sent;DELETEandPATCHraisePermissionErrorand the transport is never called;/trash/emptyand/assetsraise;iter_metadatastops atlimitmid-page; it followsnextPage(legacy) andnextCursor(filter); a 400 with{"message": "x"}becomesImmichError(400, "x"); a connect error becomes status 0; the key never appears instr(error). - Criteria: golden-body tests for both styles for (a) a state only, (b) a date
range only, (c) everything set; inclusive
taken_beforeround-trips; DST bounds; empty criteria →is_empty(); reversed dates raise;exclude_album_idin legacy raises;resolve_placereturns the canonical spelling, and gives three closest matches on a miss. - Smoke:
python -c "import immich_mcp.server"imports, andmcp.name == "immich".
Acceptance
.venv/bin/pytest tests/unit -qanduvx ruff==0.8.2 check .are green.IMMICH_URL=http://x IMMICH_API_KEY=y .venv/bin/python -m immich_mcpstarts 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.pytests/unit/test_immich_summarize.pytests/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
asset_day(asset, tz) -> date | None: the date part ofasset["localDateTime"]parsed as-is, with no time-zone conversion (it is wall-clock time; §7.4). If that's missing, convertfileCreatedAttotzand take the date.Noneif both are missing.asset_place(asset) -> tuple[str | None, str | None]:(city, state)fromexifInfo, with empty strings treated asNone.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"}, wheretop_placeis the most common"City, State"(or just the state, ornull). Sort bycountdescending, thenstartdescending; keepkeep.top_places(assets, n=5) -> list[{"place","count"}], plus ano_locationcount (returned as a tuple).media_counts(assets) -> {"photo": n, "video": m}(typeisIMAGE/VIDEO).pick_samples(assets, n, public_url, tz) -> list[dict]:nevenly spaced assets after sorting by day, each{"date","place","file","url"}withurl = f"{public_url}/photos/{id}".n=0→[].summarize(assets, *, total, tz, public_url, samples=5, truncated=False) -> dictwith the keystotal, scanned, truncated, first, last, clusters, top_places, no_location, by_media, samples.criteriais not included here; the tool adds it.fit(result: dict, max_chars=2500) -> dict: ifjson.dumps(result, separators=(",",":"), ensure_ascii=False)is overmax_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
localDateTimenorexifInfo. Thencluster_by_dayreturns exactly two clusters, with the trip first:start=2026-08-02,end=2026-08-09. gap_days=2boundary: days 1, 4 → two clusters; days 1, 3 → one.localDateTime"2026-08-09T23:30:00.000Z"stays Aug 9 undertz=America/Los_Angelesandtz=Asia/Tokyo(no conversion).- The
fileCreatedAtfallback converts:2026-08-10T02:00:00Zwith New York → Aug 9. pick_samplesspreads samples evenly (first and last included whenn >= 2) and builds correct URLs.fiton a large synthetic result is at most 2500 chars, setstrimmed, trims samples before places before clusters, and leaves its input unchanged.- Empty input →
totalpasses through,first/lastarenull, 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/mainafter 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
- A shared call context
async with call_context() as ctx:loads config, opens the client, and works outctx.style:config.api_styleif set, otherwisefilterwhenserver_version() >= (3, 2, 0)andlegacybelow that. It catchesImmichError,CriteriaErrorandValueErrorand turns them into{"error": "<sentence>"}. Tools never raise to the MCP layer. - Tool names are registered without a prefix:
find_albums,search_photos,smart_search,album_gaps(and in IMM-06create_album,add_to_album). raggr'sselect_toolsaddsimmich_, as it does for Homebox, so Simba sees theimmich_*names from design §7. Claude Code shows them asmcp__immich__find_albums. Descriptions that mention another tool use the raggr name (immich_add_to_album). - 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_onlydirectly. BuildPhotoCriteriainside. Test that the generated input schema has no$defsor$ref. normalize(ctx, criteria) -> (criteria, person_ids)resolvesstate,cityandcountrythroughresolve_place, andpersonthroughsearch_person: exactly one case-insensitive name match is required, otherwise an error listing up to 5 candidate names.find_albums(query="", limit=10):list_albums(), then rank: exact (case-insensitive) match first, then substring matches, thendifflib.SequenceMatcherratio ≥ 0.6, ties broken byupdatedAtdescending. An empty query returns the newestlimitbyupdatedAt. Output is{"albums": [{"id","name","asset_count","start","end","shared","owned","url"}]}.ownedcomes from a second call,GET /albums?isOwned=true(add anowned_only: bool = Falseparameter toclient.list_albums): an album is owned if its ID is in that set. This needs no extra API-key permission.start/endare converted to local dates inconfig.timezone.limitis clamped to 1–25.search_photos(<criteria>, samples=5): reject empty criteria with{"error": "Give at least a date range, place, or person."}. Thenstatistics()for the total,iter_metadata(limit=MAX_SCAN), andfit(summarize(...) | {"criteria": <normalized flat criteria dict, dates as ISO strings>}).smart_search(query, <criteria>, limit=10): requiresquery; the criteria are optional.search_smartwith the body fromto_search_bodyplusquery, andsize=limit(clamped 1–25). Output is{"results": [{"date","place","file","url"}]}in rank order, passed throughfit.album_gaps(album_id, pad_days=1):get_album. With nostartDate→ error. The window is[start - pad, end + pad]in local dates. Then a shared helperresolve_missing(ctx, album_id, criteria, limit)that IMM-06 will reuse:filterstyle: one search withexclude_album_id=album_id.legacystyle: collect the album's asset IDs (iter_metadatawithalbum_ids=[id], no limit up toMAX_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).
- 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$refin 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_countmatches. - Scenario D: smart search passes
query, clampslimitto 25, and keeps rank order. - Version detection: 3.1.9 → legacy bodies; 3.2.0 → filter bodies; the
IMMICH_API_STYLEoverride 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/mainafter 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
utils/mcp_tools.py:load_stdio_tools(line 83) gains keyword-onlyargs: list[str] | None = Noneandcwd: 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 getsargs/cwd.- New
utils/immich_mcp.py, mirroringutils/homebox_mcp.py:IMMICH_READ_TOOLS = ("find_albums", "search_photos", "smart_search", "album_gaps")andIMMICH_WRITE_TOOLS = ("create_album", "add_to_album"). The server registers these unprefixed names (IMM-04 detail 2) andselect_toolsadds theimmich_prefix, like Homebox.immich_allowlist() -> tuple: read tools, plus write tools whenIMMICH_ENABLE_WRITES == "true". This keepsselect_toolsfrom warning about "missing tools" when writes are off.load_immich_tools(): returns[]with an info log ifIMMICH_URLorIMMICH_API_KEYis unset. Otherwiseload_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").
app.pyline 134:admin_tools = await load_homebox_tools() + await load_bookstack_tools() + await load_immich_tools(), plus the import next to lines 45–46.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): changestartswith(("homebox", "bookstack"))tostartswith(("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.)
blueprints/conversation/tool_labels.pyline 50: add("immich_", "Looking through photos…")toTOOL_PREFIX_LABELS.raggr-frontend/src/components/ActivityTimeline.tsx: importImagesfromlucide-react(line 23 import block), and add["immich", Images]toTOOL_ICONS(afterbookstack).blueprints/conversation/prompts.pyline 39: insert the §9 sentence right after the BookStack sentence, word for word. Keeptest_prompts.pygreen; if it pins exact text, update the expectation in the same PR.blueprints/integrations/__init__.pyline 92 admin list: add("Immich", ("IMMICH_URL", "IMMICH_API_KEY"))after BookStack.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 validateIMMICH_API_STYLE ∈ {legacy, filter}when set..env.example(lines 66–70, the existing Immich block): keepIMMICH_URLandIMMICH_API_KEY, and add commentedIMMICH_PUBLIC_URL=,IMMICH_TIMEZONE=,IMMICH_ENABLE_WRITES=false,IMMICH_API_STYLE=, with a one-line comment on the scoped-key permissions (§8.6). KeepSEARCH_QUERY/DOWNLOAD_DIRfor the indexer.docker-compose.ymlx-raggr-commonenvironment block (nearHOMEBOX_URLat 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 ontest_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 toload_stdio_toolscontains onlyIMMICH_*keys (patch it and inspect); default command issys.executablewith args-m immich_mcpandcwdis the repo root;IMMICH_MCP_COMMANDoverride →args == []. test_tool_routing.py: "find my maine trip photos" routes tophotosfor admins;immich_*tools are hidden from non-admins in both static and dynamic modes.test_configuration.py: a badIMMICH_URLand a badIMMICH_ENABLE_WRITESare rejected; an empty Immich config is fine (optional integration).tool_labels:tool_label("immich_find_albums") == "Looking through photos…".- Integration status: admins see an
Immichrow, 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/mainafter 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
create_album(name, expected_count, <criteria>, description="")- Strip
name; if it's empty, error. 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}}.- Normalize the criteria (reuse
normalize). Empty criteria → the error from IMM-04. total = statistics(...). Iftotal > MAX_WRITE→ error asking the user to narrow the range. Then collect all IDs withiter_metadata(limit=MAX_WRITE). Request only IDs where the API allows; otherwise takeidfrom each item.- 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. create_album(name, description, ids[:500]), thenadd_assetsfor the rest in chunks of 500.- 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 runadd_to_album.
- Strip
add_to_album(album_id, expected_new_count, <criteria>)- Normalize the criteria, which can't be empty.
get_albummust succeed. If the album isn't inlist_albums(owned_only=True), error before resolving anything (Immich might reject it anyway, but this message is clearer). missing, truncated = resolve_missing(ctx, album_id, criteria, limit=MAX_WRITE). Iftruncated→ the too-many error.- If
len(missing) != expected_new_count→ the count-mismatch error, with no writes. add_assetsin chunks of 500. Tallysuccess,error == "duplicate"(→already_present), and other errors (→failed, keeping at most 5{"id","reason"}items).- Return
{"album": {...}, "added", "already_present", "failed"}.
- Normalize the criteria, which can't be empty.
- 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).
- 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 /albumswith 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
duplicateandno_permissionare tallied correctly. - A partial failure on the second PUT →
addedreflects the first chunk, pluserror. - The recorded requests across the whole test module contain no
DELETEorPATCH(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/mainafter 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:- What Simba can do with photos, using the four scenarios from design §3 as example prompts.
- 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.
- 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. - Troubleshooting: "Immich MCP not configured" log;
{"error": "Immich is unreachable"}; count-mismatch errors (expected: the library changed); a 403 on writes means the key lacksalbum.create/albumAsset.create;IMMICH_API_STYLEoverride. - 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 addsimmich_(IMM-04 detail 2), and add to §7.2 thatownedcomes fromGET /albums?isOwned=true.mkdocs.yml: addImmich MCP (usage): immich-mcp/usage.mdnext to the design entry.CLAUDE.md: underutils/, addimmich_mcp.py(the loader); under the architecture list, add a shortimmich_mcp/entry (in-repo stdio MCP server for Immich albums and photo search; admin-only in raggr; also usable from Claude Code; seedocs/immich-mcp/). AddIMMICH_*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 redeploydone - Design: §3, §14
Phase 1 (read key, IMMICH_ENABLE_WRITES unset)
- Put
IMMICH_URL, the readIMMICH_API_KEY,IMMICH_PUBLIC_URLand, if IMM-01 decided it,IMMICH_API_STYLEin production.env. Runmake redeploy.make logsshould show "Immich MCP: loaded 4 tools". - 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.
- 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. - 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)
- Swap in the write key and set
IMMICH_ENABLE_WRITES=true, thenmake redeploy. Expect "loaded 6 tools". - 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.
- 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.
- 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.
- 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. - Delete the test album by hand in the Immich UI. Simba can't.
Claude Code
- Register the server on the laptop with the
usage.mdcommand. 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).