Skip to main content
Glama
TheBananaStand

leo-testflight-mcp

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 prefixcom.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.

Related MCP server: App Store Connect MCP Server

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

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 semanticsupdate 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

./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.

Related MCP Connectors

Related MCP Servers