leo-testflight-mcp
README.md
# leo-testflight-mcp
TestFlight deployment for registered iOS projects as a Leo package, over MCP —
a register of projects, the rsync-and-ssh that ships one to the Mac, and the App
Store Connect lookup that says which build number it became.
The same integration as the compiled `leo-testflight` package, reachable as a
package the hub **installs at runtime** rather than one it has to be rebuilt for.
## Tools
The compiled package's two tool names, verbatim, with their actions. Not split
into one tool per action the way `leo-proluxe-mcp` split its fifteen — there the
action names were already distinct verbs and the union schema carried nine
mostly-irrelevant fields, whereas `list`/`add`/`update`/`remove` mean nothing on
their own in a global namespace and share almost every parameter.
| Tool | Requires | Does |
|---|---|---|
| `testflight_projects` | `action` | `list`, `add`, `update`, `remove` on the register |
| `testflight_deploy` | `project` | rsync → ssh → `scripts/testflight.sh` on the Mac |
| `testflight_apps` | — | `/v1/apps`, exactly matched when given a `bundle_id` |
| `testflight_builds` | `bundle_id` | `/v1/builds`, newest upload first |
| `testflight_build_number` | `bundle_id` | the highest build number currently up there |
`testflight_deploy` is the compiled package's sequence, unchanged: rsync the
project tree to the Mac (excluding `.build`, `build`, `DerivedData`, `.git`),
rsync `../shared/LeoShared` beside it if there is one, then a single `ssh` that
optionally unlocks the login keychain and runs that project's own
`scripts/testflight.sh` with `--key-id` and `--issuer-id`. The register is marked
`in_progress`, then `success` or `failed`, and the build number is scraped back
out of the script's stdout. Half an hour is a normal runtime.
`testflight_apps` forwards Apple's JSON **unparsed**. Asked for one `bundle_id`
it narrows `data` to exact matches first, because Apple's `filter[bundleId]`
matches on a **prefix** — `com.you.leo` also returns `com.you.leosurface`, a
different app with its own build numbering, and taking the first row reported
that app's builds as yours.
`testflight_build_number` is the one tool that normalises rather than forwards,
because the thing it ports does: `{"build_number": 307}`, or `null`. Never `0` —
a zero baseline is beaten by the very next poll, which is how an upload that
became 307 gets announced as 306.
## Authentication
Two credentials for two different machines, and they are not interchangeable.
**The Mac** is reached over plain `ssh` as `mac_user@mac_host`, exactly as the
compiled package reaches it — not the satellite WebSocket, not russh. Those
settings belong to the Mac integration and are read without being declared.
**App Store Connect** is a short-lived **ES256 JWT**, signed with your `.p8`. The
claim set and the ten-minute lifetime are transcribed from `asc_max_build()` in
`ios/scripts/testflight.sh`, which is where the compiled package's pipeline
actually talks to Apple:
```
header {"typ":"JWT","alg":"ES256","kid":<asc_key_id>}
claims {"iss":<asc_issuer_id>,"iat":<now>,"exp":<now+600>,"aud":"appstoreconnect-v1"}
```
A token is minted **per request**, which is what the script does — signing is
local ECDSA over a couple of hundred bytes, so there is nothing to save by
caching and a cache is a thing that can hand out an expired token.
The part worth knowing about is the signature. Node's `createSign("SHA256")`
returns **ASN.1 DER**, because that is what OpenSSL emits; JOSE wants the two
integers raw, fixed-width, big-endian, concatenated. Base64url the DER instead
and you get a syntactically perfect JWT that Apple answers **401** — the same
bare 401 as a revoked key, which sends whoever is debugging off to regenerate a
`.p8` that was never the problem. The conversion is `derToJose`, and it handles
both directions DER moves in: it strips the `0x00` an INTEGER gets when its top
bit is set, and **left**-pads a value DER trimmed leading zeros from.
## Configuration
Leo hands these to this process under their settings keys, **verbatim and
lower-case**, so the descriptor's `settings_read` and `process.env.<key>` have to
agree or the credential silently never arrives.
| Key | |
|---|---|
| `asc_key_id` | required — the Key ID, and the JWT's `kid` |
| `asc_issuer_id` | required — the Issuer ID, and the JWT's `iss` |
| `asc_private_key` | the `.p8` itself — see below |
| `mac_host` | required for `testflight_deploy` — the Mac satellite |
| `mac_user` | required for `testflight_deploy` |
| `mac_password` | optional — unlocks the login keychain for codesign |
`asc_private_key` is the **one field the compiled package does not have**, and it
is an addition rather than an oversight. That package never holds the key: it
names the key *id* on a command line and the Mac supplies the material from
`~/.appstoreconnect/private_keys/AuthKey_<key id>.p8`. A server that signs its
own tokens needs the bytes. So it is read the way the script reads it — the
setting wins; absent it, that standard path is tried for `asc_key_id`, and a
machine already set up for the compiled package needs nothing pasted. A
*filename* pasted into the box is read as one, because people do that.
Without the settings the server still starts and lists its tools; every call
answers with the missing keys named and where to enter them.
The register lives at `LEO_TESTFLIGHT_MCP_STATE`, or
`$XDG_STATE_HOME/leo-testflight-mcp/projects.json`. The compiled package kept it
in `leo_db`, which a runtime-installed package has no access to.
## Development
```bash
npm install
node test.js # no network, no disk, no Mac
```
The test covers what fails *quietly*, against a throwaway P-256 key generated in
the file — a private key in a repository is a private key on every machine that
clones it.
- **The token, every way of getting it wrong.** They are all the same 401 with no
body: a `kid` that never reached the header, an `aud` of `appstoreconnect`, an
`exp` in milliseconds (`Date.now()` handed in whole is a token issued in the
year 57000, over Apple's 20-minute ceiling), standard base64 instead of
base64url. The claim set, its order, the ten-minute arithmetic and the
three-segment result are pinned.
- **DER → JOSE, including both padding directions.** The `0x00` sign byte, which
DER adds to about half of all signatures; the leading zeros DER *trims*, which
need left-padding back and which are roughly 1 signature in 256 — rare enough
to pass every hand test and fail in production. Then the real proof: sign
through the actual path and verify the raw signature with Node's own P1363
reader, and assert the **unconverted DER does not verify**, which is exactly
what Apple sees. Then 300 real signings, each of which must verify.
- **The `.p8` as it survives a settings box** — literal `\n` two-character
sequences, CRLF, a missing trailing newline. All three end at
`DECODER routines::unsupported`, which reads as "your key is invalid" when it
is the box that mangled it. A P-384 key is refused rather than truncated into a
well-formed, invalid signature.
- **Absent versus empty.** An undeclared filter interpolated into a URL is the
literal text `undefined`, which Apple honours and answers with an empty `data`
array — indistinguishable from an app nobody has created. Empty means no `?` at
all. And on the Mac: `security unlock-keychain -p ''` does not unlock anything,
it *fails*, and joined with `&&` it takes the build down before a line of Swift
compiles — so an empty password must omit the step, not run it blank.
- **Path encoding.** `/` escapes so an id cannot walk sideways into another
endpoint; a space is `%20` and never `+`, which only means "space" in a query.
- **The prefix trap and the build number.** That `com.you.leosurface` does not
come back for `com.you.leo`; that `sort=-uploadedDate` and not by version,
which is a string to the API so "9" sorts above "10"; that a non-numeric
version is skipped rather than coerced; and that "nothing to report" is
`undefined`, never `0`, in both the API lookup and the stdout scrape.
- **`shellQuote` through a real shell**, on the compiled package's own adversarial
input — registered fields are attacker-shaped, and a project whose remote path
carries a `;` is a command on the Mac.
- **rsync's trailing slashes**, both of them. Without one on the source, rsync
copies the *directory* into the target and reports complete success, so the
build runs against a stale tree.
- **The register's semantics** — `update` is `COALESCE` and touches four columns
only (`xcode_project` is not one of them); deploy history and identity survive
an edit; a status write with no build number keeps the last one.
Every one of those was mutation-tested: the behaviour was broken, the suite was
confirmed to fail, and the break was reverted.
## Faithfulness notes
Four places where this is deliberately not a byte-for-byte port, and one it
cannot be.
- **The register is a JSON file, not `leo_db`.** A runtime-installed package has
no hub database. The observable semantics are ported exactly, including the
quirk that lookup is case-insensitive (`LOWER(name) = LOWER(?)`) while the
column's `UNIQUE` constraint is not — so `LeoMobile` and `leomobile` can both
be registered and a lookup finds whichever came first. Reproduced rather than
corrected: a port that quietly refused the second `add` would diverge from the
hub.
- **`asc_private_key` is new**, for the reason above.
- **Three App Store Connect tools are new.** The compiled package exposes two
tools and never calls Apple; `asc_max_build()` inside the script it invokes
does. That half needs no Mac, so it is worth ten seconds instead of a
twenty-minute archive. There is deliberately **no** tool for beta groups,
testers or submissions: nothing in the compiled package or its script touches
`/v1/betaGroups`, `/v1/betaTesters` or `/v1/betaAppReviewSubmissions`, and
inventing endpoints is not porting.
- **`testflight_deploy` has no 30-minute deadline of its own.** The compiled
package declares `ToolDeadline::secs(1800)`; MCP has no equivalent, so the
timeout belongs to whatever the hub applies to the tool call.
- **The tool risk levels do not survive.** `testflight_deploy` is `RiskLevel::High`
in the compiled package — it ships to real testers — and MCP has nowhere to say
so. The description says it in words instead.
## Publishing
```bash
./store/publish.sh # live
./store/publish.sh draft # stage for review at admin.leoconnect.io
```
Needs a Cloudflare login with `D1:Edit` on the `leo-store` database. The script
refuses unless the pinned commit is both real and pushed — a SHA that resolves
nowhere installs cleanly and then fails on every hub at first launch. The pin in
`store/registry-entry.json` is a placeholder until this is pushed somewhere.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues