leo-testflight-mcp
Provides TestFlight deployment for iOS projects, including managing a project register, deploying to a Mac via rsync/ssh, and querying App Store Connect for apps, builds, and build numbers.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@leo-testflight-mcpDeploy the iOS project to TestFlight"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
|
|
|
|
| rsync → ssh → |
| — |
|
|
|
|
|
| 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.
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 | |
| required — the Key ID, and the JWT's |
| required — the Issuer ID, and the JWT's |
| the |
| required for |
| required for |
| 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 MacThe 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
kidthat never reached the header, anaudofappstoreconnect, anexpin 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
0x00sign 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
.p8as it survives a settings box — literal\ntwo-character sequences, CRLF, a missing trailing newline. All three end atDECODER 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 emptydataarray — 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%20and never+, which only means "space" in a query.The prefix trap and the build number. That
com.you.leosurfacedoes not come back forcom.you.leo; thatsort=-uploadedDateand 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" isundefined, never0, in both the API lookup and the stdout scrape.shellQuotethrough 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 —
updateisCOALESCEand touches four columns only (xcode_projectis 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'sUNIQUEconstraint is not — soLeoMobileandleomobilecan both be registered and a lookup finds whichever came first. Reproduced rather than corrected: a port that quietly refused the secondaddwould diverge from the hub.asc_private_keyis 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/betaTestersor/v1/betaAppReviewSubmissions, and inventing endpoints is not porting.testflight_deployhas no 30-minute deadline of its own. The compiled package declaresToolDeadline::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_deployisRiskLevel::Highin 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.ioNeeds 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
Related MCP Connectors
Run App Store Connect from your IDE: pricing, listings, screenshots, releases, AI visibility.
Build, run, and inspect iOS apps in disposable hosted Simulators from cloud coding agents.
- app-managerOAuthapp.lance
App Store Connect operator for AI agents: icons, TestFlight builds, listings, IAP, rejection fixes.
Create App Store screenshots, icons, ASO copy, localization, and revisions via hosted MCP.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables automated management of iOS apps, builds, TestFlight beta testing, and user access control through Apple's App Store Connect API.30 npmMIT
- FlicenseNot gradedqualityDmaintenanceEnables management of App Store Connect apps including registration, listing, IPA upload, store listing updates, and in-app purchase creation via natural language.-
- AlicenseNot gradedqualityCmaintenanceEnables analysis and management of iOS/macOS apps via the App Store Connect API, including app management, reviews, sales reports, analytics, performance metrics, and TestFlight.11 npm2MIT
- AlicenseAqualityBmaintenanceAn MCP server that drives the full App Store release cycle for iOS and macOS apps: version bump, archive + TestFlight upload, metadata, review submission, and status.831 npmMIT