MCP reference

screenwright-mcp reference

0.1.3macOS 15+stdio · JSON-RPC 2.0MCP 2025-06-18

Overview

The agent face of ScreenWright. An MCP server over stdio that reads and writes an iPhone or iPad home-screen layout over USB or Wi-Fi, through the same verified pipeline the Mac app uses.

  • Transport: stdio, newline-delimited JSON-RPC 2.0.
  • MCP protocol version 2025-06-18; the server echoes the client's version in initialize.
  • Server name screenwright, version 0.1.3.
  • Tools only: no resources, no prompts.
  • Logs go to stderr only; stdout is the protocol.
  • Works with the ScreenWright app closed. Same HomeScreenKit core.

Server instructions sent in initialize · verbatim

ScreenWright edits an iPhone/iPad home screen layout over USB or Wi-Fi. Workflow: list_devices → get_layout (structure, widget UUIDs, grid metrics) → get_installed_apps (for planning) → preview_layout (dry-run: compile + validate + exact diff, device untouched) → apply_layout (snapshot → write → settle → read back → verify). Every write is snapshotted first; list_snapshots → preview_restore_snapshot → restore_snapshot roll back. Hard limits: wallpaper and icon appearance are out of reach; apps cannot be removed from the home screen (SpringBoard re-inserts them); widgets cannot be created, only moved/resized/removed. Preview before you apply.

Shared arguments

Every device-touching tool accepts:

  • udid (string, optional): target device UDID from list_devices. Omit it and the server takes the first USB listing; when no USB device is listed it takes the first listing of any transport, so a Wi-Fi-only device is picked silently and the session runs over Wi-Fi (the result says so: transport: "Wi-Fi"). Pass udid when more than one device is around.
  • network (boolean, optional): connect over Wi-Fi instead of USB. Connection establishment retries for about 30 seconds because a locked phone's Wi-Fi entry comes and goes. Any write over Wi-Fi is flagged wireless: true.

get_layout, preview_layout, apply_layout, preview_restore_snapshot and restore_snapshot report transport ("USB" or "Wi-Fi"); every write additionally carries wireless.

Install and register

The server ships inside the app, at ScreenWright.app/Contents/MacOS/screenwright-mcp, and is signed and notarized with it. There is no second download and no install step: register that path with your client. The examples assume the app is in Applications; if it lives somewhere else, choose Help, then Set Up MCP Server in ScreenWright and copy either form below with the real path filled in.

Claude Code: project .mcp.json

.mcp.json
{
  "mcpServers": {
    "screenwright": { "command": "/Applications/ScreenWright.app/Contents/MacOS/screenwright-mcp", "args": [] }
  }
}

or from the shell:

shell
claude mcp add screenwright -- /Applications/ScreenWright.app/Contents/MacOS/screenwright-mcp

Claude Desktop

~/Library/Application Support/Claude/claude_desktop_config.json, same shape; the path must be absolute. Any MCP client that speaks stdio can register it the same way; Claude Code is where it has been exercised.

Smoke test by hand

Two newline-delimited requests on stdin:

shell
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_devices","arguments":{}}}' \
  | /Applications/ScreenWright.app/Contents/MacOS/screenwright-mcp

You should see an initialize result followed by a devices list. A phone with Wi-Fi visibility on appears twice: same UDID, a "USB" row and a "Wi-Fi" row.

With no device attached, list_devices returns {"count": 0, "devices": []} and any device tool returns:

{ "error": "device_unavailable", "message": "no devices visible to usbmuxd. Is the device attached (or paired with Wi-Fi visibility on)?" }

Pairing is the same as for the app: one USB attach with Trust This Computer; for Wi-Fi, enable “Show this iPhone when on Wi-Fi” in Finder once.

Building from a checkout instead? swift build -c release --product screenwright-mcp produces the same server under .build/release, and ./install.sh --mcp copies it to /usr/local/bin. Register whichever copy you mean to run; the two are built from the same sources.

Workflow

  1. list_devices: find the UDID and the transport you want.
  2. get_layout: the current structure, every widget's UUID, and the real grid metrics (columns, rows, slot capacities). Never assume 4×6.
  3. get_installed_apps: the list app references resolve against. A superset of what's on the home screen; hidden and system apps included.
  4. preview_layout: compile, validate, and diff your spec against the device. No write. diff is the change you asked for, with a readable summary beside it; predicted is what the phone will do with it, and normalizations names each app SpringBoard will put back or drop. Pass baselineFingerprint on as expectedBaseline.
  5. apply_layout write: snapshot → validate → write → settle ~3 s → read back → verify.
Rollback:list_snapshotspreview_restore_snapshotrestore_snapshotthrough the same verified pipeline.

Preview before you apply, and before you restore. Read diff; it is what you asked for. Read predicted; it is what you will get.

Tools

Sixteen tools. Three of them write.

list_devices read

List devices visible to usbmuxd: name, model, iOS version, transport, UDID. A device with Wi-Fi visibility appears twice (USB + Wi-Fi rows, same UDID). Identity is the UDID; pass network: true to other tools to use the Wi-Fi link.

Arguments
none
Returns
{ devices: [{ udid, transport, name?, model?, iosVersion? }], count }

get_layout read

Read the device's current home-screen layout as structured JSON: dock, pages (1-based), folders with interior pages, widgets with their UUIDs (needed to reference them in a layout spec), plus real grid metrics. Never assume 4×6. Also baselineFingerprint: a stable hash of this state that apply_layout and restore_snapshot accept as expectedBaseline.

Arguments
udid?, network?
Returns
{ dock: [entry], pages: [{ page, items: [entry] }], metrics: { columns, rows, pageSlotCapacity, dockCapacity, maxPages, folderPageCapacity, widgetSlotCosts: { small, medium, large, extraLarge } }, device: { udid, transport }, baselineFingerprint }

On the test iPhone: 24 slots per page, dock capacity 4, 15 pages, widget costs small 4 / medium 8 / large 16. Entry shapes are below.

get_installed_apps read

Every installed app: bundle ID + display name (some system bundles are nameless). This is the list app refs resolve against: a superset of what's on the home screen; hidden and system apps included. 499 on the test iPhone.

Arguments
udid?, network?
Returns
{ apps: [{ bundleID, name? }], count }

validate_layout read

Check a proposed layout spec against the device without touching it: spec shape, app-ref resolution, slot capacities, widget rules. Returns structured errors and warnings.

Arguments
layout (required), udid?, network?
Returns
{ ok, errors: [issue], warnings: [issue] }; isError when ok is false

preview_layout read

Dry-run a proposed layout spec: compile, validate, and diff against the device's current state. No write happens. The result carries diff, the requested change as exact per-path lines before SpringBoard normalization, and summary: an array of human-readable sentences, one per change a person would recognize (an app or widget moving; a folder created, renamed or removed; a widget resized), with slot-by-slot ripple collapsed. They are the same sentences, in the same order, the ScreenWright app shows in its review sheet. summary is empty when nothing would change; read diff when you need the exact paths.

On a valid spec the result also carries what the phone will make of that request. predicted.layout is the expected read-back, in get_layout format. predicted.diff is current state to predicted state, compared at verification identity level, which ignores app metadata; diff is not computed that way, so the two can differ in metadata detail even when nothing is predicted to be normalized. Do not look for the gap by diffing the two diffs. normalizations is the gap: one entry per affected app, with bundleID, action (reinsert or drop), reason (omittedApp or uninstalledApp) and a message; empty when none. An invalid spec omits both keys.

Preview and apply_layout call the same predictor with the same inputs, so they agree by construction, and preview adds no device read. Prediction is not verification: an actual mismatch on read-back still fails the verify. The predictor's dock and folder placement for a re-inserted app is untested.

The result also carries baselineFingerprint: a stable hash of the device state this diff was computed against (device-owned fields such as iconModDate excluded, so badge churn does not change it). Pass it to apply_layout as expectedBaseline to make the write conditional on the device still being in that state.

Arguments
layout (required), udid?, network?
Returns
{ ok, errors, warnings, diff: [{ path, message }], summary: [string], predicted: { layout, diff: [{ path, message }] }, normalizations: [{ bundleID, action, reason, message }], transport, wireless, baselineFingerprint }; predicted and normalizations only when ok; isError when ok is false

What you asked for, and what you will get

Captured from the production handler against a fixture. The baseline is Phone in the dock and Safari, then Music, on page 1. The spec replaces every page with just Safari:

{ "version": 1, "otherPages": "replace", "pages": [{ "items": ["com.apple.mobilesafari"] }] }
diff · what you asked for
{
  "diff": [
    { "message": "length 2 -> 1",
      "path": "state[1]" }
  ],
  "summary": [
    "Com.apple.Music is removed from page 1 slot 2."
  ]
}
predicted · what you will get
{
  "predicted": {
    "diff": [],
    "layout": {
      "dock": [{ "app": "com.apple.mobilephone" }],
      "pages": [{ "page": 1, "items": [
        { "app": "com.apple.mobilesafari" },
        { "app": "com.apple.Music" }
      ] }]
    }
  },
  "normalizations": [{
    "bundleID": "com.apple.Music",
    "action": "reinsert",
    "reason": "omittedApp",
    "message": "SpringBoard is predicted to re-insert com.apple.Music because it is on the current home screen but omitted from the requested layout."
  }]
}

The requested diff says page 1 goes from two entries to one. predicted.diff is empty: the write changes nothing, because SpringBoard re-inserts Music. summary still describes the requested change, so here it says Music is removed while predicted keeps it; that is deliberate and unresolved.

One response, split in two; ok, errors, the omittedApp warning, transport, wireless and baselineFingerprint are left out. The fixture has no display names, which is why the sentence says Com.apple.Music.

apply_layout write

Compile and write a layout spec to the device: snapshot → validate → write → settle ~3 s → read back → verify. Refuses to write on any compile or validation error. Returns the verification result and the rollback snapshot, plus summary: the human-readable sentences for what was written (one per change, ripple collapsed, same order as preview_layout and the app's review sheet) next to changesPlanned, the exact diff's line count. Use preview_layout first. The write is bound to the device state the spec is compiled against: one read serves compile, validation and the diff, and if the device changes after that read the call is refused as stale_plan rather than overwriting the change. Optional expectedBaseline: the baselineFingerprint a preview reported; when the device no longer matches it the call is refused the same way, so the diff a person approved is the diff that gets written. Omit it and behaviour is unchanged. The verify compares the read-back against the same prediction preview_layout reports as predicted.layout.

Arguments
layout (required), expectedBaseline?, udid?, network?
Returns
the write report plus compileWarnings and baselineFingerprint

On a compile failure: { applied: false, message: "compile failed; nothing was written", ok, errors, warnings, diff, summary }. If validation fails against the device's current state: { applied: false, error: "validation_failed", message: "validation failed against the device's current state; nothing was written", issues }. If the device changed since the read, or expectedBaseline no longer matches: { error: "stale_plan", … }. isError on any of those, and on a write whose verify is not clean.

list_snapshots none

List pre-write snapshots for a device, newest first. Every apply_layout / restore_snapshot takes one before writing; at least the last 20 are kept. Reads the snapshot folder only; no device session is opened. With udid omitted it asks usbmuxd for the first USB listing's UDID, or the first listing of any transport when no USB device is present (a Wi-Fi-only phone counts), and returns device_unavailable only when usbmuxd lists nothing at all; pass udid to list snapshots for a device that isn't plugged in.

Arguments
udid?
Returns
{ udid, snapshots: [{ snapshot, date, label? }], count, root }; snapshot is the filename, date is ISO 8601, root is the store's path

preview_restore_snapshot none

Dry-run a rollback. Writes nothing and takes no snapshot. Invariant 7 says every mutating path has a dry run, and restore did not have one until this tool; restoring a week-old snapshot is exactly when you want to look first. It plans through the same restore planner restore_snapshot uses and takes the same optional snapshot argument (omitted means the newest from list_snapshots), so it reports the same uninstalledApp warnings a real restore would raise for an app deleted since the snapshot, with the matching drop in normalizations. The result is the preview_layout shape, predicted and all, plus snapshot: the filename it chose.

Pass both the filename and baselineFingerprint to restore_snapshot. The filename preserves your choice if a newer snapshot appears in between; the fingerprint, as expectedBaseline, refuses the restore if the device has moved on.

Arguments
snapshot?, udid?, network?
Returns
{ ok, errors, warnings, diff, summary, predicted, normalizations, snapshot, transport, wireless, baselineFingerprint }; predicted and normalizations only for a plan the restore would accept; isError when ok is false

Unknown filename → { error: "unknown_snapshot", value, candidates }. No snapshots → { error: "no_snapshots" }.

restore_snapshot write

Preview with preview_restore_snapshot first. Roll back: push a snapshot from list_snapshots at the device, through the same verified pipeline as apply_layout (a fresh pre-restore snapshot is taken first). Omit snapshot to restore the most recent one, or pass the filename the preview chose. An app deleted from the phone since the snapshot does not block the rollback: the result carries an uninstalledApp warning and SpringBoard drops that entry. Takes the same optional expectedBaseline as apply_layout: if the device has moved on from the state you decided to roll back from, the restore is refused as stale_plan with nothing written.

Arguments
snapshot?, expectedBaseline?, udid?, network?
Returns
the write report plus restored (the filename) and baselineFingerprint

Unknown filename → { error: "unknown_snapshot", value, candidates }. No snapshots → { error: "no_snapshots" }.

get_icon read

An app's real icon as a PNG image, for agents that render. Takes the exact bundle ID.

Arguments
bundle_id (required), udid?, network?
Returns
an image content block, image/png. Missing argument → { error: "invalid_argument" }

list_saved_layouts none

List the saved-layout library on this Mac: every named capture, from any device, newest first. Contacts no device. Each row carries id and revision, both of which the mutating tools require, plus name, payloadChecksum, capturedAt, capture (a device read, or a snapshot with its filename) and source: the device the layout came from, with its grid metrics. Names are unique per source device, so two devices can each hold a layout called Work. Records that could not be read are reported under unavailable with a reason rather than silently dropped.

Arguments
udid?
Returns
{ layouts: [{ id, revision, name, payloadChecksum, capturedAt, capture, source: { udid, name?, model?, osVersion?, metrics } }], unavailable: [{ file, reason }], count }

Pass udid to see only the layouts captured from one device.

save_layout read

Save the connected device's current home screen as a named layout in the library on this Mac. Reads the device and writes nothing to it; no snapshot is taken. The record keeps the exact wire state along with the device's UDID, name, model, iOS version and grid metrics, and gets a fresh id and revision. A name already used for this device is refused as duplicate_name; the same name saved from a different device is fine. Every save creates a new record. There is no overwrite.

Arguments
name (required), udid?, network?
Returns
{ layout: { id, revision, name, payloadChecksum, capturedAt, source }, baselineFingerprint }

preview_saved_layout read

Dry run applying a saved layout to the connected device: the same plan apply_saved_layout would execute, with no write and no snapshot. Reads the record by id; an optional revision is refused as stale_revision if the record has changed since. Returns the record, crossDevice when the layout came from a different device than the one connected, the usual ok / errors / warnings / diff / summary, and a baselineFingerprint for the state the plan was computed against.

Arguments
id (required), revision?, udid?, network?
Returns
{ layout, crossDevice, ok, errors, warnings, diff, summary, transport, wireless, baselineFingerprint }

Apps read from another device are real identifiers, never invented. One that is not installed here is reported rather than assumed, and the apps the phone is expected to drop are listed before you write.

apply_saved_layout write

Preview with preview_saved_layout first. Writes a saved layout to the connected device through the same verified pipeline as apply_layout: snapshot, write, settle, read back, verify. Bound to the record and to the device at once. id and revision are required, and a record renamed or replaced since is refused as stale_revision with nothing written. The optional expectedChecksum refuses as checksum_mismatch if the stored payload is not the one you previewed, and the optional expectedBaseline refuses as stale_plan if the phone has moved on.

Arguments
id (required), revision (required), expectedChecksum?, expectedBaseline?, udid?, network?
Returns
the standard write report, plus the record it was applied from

Applying a layout captured on another device is allowed and warned about, not blocked. The report names the apps that were actually dropped, measured from the read back rather than predicted.

rename_saved_layout none

Rename a saved layout in the library. Contacts no device. Requires the record's current revision; a stale one is refused as stale_revision and nothing changes. The new name must be unused on the record's source device, duplicate_name otherwise. Renaming to the same name is a no-op that keeps the revision and reports changed as false.

Arguments
id, revision, name (all required)
Returns
{ layout: { id, revision, name, ... }, changed }

delete_saved_layout none

Delete a saved layout from the library. Contacts no device and touches no snapshot. Requires the record's current revision; a stale one is refused as stale_revision and the record stays. There is no undo.

Arguments
id, revision (both required)
Returns
{ deleted: true, id }

Layout spec

The grammar below is embedded verbatim in the validate_layout, preview_layout and apply_layout descriptions; an agent needs nothing outside tools/list.

layout-spec grammar
{
  "version": 1,
  "dock": ["com.apple.mobilephone", "Safari"],   // optional; 1-4 apps only. Omit to keep the current dock.
  "pages": [
    {"page": 3, "items": [                        // "page": 1-based page number (patch mode only; one past the end appends)
      "com.apple.freeform",                       // bare string = app ref: exact bundle ID, else case-insensitive display name
      {"folder": "Studio", "items": ["com.adobe.fresco", "Linea"]},   // or "pages": [[...],[...]] for explicit interior pages
      {"widget": "<UUID from get_layout>", "size": "medium"}          // existing widgets only, by UUID; "size" optionally resizes
    ]}
  ],
  "otherPages": "keep",       // "keep" = patch mode (default): only the named pages change, everything else is carried verbatim.
                              // "replace" = full mode: the pages array IS the whole home screen, in order ("page" numbers forbidden).
  "unplacedWidgets": "keep"   // "keep" | "error" | "drop". Default "keep" in patch mode, "error" in full mode. Never silent.
}

Wide lines scroll sideways inside the block; the scrollbar stays visible.

default

Patch mode

otherPages: "keep". Name the pages you're changing by 1-based number; one past the end appends a page. Unnamed pages, the dock if omitted, and every widget not mentioned are carried verbatim.

Full mode

otherPages: "replace". The pages array is the entire home screen in order. Page numbers are an error (page_number_in_full_mode). A widget you don't place is unplaced_widget (the full-mode default, "error") unless unplacedWidgets is "keep" (re-anchored to the end of its current page, with a widget_reanchored warning) or "drop" (removed, with a widget_dropped warning).

App references

A bare string is an app. Exact bundle ID first; otherwise a case-insensitive display name. A miss is unknown_bundle_id or unknown_name with did_you_mean; two matches is ambiguous_name with candidates.

Folders

{ "folder": name, "items": [refs] } packs items into interior pages using the device's folder page capacity; { "folder": name, "pages": [[refs], [refs]] } sets interior pages explicitly. Folders need a name and non-empty pages. Folders cannot nest.

Widgets

{ "widget": UUID, "size"?: "small" | "medium" | "large" | "extraLarge" }. The UUID must come from get_layout. size resizes; contents are carried verbatim. widgetSlotCosts from get_layout lists a cost for all four sizes on every device, so its presence there says nothing about fit. What governs extraLarge is page capacity: the kit counts it as 4×8, 32 slots, and an iPhone page holds 24 (pageSlotCapacity), so validation rejects it there as pageCapacity. It fits only where pageSlotCapacity is at least 32. The 4×8 footprint is the kit's assumption; it has not been confirmed against an iPad.

unplacedWidgets

"keep" carries an unmentioned widget where it is; "error" refuses; "drop" removes it. Never silent.

Rules an agent must know

  1. App refs resolve against the installed list; a miss or ambiguity is a structured error (did_you_mean / candidates), never a guess.
  2. Placing an app removes it from everywhere else: duplicates are unrepresentable.
  3. Apps CANNOT be removed from the home screen (SpringBoard re-inserts anything omitted; the validator warns with omittedApp). Widgets CAN be removed, by omission with "unplacedWidgets": "drop".
  4. Widgets are referenced by UUID from get_layout, never invented; a synthesized widget renders as a dead blank tile.
  5. Slot budget per page comes from get_layout's metrics (e.g. 24 on an iPhone: app/folder = 1 slot, small widget = 4, medium = 8, large = 16). Dock 1–4 apps only. Page count within the device's maxPages (15).
  6. Emptied folders and pages are dropped with warnings (folder_dropped, page_dropped).

get_layout entry shapes

The read mirrors the spec's keys so an agent can copy identifiers straight from a read into a spec.

App
{ "app": "com.apple.facetime", "name": "FaceTime" }
Folder
{ "folder": "Utilities", "pages": [[{ "app": "…", "name": "…" }, …]] }
Widget
{ "widget": "4B5D8634-…", "size": "small", "contents": "small Smart Stack: com.apple.weather / …" }
Unknown
{ "unknown": true, "note": "unrecognized entry shape: preserved verbatim on untouched pages, not expressible in a layout spec" }

Device-owned metadata (iconModDate, bundleVersion) never appears and is never part of a diff.

Errors

Every issue is an object, never a bare string:

{ "error" | "warning": code, "message", "path"?, "value"?, "did_you_mean"?: [], "candidates"?: [] }

All issues are collected in one pass. Codes are stable.

PhaseCodes
Spec shapeinvalid_spec, unknown_key, nested_folder
Resolutionunknown_bundle_id, unknown_name, ambiguous_name, unknown_widget, duplicate_ref
Mergeduplicate_page_number, page_gap, page_number_in_full_mode, unplaced_widget, widget_reanchored, widget_dropped, folder_dropped, page_dropped, unrepresentable_entry
Validation rules
same shape, coded by rule name
dockCapacity, dockAppsOnly, pageCapacity, pageCount, emptyPage, unknownApp, duplicateApp, folderName, folderEmpty, folderPageCapacity, folderPageCount, widgetShape, widgetProvenance, widgetModified, widgetGridSize, unknownEntry, omittedApp (warning), installedListUnavailable, provenanceUnchecked
Tool and write levelinvalid_spec (layout is not an object), invalid_argument, unknown_snapshot (with candidates), no_snapshots, device_unavailable, validation_failed (with issues), stale_plan, write_unverified

stale_plan

Returned by apply_layout and restore_snapshot when the device's layout changed between your read and your write. Message: “the device changed since the layout was planned; nothing was written and no snapshot was taken. Re-run apply_layout (it re-plans against the current state)”. Carries drift: [{ path, message }]. Nothing is pushed and no snapshot file is written. The same refusal, with expectedBaseline and currentBaseline added and drift empty, answers an expectedBaseline that no longer matches the device, whichever preview it came from. The write is bound to the read its spec was compiled against, so a change on the phone after that read is refused, not merged away.

  1. Re-read with get_layout.
  2. Re-preview.
  3. Apply.

write_unverified

Returned when the pipeline broke after the snapshot landed: phase is sending, readBack or decoding. The write may be on the device; do not conclude “not applied”. Carries phase, writeSent, cause, transport, wireless, snapshot, snapshotPath and recoveryAdvice, the same recovery fields an unclean report carries. Re-read the device before deciding anything.

What a refusal looks like

Two apps on the test phone are named “Connect”: App Store Connect and Garmin Connect. Ask for “Connect” by name and typo a policy key, and validate_layout returns this. Nothing was written.

validate_layout → result · isError
{
  "ok": false,
  "errors": [
    {
      "error": "ambiguous_name",
      "path": "$.pages[0].items[0]",
      "value": "Connect",
      "candidates": ["com.apple.AppStoreConnect", "com.garmin.connect.mobile"],
      "message": "\"Connect\" names 2 installed apps (com.apple.AppStoreConnect, com.garmin.connect.mobile). Use a bundle ID; the compiler reports ambiguity rather than guessing"
    }
  ],
  "warnings": [
    {
      "warning": "unknown_key",
      "path": "$.otherPagez",
      "value": "otherPagez",
      "did_you_mean": ["otherPages"],
      "message": "unrecognized key \"otherPagez\" is ignored; a typo here silently takes the default"
    }
  ]
}
  1. Candidates, not a choice. The agent picks a bundle ID; the compiler won't.
  2. A path into your spec. $.pages[0].items[0]. Every issue says where.
  3. Typos in keys come back as warnings. A misspelled otherPages would otherwise silently take the default.
  4. Every issue in one pass. Fix them all, validate again, then preview.

Message strings are the compiler's own; the two Connect apps are real, from the test phone's installed list. And exact bundle IDs are exact: com.zwift.Zwift is the Zwift Companion app; the game is com.zwift.ZwiftGame.

Warnings

An app you omitted is a warning (omittedApp), not an error: it can't be removed, and SpringBoard will put it back. The verifier expects that re-insertion and counts it as clean, and preview_layout shows it before you write, as a reinsert in normalizations and in predicted.layout. An app deleted from the phone since a snapshot is the other case: uninstalledApp on a restore, a drop in the prediction.

Write report

One shape for anything that went through the verified writer, apply_layout and restore_snapshot:

write report
{
  "applied": true,
  "verifiedClean": boolean,          // the only success signal that means anything
  "transport": "USB" | "Wi-Fi",
  "wireless": boolean,
  "snapshot": "<filename>",           // yyyyMMdd-HHmmss.SSS[-label].plist, e.g. 20260901-011349.680-mcp-apply.plist
  "snapshotPath": "<absolute path>",  // under ~/Library/Application Support/ScreenWright/Snapshots/<udid>/
  "changesPlanned": number,           // the number of diff lines the preview showed
  "summary": [string],                // one readable sentence per change, ripple collapsed; [] when nothing changed
  "expectedNormalizations": [{ "path", "message" }],
  "unexpectedDifferences": [{ "path", "message" }],
  "recoveryAdvice": string            // only when verifiedClean is false
}
FieldMeaning
appliedThe push happened. Refusals never reach this shape; they return error objects instead.
verifiedCleanThe read-back matched the predicted post-write state, ignoring device-owned fields. The same prediction the preview reported as predicted.layout.
transport / wirelessWhich link carried the write, and whether it went over Wi-Fi.
snapshot / snapshotPathThe pre-write snapshot's filename and full path. Labels: mcp-apply for applies, pre-restore for restores.
changesPlannedThe diff line count the preview showed.
summaryOne human-readable sentence per change that was written (moves, folder create / rename / remove, widget resize), in the order the app's review sheet lists them. The same array preview_layout returns; the diff stays the exact record.
expectedNormalizationsDifferences the verifier predicted and counted as clean, such as SpringBoard re-inserting an app the spec omitted.
unexpectedDifferencesAnything else. Non-empty means UNCLEAN and the result is returned with isError.
recoveryAdvicePresent only when unclean: restore the named snapshot with restore_snapshot, or use Settings → General → Transfer or Reset iPhone → Reset → Reset Home Screen Layout.

apply_layout adds compileWarnings; restore_snapshot adds restored. Diffs ignore iconModDate and bundleVersion. Settle time is ~3 s.

What a clean write returns

The dock swap from the worked example, as logged: 6 diff lines, two sentences, that snapshot, USB, verified clean. Field names are the server's; the object is assembled from the session record.

apply_layout → result
{
  "applied": true,
  "verifiedClean": true,
  "transport": "USB",
  "wireless": false,
  "snapshot": "20260901-011349.680-mcp-apply.plist",
  "snapshotPath": "/Users/you/Library/Application Support/ScreenWright/Snapshots/<udid>/20260901-011349.680-mcp-apply.plist",
  "changesPlanned": 6,
  "summary": [
    "Zwift moves from page 11 slot 23 to dock slot 4.",
    "Music moves from dock slot 4 to page 11 slot 23."
  ],
  "expectedNormalizations": [],
  "unexpectedDifferences": [],
  "compileWarnings": []
}

Snapshots and rollback

Every apply_layout and restore_snapshot writes a snapshot first, under ~/Library/Application Support/ScreenWright/Snapshots/<udid>/, named yyyyMMdd-HHmmss.SSS[-label].plist. At least the last 20 are kept per device. The app's Snapshots sheet and list_snapshots read the same folder; an agent's snapshots show up in the app, and vice versa.

apply_layout→ writessnapshot A·restore_snapshot A→ writessnapshot B→ A is back, and B can undo the undo.

list_snapshots returns them newest first. preview_restore_snapshot plans the rollback without writing or snapshotting: the diff and summary the restore would make, the uninstalledApp warnings it would raise, the filename it chose, and a baselineFingerprint. restore_snapshot pushes that snapshot back through the full verified pipeline, and takes a fresh pre-restore snapshot first, so rolling back is itself reversible. Omit snapshot on either to use the most recent; pass the previewed filename and fingerprint to restore exactly what you looked at.

The app reads the same folder as a write history (Help, then Write History, or ⌘Y; also the History button in the editor). Each snapshot is one write, labelled by who took it: mcp-apply and pre-restore show as AGENT, gui-apply as APP, with the same summary sentences for what the layout had become by the next snapshot. An agent's writes are visible to the person who owns the phone even if the app was closed at the time. Write History, and what it cannot tell →

If a restore verifies unclean, the on-device reset is the last resort:

SettingsGeneralTransfer or Reset iPhoneResetReset Home Screen Layout

Connectivity and hard limits

Connectivity

  • USB: one-time “Trust This Computer” pairing. Works with the phone locked.
  • Wi-Fi: enable “Show this iPhone when on Wi-Fi” in Finder over USB once; then same network, no cable. network: true selects the link; connection attempts retry for ~30 s because a locked phone's entry comes and goes; writes are flagged wireless: true.
  • If Wi-Fi discovery stops working after it worked once, check that the phone's Private Wi-Fi Address is off for that network.
  • Grid metrics are read from the device: an iPad's grid is read as an iPad's grid.

Hard limits

The first four are stated to agents in the server's instructions rather than attempted; the rest follow from the wire format and the device's own metrics.

  • Wallpaper: cannot be set. On iOS 27, reading it drops the service channel, so it is never read either.
  • Icon appearance (dark, tinted, large): cannot be set.
  • Apps cannot be removed from the home screen; SpringBoard re-inserts anything omitted. Omission is a warning.
  • Widgets cannot be created; only moved, resized, or removed, by UUID.
  • Blank slots, free placement, hidden apps, Today View widgets, App Library order: not in the wire format.
  • Page ceiling is a device metric (15 on current iOS). Slot capacities are device metrics too.
  • Apple can change the service in any iOS release. Tested on iOS 27.0, iPhone 16 Pro, and an iPad.

The full table, with reasons, is on the How it works page.

Worked example: a real session

From Claude Code, over USB, on an iPhone 16 Pro running iOS 27.0.

preview_layoutok · diff: 6 lines · summary: 2 sentences · dock + page 11 only
apply_layout20260901-011349.680-mcp-apply.plist · verifiedClean: true
preview_layoutok · Smart Stack 4B5D8634-… page 1 → page 2 · 147 diff lines · summary: 5 sentences
apply_layout20260901-011440.097-mcp-apply.plist · verifiedClean: true
Patch 1: swap Zwift and Music in the dockget_layout → preview_layout → apply_layout

The dock was full (4 of 4), so the spec placed Music explicitly in the page-11 slot Zwift was leaving (nothing left for the phone to guess). preview_layout returned a 6-line diff touching only the dock and page 11. apply_layout came back with zero unexpected differences.

20260901-011349.680-mcp-apply.plistverifiedClean: true
Patch 2: move the Weather Smart Stack to page 2, by UUIDget_layout → preview_layout → apply_layout

The widget (4B5D8634-…) went from page 1 to page 2. Both pages were exactly 24 of 24 slots, so page 2's last four apps moved into the slots the widget freed on page 1. The raw plan was 147 changes (positional ripple), with the widget's contents carried verbatim.

20260901-011440.097-mcp-apply.plistverifiedClean: true

What the Mac app's Review sheet makes of those two patches, pinned by a fixture test: seven entries: a dock swap, one widget moved, and four apps moved to make room.

fixture · compile only  On a fixture, the same compiler reorganized a real 265-app baseline into 17 folders with zero errors and zero warnings, every app placed exactly once. A compile, not a device write.