Releases: sweetrb/apple-photos-mcp
Release list
v2.1.11
Documentation
- Retagged the project-scope
.mcp.jsonentrypoint excerpt fromjson totext. The block is a single"args": [...]key/value fragment, not a JSON document, so it never parsed — a reader copying it as JSON got a syntax error. Added guards that keep every documented example honest: every ```json fence across README.md, CLAUDE.md and docs/ must parse, every documentedAPPLE_*_MCP_*environment variable must exist under src/, and the README `## Tool Reference` must document exactly the tools the built server advertises — in both directions, so neither an undocumented new tool nor a leftover entry for a removed one can pass.
v2.1.10
Fixed
- Every tool was refused by the client, because all 42 advertised schemas declared JSON Schema draft-07. MCP has standardized on JSON Schema 2020-12, and clients now reject anything else outright —
Tool '<name>' has an invalid outputSchema: JSON Schema declares an unsupported dialect ("$schema": "http://json-schema.org/draft-07/schema#"). The default validator supports JSON Schema 2020-12 only.The server connected fine; not one of its 21 tools was usable. The dialect is not ours to choose at the registration site: the SDK'sserver/mcp.jscallstoJsonSchemaCompat(obj, { strictUnions, pipeStrategy })with notarget, somapMiniTarget(undefined)resolves to'draft-7'and both the emittedinputSchemaandoutputSchemaare stamped draft-07. Upgrading zod does not fix it — the v4 (zod/v4-minitoJSONSchema) branch falls back to the same target, verified empirically against SDK 1.30.0 + zod 4.4.3 — so this is fixed at the transport boundary instead, the only public seam that does not reach into SDK internals:src/index.tswraps the stdio transport inwithJsonSchema2020_12(), which rewrites the outgoingtools/listpayload. The converter also handles the keywords whose spelling changed in 2020-12 (definitions→$defsand the#/definitions/refs pointing at it, tupleitems→prefixItems,additionalItems→items,dependencies→dependentRequired/dependentSchemas, booleanexclusiveMinimum/exclusiveMaximum→ numeric). None of those paths is reachable from today's schemas — they are already dialect-portable, and the rewritten payload is byte-identical to the old one once$schemais stripped — so the conversion is a no-op in practice and exists so the seam stays correct if a new zod construct ever introduces one. Origin: sweetrb/apple-mail-mcp#147; fixed identically in all four servers.
Added
- The outputSchema contract test now pins the advertised dialect. Two assertions against the real built server over stdio: every
inputSchema/outputSchemamust declarehttps://json-schema.org/draft/2020-12/schema, and no advertised schema may contain a draft-07-only construct (adraft-07mention anywhere, a#/definitions/$ref, adefinitions/additionalItems/dependencieskeyword, a booleanexclusiveMinimum/exclusiveMaximum, or a$schemaon any node but the root). The existing checks could not see this class at all — they inspect schema contents, and a schema can be perfectly shaped while declaring a dialect that makes the client discard the tool before it is ever called. A new unit suite covers the converter itself, including the cases today's schemas do not exercise.
Documentation
- README's Troubleshooting section now carries the "unsupported dialect" symptom with the exact error text and the fix (upgrade to 2.1.10, restart the host app), since a user on any earlier version sees it on every tool and has no other way to connect it to a server version. The Architecture section states that schemas are advertised as 2020-12 and points at the normalizer, and CONTRIBUTING.md records the wrapper as load-bearing — it is a
transport.sendinterception with no local caller, exactly the shape a later cleanup would remove without knowing that doing so makes the whole server unusable.
Security
-
Bumped the pinned
pnpm/action-setupto v6.0.10 andgithub/codeql-action/*to v4.37.6. Dependabot's weeklygithub-actionsgroup PR landed these in apple-mail-mcp (#146) and apple-numbers-mcp (#62) but skipped this repo, soconformance-check.shreported drift inci.yml,publish.yml,dependabot-rebuild.yml,codeql.ymlandscorecard.yml. The four servers are meant to carry a byte-identical workflow set, and a silent group-skip is the recurring way that breaks — this is the same failure recorded for the scorecard pin in 2026-08-05..github/does not ship, so this owes no version bump. -
Floored
js-yamlto^4.3.1, clearing GHSA-5p4m-2wfm-xmqj (high). Quadratic CPU consumption while resolving!!omapkeys — a malicious YAML document can be made to burn CPU superlinearly in the number of map entries. The advisory notes the CVE-2026-59870 fix was never backported to the 3.x line, so 4.3.1 is the first complete release.js-yamlreaches the tree aseslint->js-yaml, which is development scope, and it does not appear in the committedbuild/index.js— verified, 0 references — so no published artifact ever carried it and this owes no version bump. Nojs-yamloverride existed here before; apple-mail-mcp carried one pinned at^4.2.0— below this fix — which is how the gap was found.
v2.1.9
Fixed
- Every tool advertised an output schema that rejected undeclared keys, discarding otherwise-correct results. The MCP client validates a result's
structuredContentagainst the JSON Schema the server advertised, not against the server's own zod object — and a bare zod raw shape renders asadditionalProperties: false. So any field a handler emits that its schema doesn't enumerate is a hard client-side-32602 … data must NOT have additional properties, throwing away a payload the handler computed correctly. The server never notices, because zod's own parse silently strips unknown keys instead of failing, which is exactly why theregisterTool/outputSchemamigration's "all fields optional, no.strict()" read as permissive: it covered optionality, not undeclared keys. All 21 tools in this repo were advertisingadditionalProperties: false. Every tool now registers through a wrapper applying.passthrough(), advertisingadditionalProperties: true— the contract that migration intended. Found while fixing the same defect in the sibling apple-mail-mcp (sweetrb/apple-mail-mcp#135), where it was not latent: it brokeget-mail-statson every call for anyone with IMAP configured.
Added
- The outputSchema contract test now asserts that every tool tolerates undeclared keys. The existing checks — every tool has an
outputSchema, none requires a field — could not see this class, because they inspect the advertised schema and round-trip only the diagnostic tools; a tool whose payload carries an undeclared key passes CI and fails in the user's client. The suite now fails any tool advertisingadditionalProperties: false, so this cannot silently return.
v2.1.8
Changed
- Dependency bump via Dependabot; committed bundle rebuilt. (automated)
Security
- Bumped the pinned
ossf/scorecard-actionto2d1146689b8cda280b9bc96326124645441f03bc(v2.4.4). Dependabot's weeklygithub-actionsgroup PR landed this in apple-mail-mcp (#134) and apple-numbers-mcp (#56) but skipped this repo and apple-notes-mcp, soconformance-check.shreportedDRIFT: .github/workflows/scorecard.yml differs. The four servers are meant to carry a byte-identical workflow set, and a silent group-skip is the recurring way that breaks..github/does not ship, so this owes no version bump.
v2.1.7
Security
-
Floored
honoto^4.12.34, clearing GHSA-8j4g-w8fx-2239 (moderate). This was deferred earlier the same day: the fix release was still inside the repo's 24-hourminimumReleaseAgesoak — it missed by under three minutes — and nominimumReleaseAgeExcludecarve-out was added, because the soak is the point. It matured at 2026-08-04T02:36:40Z and is floored now.pnpm auditreports no known vulnerabilities. -
Floored
fast-urion its fix for GHSA-7p8r-x3mc-p8w7 (high) and corrected an exact pin that had silently become a ceiling. The override was written as the bare pinfast-uri: 3.1.4back when 3.1.4 was the security floor for an earlier advisory. When 3.1.5 shipped the fix for GHSA-7p8r-x3mc-p8w7, that pin inverted into a ceiling: it held the tree on the vulnerable version and made the advisory permanently unclearable, with no warning — an override that pins rather than floors cannot distinguish "this is the minimum safe version" from "this is the only allowed version". The entry is now the caret range^3.1.5, which stays inside the majorajvexpects while letting patch fixes flow in. This one is genuinely in the product rather than the dev toolchain:fast-uriarrives viaajv→@modelcontextprotocol/sdk, andajvis inlined into the committedbuild/index.js, so the published bundle carried the vulnerable parser verbatim. The rebuilt bundle now carries 3.1.5, which is why this release owes a version bump. Every other floor inpnpm-workspace.yamlwas audited for the same shape; thebrace-expansionentries are already two-sided ranges and are unaffected. Matches apple-mail-mcp#128. -
Floored
ip-addresson 10.3.1 for GHSA-mwp4-54f8-5fhr (high), which also carries GHSA-4xrf-jv44-h6hh and GHSA-22jq-vg5j-6vgg. Reached asexpress-rate-limit→@modelcontextprotocol/sdk, which capped it at 10.2.0 — below the first fully patched release. Written as^10.3.1rather than a pin, for the reason above; it resolves 10.4.0. The dependency is runtime-scope, but the SDK's HTTP transport is not part of this server's bundle (noip-addressreference appears inbuild/index.js), so the exposure was supply-chain rather than a live code path in the published package. -
Floored
postcsson 8.5.23 for GHSA-fxqj-rqcc-2cmp (moderate) — the incomplete-fix follow-up to GHSA-6g55-p6wh-862q, where an attacker-controlledsourceMappingURLcan read arbitrary.mapfiles whenfromis unset. Development scope only (vitest→vite→postcss, absent from the shipped bundle), andvite's own range is already^8.5.6, so the floor costs nothing; it resolves 8.5.25. -
Deferred:
hono(GHSA-8j4g-w8fx-2239, moderate). The fix is in 4.12.34, but that release was still inside this repo's declared 1440-minuteminimumReleaseAgesoak, sopnpm installrefuses it withERR_PNPM_NO_MATURE_MATCHING_VERSION. NominimumReleaseAgeExcludecarve-out was added and no audit suppression was applied — the soak is a deliberate supply-chain control, and squeezing a release past it by minutes defeats the point of having one.honoreaches the tree as@hono/node-server→@modelcontextprotocol/sdkand is not in the shipped bundle, so nothing in the published package is exposed. The follow-up (addhono: ^4.12.34) is recorded as a comment beside the other floors inpnpm-workspace.yaml.pnpm audit --audit-level=highis clean; this advisory is the single remaining moderate.
v2.1.6
Added
version-guardnow requires every version bump to be documented under a real## [X.Y.Z]CHANGELOG heading. The guard already refused a bump to a version that was already on npm, but it never checked that the new version was described anywhere. Notes parked under## [Unreleased]are orphaned the moment the release ships: nothing in the release path renames that section — theversionlifecycle script only syncs the plugin manifests — so the published version goes out undocumented while its release notes sit under a heading still claiming they are unreleased. apple-notes-mcp shipped 2.6.10 and 2.6.11 exactly that way before this check existed. A bump whose version has no matching heading now hard-fails the PR, with an error naming the heading to add. Keep an empty## [Unreleased]at the top regardless —dependabot-rebuild.ymlhard-exits without that marker, and since it already inserts a real heading, bot PRs pass unchanged. The guard file lives in.github/, which does not ship, so this owes no version bump. Matches apple-mail-mcp#124, keeping the guard identical across the four servers. (#61)
Fixed
version-guardno longer demands a version bump for byte-neutralsrc/changes. The shipped-bytes detector treated every non-test file undersrc/as shipped, but TypeScript there reaches users only after esbuild inlines it intobuild/index.js— so a comment-, formatting- or type-only edit that leaves the committed bundle byte-identical was hard-blocked, leaving only two bad options: publish a release of literally nothing, or do not write the comment.src/**/*.tsis now a first-cause detector that implies a bump only whenbuild/**changed too. The exemption is sound rather than merely convenient: ci.yml's "Verify committed build/ matches source" step rebuilds and requiresgit diff --quiet build/, and it runs in thetestjob whosetest (22)/test (24)contexts are required by branch protection — so at merge time an unchangedbuild/provably matchessrc/. Everything else undersrc/(the verbatim-shipped*_reader.pysidecars),requirements.txtandbuild/**stay unconditional detectors, and the rule is written fail-safe: only.tscounts as bundle-only, so any new file type undersrc/still requires a bump.- Timeout errors no longer point at an environment variable that cannot help.
APPLE_PHOTOS_MCP_TIMEOUTsets only the default sidecar budget — ten tools pass an explicittimeoutMsthatrunPhotosReaderuses in preference to it (get-selected-photos2 min;find-duplicates,create-album,add-to-album,set-photo-metadata,set-keywords,set-photo-date5 min;remove-from-album,import-photos10 min;export30 min). The single shared timeout string named the variable unconditionally, so afind-duplicatestimeout on a big library told the user to raise a setting that is inert for that call: they raise it, nothing changes, and no doc admitted those budgets are fixed. The message now branches — the default path keeps "RaiseAPPLE_PHOTOS_MCP_TIMEOUT(ms)…", a fixed-budget call instead says its timeout is fixed at Nms and that the variable does not apply — and README, CLAUDE.md anddocs/LIMITATIONS.mdlist which tools carry which budget. - The "Python 3 not found on PATH" setup error now names the
doctortool, matching every other setup-failure string in the server (setupHint(), the missing-photoscript error, the write-gate error, the Full-Disk-Access remediation) and the invariant stated insrc/utils/docsUrls.ts. It is the one first-run failure that ended with a docs URL alone, steering the user toward a manual install guess instead of the tool that distinguishes a Python problem from an osxphotos or Full-Disk-Access one.doctor's own copy of the string is deliberately left alone — a doctor pointer inside doctor is circular.
Documentation
- Retired the pre-2.0 "read-only except
export" model from every surface that still carried it. Seven opt-in tools have mutated the Photos library since 2.1.0, butexport's own MCPSafety:line still called itself "the only side-effecting tool", and so didsrc/utils/exportPath.ts's module docstring, CLAUDE.md's behavioral-contract bullet, SECURITY.md's posture bullet and CONTRIBUTING.md's sidecar guidelines. Each is now scoped to what remains true —exportis the only tool that writes files outside the library — and points at theAPPLE_PHOTOS_MCP_ENABLE_WRITESgate. Swept with them: README's "All five tools below" heading a section documenting seven;docs/WRITE-BACKEND.md's Writes row (missingset-photo-dateandimport-photos) and its cost argument weighed against "five opt-in tools"; README'sdoctorentry listing four checks whendoctorhas run six since 2.0.0;docs/FULL-DISK-ACCESS.md's "no tool works … every tool ultimately reads the SQLite database" (the write path needs macOS Automation, not FDA — a-1743failure is not an FDA problem) and its three-item "full setup diagnostic"; SECURITY.md's Supported Versions table still covering only1.x.x; CLAUDE.md's "Every tool accepts an optionallibrarypath" (ten of the twenty-one do not, and the same file said so 47 lines earlier); thefind-duplicatesremediation that routed quarantining through Photos.app by hand rather thancreate-album+add-to-album; and the Antigravity marketplace blurb still advertising the Hermes packaging removed in 2.1.5. - Documented three limits and one failure mode that only existed in the source.
get-thumbnail's 8 MB response cap (derivative selection pre-filters on it, but asips-rendered fallback from a very high-resolution original can still exceed it); the schema maxima onfind-duplicates.limit(10000),list-keywords.limit/list-persons.limit(100000),set-photo-metadata'stitle(255) anddescription(2048), andset-keywords' per-keyword 255; andremove-from-album'sapple-photos-mcp-tmp-<hex>scratch album — a rebuild killed part-way (its fixed 10-minute budget expiring while copying a very large album) leaves the original album and every photo intact, but can strand that scratch album, one per interrupted attempt, since the name is collision-checked per call. docs/WRITE-BACKEND.mdnow listsget-selected-photosas the one photoscript-backed read, so the Path→Backend table accounts for every AppleScript caller rather than implying the split is purely reads-vs-writes.- README's link to
docs/WRITE-BACKEND.mdis now an absolute GitHub URL, matching every other cross-file link in the file — it was the last relative one, and relative links do not resolve on npmjs.com. - Recorded the export progress contract as a deliberate decision. The terminal
done === totalprogress notification is best effort: an MCP client deletes a request's progress handler when the response arrives and discards later notifications for that token, so a notification emitted immediately before the result is intermittently thrown away (measured: server sent four, client delivered one). Per-photo notifications are the progress signal; the tool result'sexportedCount/skippedCountis the completion signal. The notification is still emitted on purpose — nothing depends on it, it usually arrives, and removing it would bake a workaround for a client-side lifecycle bug into this server's public behaviour. Noted at the emission site and in CLAUDE.md so it is not "cleaned up" later.
Security
- Floored all three dev-only
brace-expansionmajors on their complete fixes for GHSA-mh99-v99m-4gvg / CVE-2026-14257 (high) —1.1.16→1.1.18,2.1.3→2.1.4, and both5.0.7and5.0.8→5.0.9. Three separate majors are reachable through the dev toolchain (eslint→minimatch@3on v1,minimatch@9on v2,minimatch@10on v5), and they are not API-compatible — minimatch 3 requires the v1 CommonJS API, so a single floor spanning them fails withexpand is not a function. Each major therefore carries its own two-sided floor; the bounds must be two-sided because a bare<5.0.9also matches1.1.18and2.1.4under semver and would drag the CommonJS path onto the v5 ESM API. The advisory's own first-patched versions (1.1.17/2.1.3/5.0.8) are not sufficient: they bound the accumulator incombinebut never threadmaxLengthintoexpandSequence, so the sequence path ({1..N},{a..z..k}) stays capped only by item count and a padded sequence still materialises ~100,000 intermediate strings before the outer bound truncates (measured 4,606 ms / 176 MB RSS on1.1.17vs 9 ms / 61 MB on1.1.18, identical final output). Two of the four paths resolved here (1.1.16,5.0.7) were below even the advisory's floor. Adopted only after every release cleared this repo's 24-hourminimumReleaseAgegate, with nominimumReleaseAgeExcludecarve-out and no audit suppression —pnpm auditwill keep reporting the advisory until GitHub's metadata (which still lists5.0.8as first-patched, and so marks the entire v1 line vulnerable under semver) catches up. Dev toolchain only:brace-expansionis not in the shipped bundle, so the published package is unaffected, the committed bundle is byte-identical, and no version bump is owed. Matches apple-mail-mcp#123 — thanks to @jjoanna2-debug for the original finding.
v2.1.5
Fixed
- Export progress notifications could be silently dropped — most often the final
done === totalone. Theexporttool sent them fire-and-forget (void extra.sendNotification(...)), so the sends raced the tool's own response. That race is not benign: an MCP client deletes the request's progress handler the moment the response arrives (Protocol._onresponse), andProtocol._onprogressdiscards a notification whose token no longer has a handler (reporting "progress notification for an unknown token"). A notification that lost the race was therefore lost outright, never delivered late — and the terminal notification, being emitted last, was the most exposed, so a client surfacing completion from it could intermittently never see an export finish. The tool now awaits its sends before returning, putting every notification on the wire ahead of the result. Each send still swallows its own failure, so this cannot fail an export; it only orders the writes. pnpm versionno longer breaks with the.hermes-plugin/removal. Theversionlifecycle script listed.hermes-pluginin itsgit add;git addexits 128 on a pathspec that matches nothing, which would have broken the documented release step (pnpm version <patch|minor|major> --no-git-tag-version) for every subsequent release. The stale path is dropped from thegit addlist.- Dependabot auto-bump silently stopped staging its own changes.
dependabot-rebuild.yml's bump step writes the patch version, syncs the plugin manifests and prepends a CHANGELOG entry, then staged them withgit add package.json CHANGELOG.md build .claude-plugin .agents codex .hermes-plugin .antigravity-plugin. Once.hermes-plugin/was removed that pathspec matched nothing, andgit addis all-or-nothing — it exited 128 and staged none of the others, with2>/dev/null || truehiding the failure. The following step re-adds onlybuild/, so a Dependabot PR would have committed a rebuilt bundle with no version bump and no changelog entry, failingrequire-version-bumpand blocking the automation that is meant to run without a human. Dropped the stale path, and dropped the error suppression so a future missing path fails loudly instead of silently skipping the bump. persistent-sidecarintegration flake. Theexpected 3 to be 4failure that intermittently blocked unrelated PRs was this dropped notification, not a slow one, and specifically the terminal one. Two earlier attempts were wrong: widening the assertion's poll from 10s to 30s could not help (a discarded message never arrives, and a 30s budget failed identically onmain), and the ordering fix above — correct on its own merits — cannot stop the client tearing down its progress handler first. Measured directly: with the synthetic sidecar's inter-photo spacing removed so all four notifications arrive as one burst, the server sent all four and the client delivered one, rejecting the rest with "progress notification for an unknown token". The test now asserts the three per-photo notifications — what the server controls and the client reliably delivers — and no longer asserts the terminal one, with the mechanism and both failed attempts recorded inline so they are not retried.
Removed
.hermes-plugin/packaging docs (README.md,config.yaml). Hermes Agent has no plugin/marketplace drop-in, so a directory of manifest-looking files was easy to misread as an installable package. The setup it documented is not lost — thehermes mcp addcommand, the~/.hermes/config.yamlmcp_servers:snippet, and the restart note now live inline in the README's "Other Hosts" section. Matches apple-mail-mcp#116, keeping multi-host packaging parity across the four Apple MCP servers. No effect on the published package:.hermes-plugin/was never inpackage.jsonfiles[].
v2.1.4
Security
- Override the MCP SDK's transitive
@hono/node-serverandfast-uridependencies to patched releases (@hono/node-server2.0.10,fast-uri3.1.4), clearing the Hono static-file path-traversal advisory and the twofast-urihost-confusion advisories that the SDK's own ranges still resolve to. Fleet-wide companion to sweetrb/apple-notes-mcp#104 (@oliverames).
v2.1.3
Changed
- Bump the Python sidecar's
osxphotospin from 0.75.9 to 0.76.1. This is a runtime dependency — the sidecar is what reads the Photos library — so the pin ships. Verified before release against a real 31,463-item library rather than the mocked unit tests:healthreports osxphotos 0.76.1 and the correct photo count;library-inforeturns 30,892 photos / 571 movies / 47 albums / 44 keywords / 217 persons; andquery,list-albums,list-keywordsandlist-personsall return correct records.photoscriptstays pinned at 0.5.3 — the two are kept in step deliberately, sincephotos_reader.pyuses itsscript_loader.run_scriptfor bulk album-id calls.
v2.1.2
Changed
- CI/release hardening:
version-guardnow treats the committedbuild/bundle as shipped bytes (closing the lockfile-only and devDep silent-never-publish vectors) with an npm version-collision check;publish.ymlgained a daily self-healing watchdog, manual dispatch, exact-version skip, CI-validated-commit checkout, and GitHub-Release self-heal; Dependabot bundle rebuilds now auto-bump a patch version; CI boots the committed bundle standalone on Node 20 every run; the bundle is now built with--target=node20, making theengines.node >= 20claim enforced at build time. requirements.txtis now exact-pinned and under Dependabot pip management; CodeQL scans the Python sidecar.
Documentation
docs/FULL-DISK-ACCESS.md— adds a "still failing after granting every host app?" section (#37 follow-up): on at least one confirmed setup, TCC attributed the FDA check directly to thenodebinary itself, not to any wrapper app — responsibility never climbed pastnodeto see those grants. Documents the definitive diagnostic (log stream --predicate 'subsystem == "com.apple.TCC"', reading theResp:process from the denial) and the fix (grant FDA to the exactnodepath the log names), plus the ad-hoc-vs-Developer-ID-signature note on why the grant may or may not survive a Node upgrade.