From 6c2188bcdff72bd7fc3721addea9d906f1854e55 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:21:23 +0200 Subject: [PATCH] Split by domain and serve every operation over MCP The certificate work landed inside the metadata client, where it did not belong, and adding an MCP server was about to make that worse by depending on both halves at once. Six crates now: core carries the transport (JWT auth, the HTTP client, JSON:API envelopes, errors), aso carries App Metadata, signing carries certificates and CSR generation, and frontend holds what the two front ends share so they cannot disagree. aso and signing know nothing about each other, and CI builds each library on its own, because a workspace build compiles them with the server's dependencies already in the graph and proves nothing. core exists because both domains need Client and ApiKey. Calling that shared transport "aso" would have meant signing depending on App Store Optimization for its HTTP client, a misnomer that only gets worse when provisioning profiles and devices land. This breaks anyone embedding the library. Rust allows inherent impls only in the crate that defines a type, so every resource that used to be a method on Client is an extension trait now: AppsApi, CertificatesApi, and the rest, with a prelude per domain crate. The methods do not exist until the trait is in scope. The MCP server exposes 28 tools, every command line operation except certificate revocation, following the xcrs contract in smbcloud-cli: tool names come from the embedder through a macro, so a host can re-expose the same tools under its own namespace without forking the bodies. Contract tests assert the exact tool set, titles under 40 characters, all six description sections, annotation consistency, and a description on every input schema property. Revocation is not a tool, and a test fails the build if it becomes one. Revoking a signing certificate invalidates every provisioning profile embedding it, for every teammate and every CI job, at once and irreversibly, and no confirmation string a model types on a human's behalf makes that safe. Scoped deletes are exposed and annotated destructive, because a version, a localization, or a screenshot can each be recreated by re-running the tool that made it. Tool results carry no key material. certificate_create returns the path it wrote the private key to, never the key, since results are read by a model and end up in transcripts. Credentials resolve per call rather than at startup, so an unconfigured server still answers tools/list and then fails naming what is missing; one that refuses to start can tell nobody what it needs. The MCP Registry marker lives in the CLI crate's README rather than the library's, because the registry fetches the package named in server.json and greps that crate's README for it. Co-Authored-By: siGit Code siGit-Code-Cloud-Agent-Session: https://code.sigit.si/cloud/sessions/d8b72f7e-fb25-45ee-a7f7-e2bf826baff3 --- .github/workflows/ci.yml | 10 + .github/workflows/release-mcp-registry.yml | 127 +++ Cargo.lock | 460 ++++++++- Cargo.toml | 18 +- README.md | 24 + crates/ascapi/Cargo.toml | 28 - crates/ascapi/src/ascapi.rs | 62 -- crates/aso/Cargo.toml | 19 + crates/{ascapi => aso}/src/app.rs | 31 +- crates/{ascapi => aso}/src/app_info.rs | 27 +- .../src/app_info_localization.rs | 54 +- crates/{ascapi => aso}/src/app_screenshot.rs | 103 +- .../{ascapi => aso}/src/app_screenshot_set.rs | 50 +- .../{ascapi => aso}/src/app_store_version.rs | 76 +- .../src/app_store_version_localization.rs | 54 +- crates/{ascapi => aso}/src/build.rs | 23 +- crates/{ascapi => aso}/src/bundle_id.rs | 31 +- crates/aso/src/lib.rs | 44 + crates/cli/Cargo.toml | 9 +- crates/cli/README.md | 84 ++ crates/cli/src/main.rs | 70 +- crates/core/Cargo.toml | 18 + crates/{ascapi => core}/src/auth.rs | 0 crates/{ascapi => core}/src/client.rs | 17 +- crates/{ascapi => core}/src/error.rs | 0 crates/{ascapi => core}/src/jsonapi.rs | 0 crates/core/src/lib.rs | 23 + crates/frontend/Cargo.toml | 20 + crates/frontend/src/certificates.rs | 247 +++++ crates/frontend/src/enums.rs | 86 ++ crates/frontend/src/env.rs | 44 + crates/frontend/src/lib.rs | 30 + crates/frontend/src/time.rs | 45 + crates/mcp/Cargo.toml | 25 + crates/mcp/src/lib.rs | 881 ++++++++++++++++++ crates/mcp/src/requests.rs | 246 +++++ crates/mcp/src/server.rs | 361 +++++++ crates/signing/Cargo.toml | 24 + crates/{ascapi => signing}/src/certificate.rs | 75 +- crates/{ascapi => signing}/src/csr.rs | 2 +- crates/signing/src/lib.rs | 22 + server.json | 72 ++ 42 files changed, 3352 insertions(+), 290 deletions(-) create mode 100644 .github/workflows/release-mcp-registry.yml delete mode 100644 crates/ascapi/Cargo.toml delete mode 100644 crates/ascapi/src/ascapi.rs create mode 100644 crates/aso/Cargo.toml rename crates/{ascapi => aso}/src/app.rs (69%) rename crates/{ascapi => aso}/src/app_info.rs (63%) rename crates/{ascapi => aso}/src/app_info_localization.rs (72%) rename crates/{ascapi => aso}/src/app_screenshot.rs (80%) rename crates/{ascapi => aso}/src/app_screenshot_set.rs (74%) rename crates/{ascapi => aso}/src/app_store_version.rs (76%) rename crates/{ascapi => aso}/src/app_store_version_localization.rs (73%) rename crates/{ascapi => aso}/src/build.rs (71%) rename crates/{ascapi => aso}/src/bundle_id.rs (71%) create mode 100644 crates/aso/src/lib.rs create mode 100644 crates/cli/README.md create mode 100644 crates/core/Cargo.toml rename crates/{ascapi => core}/src/auth.rs (100%) rename crates/{ascapi => core}/src/client.rs (89%) rename crates/{ascapi => core}/src/error.rs (100%) rename crates/{ascapi => core}/src/jsonapi.rs (100%) create mode 100644 crates/core/src/lib.rs create mode 100644 crates/frontend/Cargo.toml create mode 100644 crates/frontend/src/certificates.rs create mode 100644 crates/frontend/src/enums.rs create mode 100644 crates/frontend/src/env.rs create mode 100644 crates/frontend/src/lib.rs create mode 100644 crates/frontend/src/time.rs create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/requests.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/signing/Cargo.toml rename crates/{ascapi => signing}/src/certificate.rs (88%) rename crates/{ascapi => signing}/src/csr.rs (99%) create mode 100644 crates/signing/src/lib.rs create mode 100644 server.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2b6376..c7cb1fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,16 @@ jobs: - name: Test run: cargo test --workspace --locked + - name: Check each library builds on its own + # The layering only means something if the lower crates can be built + # without the upper ones. A workspace build would happily compile + # core with the MCP server's dependencies already in the graph and + # tell us nothing. + run: | + cargo check --package smbcloud-ascapi-core --locked + cargo check --package smbcloud-ascapi-aso --locked + cargo check --package smbcloud-ascapi-signing --locked + - name: Build docs # Intra-doc links are load-bearing in this crate: the certificate # module's warnings about lost private keys point at the csr module diff --git a/.github/workflows/release-mcp-registry.yml b/.github/workflows/release-mcp-registry.yml new file mode 100644 index 0000000..860656c --- /dev/null +++ b/.github/workflows/release-mcp-registry.yml @@ -0,0 +1,127 @@ +name: MCP Registry Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.1.0)" + required: true + +# OIDC is how we authenticate to the MCP Registry: the registry trusts a GitHub +# Actions token issued for this repository, so no long-lived secret is needed. +permissions: + id-token: write + contents: read + +jobs: + publish: + name: Publish the MCP server to the MCP Registry + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Check the server name is consistent across the sources + shell: bash + run: | + server_name="$(jq -r '.name' server.json)" + echo "Server name: ${server_name}" + + # The registry proves ownership by fetching the published crate and + # finding this marker in its README, so the marker has to be in the + # README of the crate named in server.json's packages — the CLI, not + # the library. + grep -q "mcp-name: ${server_name}" crates/cli/README.md + grep -q "mcp-name: ${server_name}" README.md + + - name: Check the crate version matches the release + shell: bash + run: | + workspace_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" + if [ "${workspace_version}" != "${RELEASE_VERSION}" ]; then + echo "Workspace version ${workspace_version} does not match release version ${RELEASE_VERSION}." >&2 + exit 1 + fi + + - name: Set the release version in server metadata + shell: bash + run: | + jq --arg v "${RELEASE_VERSION}" \ + '.version = $v | .packages |= map(.version = $v)' \ + server.json > server.tmp + mv server.tmp server.json + cat server.json + + - name: Verify the published crate carries the ownership marker + shell: bash + run: | + # Checking the source tree is not enough: a crate published before + # the marker landed will still be missing it, and the registry reads + # the published artifact, not this checkout. The comparison is + # case-sensitive. + server_name="$(jq -r '.name' server.json)" + crate="$(jq -r '.packages[0].identifier' server.json)" + + # crates.io indexes a few seconds to a few minutes after publish, and + # this workflow may run while the crate release is still in flight. + for attempt in $(seq 1 20); do + if curl -fsSL -H "User-Agent: smbcloudXYZ/smbcloud-ascapi release-workflow" \ + "https://crates.io/api/v1/crates/${crate}/${RELEASE_VERSION}" >/dev/null; then + echo "${crate} ${RELEASE_VERSION} is indexed on crates.io." + break + fi + if [ "${attempt}" -eq 20 ]; then + echo "${crate} ${RELEASE_VERSION} is still not indexed on crates.io after 10 minutes." >&2 + exit 1 + fi + echo "Waiting for crates.io to index ${crate} ${RELEASE_VERSION} (attempt ${attempt}/20)..." + sleep 30 + done + + curl -fsSL -H "User-Agent: smbcloudXYZ/smbcloud-ascapi release-workflow" -o crate.crate \ + "https://crates.io/api/v1/crates/${crate}/${RELEASE_VERSION}/download" + if ! tar -xOf crate.crate "${crate}-${RELEASE_VERSION}/README.md" | grep -q "mcp-name: ${server_name}"; then + echo "${crate} ${RELEASE_VERSION} README is missing the 'mcp-name: ${server_name}' marker." >&2 + echo "crates.io versions are immutable — cut a new release with the marker in place." >&2 + exit 1 + fi + rm crate.crate + + - name: Install mcp-publisher + shell: bash + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Authenticate to the MCP Registry + shell: bash + run: ./mcp-publisher login github-oidc + + - name: Publish + shell: bash + run: | + # Re-running a release should not fail the workflow: the registry + # rejects a version it already holds, which is the desired end state + # anyway. + out="$(./mcp-publisher publish 2>&1)" && { echo "${out}"; exit 0; } + echo "${out}" + if echo "${out}" | grep -q "duplicate version"; then + echo "Version already published — skipping." + exit 0 + fi + exit 1 + + - name: Verify the server is listed + shell: bash + run: | + server_name="$(jq -r '.name' server.json)" + curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq . diff --git a/Cargo.lock b/Cargo.lock index 992eae6..701409d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -82,7 +91,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -94,7 +103,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -207,6 +227,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.2" @@ -238,7 +270,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -303,6 +335,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -358,7 +424,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -367,6 +433,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -388,35 +460,90 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -562,6 +689,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -644,6 +795,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -885,6 +1042,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -1117,6 +1280,26 @@ dependencies = [ "yasna", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1169,6 +1352,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1272,6 +1490,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1322,7 +1566,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -1391,21 +1646,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] -name = "smbcloud-ascapi" +name = "smbcloud-ascapi-aso" version = "0.1.0" dependencies = [ - "base64", - "jsonwebtoken", + "async-trait", "md5", - "rand 0.8.7", - "rcgen", "reqwest", - "rsa", "serde", "serde_json", - "thiserror", - "tokio", - "zeroize", + "smbcloud-ascapi-core", ] [[package]] @@ -1417,10 +1666,69 @@ dependencies = [ "clap", "serde", "serde_json", - "smbcloud-ascapi", + "smbcloud-ascapi-aso", + "smbcloud-ascapi-core", + "smbcloud-ascapi-frontend", + "smbcloud-ascapi-mcp", + "smbcloud-ascapi-signing", "tokio", ] +[[package]] +name = "smbcloud-ascapi-core" +version = "0.1.0" +dependencies = [ + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "smbcloud-ascapi-frontend" +version = "0.1.0" +dependencies = [ + "base64", + "schemars", + "serde", + "serde_json", + "smbcloud-ascapi-aso", + "smbcloud-ascapi-core", + "smbcloud-ascapi-signing", +] + +[[package]] +name = "smbcloud-ascapi-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "rmcp", + "schemars", + "serde", + "serde_json", + "smbcloud-ascapi-aso", + "smbcloud-ascapi-core", + "smbcloud-ascapi-frontend", + "smbcloud-ascapi-signing", + "tokio", +] + +[[package]] +name = "smbcloud-ascapi-signing" +version = "0.1.0" +dependencies = [ + "async-trait", + "rand 0.8.7", + "rcgen", + "reqwest", + "rsa", + "serde", + "serde_json", + "smbcloud-ascapi-core", + "zeroize", +] + [[package]] name = "socket2" version = "0.6.5" @@ -1476,6 +1784,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1493,7 +1812,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1513,7 +1832,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1594,7 +1913,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1607,6 +1926,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1659,9 +1992,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1788,7 +2133,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1821,12 +2166,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1962,7 +2360,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -1983,7 +2381,7 @@ checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2003,7 +2401,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2043,7 +2441,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0593562..a5431e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "2" -members = ["crates/ascapi", "crates/cli"] +members = [ + "crates/core", + "crates/aso", + "crates/signing", + "crates/frontend", + "crates/mcp", + "crates/cli", +] [workspace.package] version = "0.1.0" @@ -10,7 +17,11 @@ license = "Apache-2.0" repository = "https://github.com/smbcloudXYZ/smbcloud-ascapi" [workspace.dependencies] -smbcloud-ascapi = { version = "0.1.0", path = "crates/ascapi" } +smbcloud-ascapi-core = { version = "0.1.0", path = "crates/core" } +smbcloud-ascapi-aso = { version = "0.1.0", path = "crates/aso" } +smbcloud-ascapi-signing = { version = "0.1.0", path = "crates/signing" } +smbcloud-ascapi-frontend = { version = "0.1.0", path = "crates/frontend" } +smbcloud-ascapi-mcp = { version = "0.1.0", path = "crates/mcp" } anyhow = "1" clap = "4" jsonwebtoken = "9" @@ -19,8 +30,11 @@ serde = "1" serde_json = "1" thiserror = "2.0" tokio = "1" +async-trait = "0.1" base64 = "0.22" rcgen = { version = "0.14", default-features = false, features = ["pem", "crypto", "aws_lc_rs"] } rsa = "0.9" rand = "0.8" zeroize = { version = "1", features = ["alloc"] } +rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] } +schemars = "1" diff --git a/README.md b/README.md index caeab29..7952adf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,30 @@ `smbcloud-ascapi` is the app stores coolest API. +Six crates, split by domain and layered so neither front end can drift +from the other: + +| Crate | What it is | +| --- | --- | +| `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types | +| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots | +| `smbcloud-ascapi-signing` | Code signing: certificates, plus local RSA key pair and CSR generation | +| `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction | +| `smbcloud-ascapi-mcp` | The MCP contract and stdio server | +| `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` | + +`aso` and `signing` know nothing about each other, and neither knows +anything about the front ends. Both add their calls to +`smbcloud_ascapi_core::Client` as **extension traits**, since Rust only +allows inherent impls in the crate that defines a type: + +```rust +use smbcloud_ascapi_core::{ApiKey, Client}; +use smbcloud_ascapi_aso::prelude::*; // or signing::prelude +``` + +MCP Registry name: `mcp-name: io.github.smbcloudXYZ/ascapi` + ## Copyright © 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)). diff --git a/crates/ascapi/Cargo.toml b/crates/ascapi/Cargo.toml deleted file mode 100644 index 601af6b..0000000 --- a/crates/ascapi/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "smbcloud-ascapi" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description = "Embeddable client for the App Store Connect API's App Metadata resources (apps, app infos, app store versions, bundle IDs, and their localizations)." -readme = "README.md" - -[lib] -path = "src/ascapi.rs" - -[dependencies] -base64 = { workspace = true } -jsonwebtoken = { workspace = true } -rand = { workspace = true } -rcgen = { workspace = true } -rsa = { workspace = true, features = ["pem"] } -md5 = "0.7" -reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -zeroize = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/ascapi/src/ascapi.rs b/crates/ascapi/src/ascapi.rs deleted file mode 100644 index a6bbf20..0000000 --- a/crates/ascapi/src/ascapi.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Embeddable Rust client (and CLI, in the sibling `smbcloud-ascapi-cli` -//! crate / `ascapi` binary) for two slices of the App Store Connect API: -//! -//! - [App Metadata](https://developer.apple.com/documentation/appstoreconnectapi/app-metadata): -//! apps, app infos and their localizations, app store versions and their -//! localizations, and bundle IDs. -//! - Signing certificates ([`certificate`]), plus local RSA key pair and -//! CSR generation ([`csr`]) so a certificate can be issued end to end -//! without the developer portal. -//! -//! ```no_run -//! use smbcloud_ascapi::{ApiKey, Client}; -//! use smbcloud_ascapi::app_store_version::{AppStoreVersionCreateAttributes, Platform}; -//! -//! # async fn example() -> smbcloud_ascapi::Result<()> { -//! let api_key = ApiKey::from_p8_file( -//! "L84N624YQH", -//! "b4e8d369-8b7d-4538-8435-643b73237575", -//! "/Users/me/private_keys/AuthKey_L84N624YQH.p8", -//! )?; -//! let client = Client::new(api_key); -//! -//! let apps = client.list_apps(Some("ai.siti.Siti")).await?; -//! let app = &apps[0]; -//! -//! client -//! .create_app_store_version( -//! &app.id, -//! AppStoreVersionCreateAttributes { -//! platform: Platform::VisionOs, -//! version_string: "1.0.0".to_string(), -//! copyright: None, -//! }, -//! ) -//! .await?; -//! # Ok(()) -//! # } -//! ``` -//! -//! Not covered (yet): provisioning profiles and devices (the other half of -//! the Provisioning surface), app preview (video) binary uploads, age -//! rating declarations, in-app purchases, and TestFlight. - -pub mod app; -pub mod app_info; -pub mod app_info_localization; -pub mod app_screenshot; -pub mod app_screenshot_set; -pub mod app_store_version; -pub mod app_store_version_localization; -pub mod auth; -pub mod build; -pub mod bundle_id; -pub mod certificate; -pub mod client; -pub mod csr; -pub mod error; -pub mod jsonapi; - -pub use auth::ApiKey; -pub use client::Client; -pub use error::{Error, Result}; diff --git a/crates/aso/Cargo.toml b/crates/aso/Cargo.toml new file mode 100644 index 0000000..9a91f41 --- /dev/null +++ b/crates/aso/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "smbcloud-ascapi-aso" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "App Store Connect App Metadata resources: apps, app infos, versions, bundle IDs, localizations, and screenshots." + +[lib] +path = "src/lib.rs" + +[dependencies] +async-trait = { workspace = true } +md5 = "0.7" +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +smbcloud-ascapi-core = { workspace = true } diff --git a/crates/ascapi/src/app.rs b/crates/aso/src/app.rs similarity index 69% rename from crates/ascapi/src/app.rs rename to crates/aso/src/app.rs index 735a7cb..f11e0a6 100644 --- a/crates/ascapi/src/app.rs +++ b/crates/aso/src/app.rs @@ -6,11 +6,12 @@ //! platform, a build upload) is what actually provisions the `App` row. //! This module only reads and updates one that already exists. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{Document, ListDocument, Resource, UpdateBody, UpdateData}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{Document, ListDocument, Resource, UpdateBody, UpdateData}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "apps"; @@ -36,11 +37,27 @@ pub struct AppUpdateAttributes { pub content_rights_declaration: Option, } -impl Client { +/// Apps: the top-level record for a product on the store. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppsApi { /// `GET /v1/apps`, optionally narrowed with `filter[bundleId]` — the /// usual way to resolve an app's ASC id from the bundle identifier /// already baked into an Xcode project. - pub async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result> { + async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result>; + + async fn get_app(&self, app_id: &str) -> Result; + + async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result; +} + +#[async_trait] +impl AppsApi for Client { + async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result> { let mut query = Vec::new(); if let Some(bundle_id) = filter_bundle_id { query.push(("filter[bundleId]", bundle_id)); @@ -51,14 +68,14 @@ impl Client { Ok(doc.data) } - pub async fn get_app(&self, app_id: &str) -> Result { + async fn get_app(&self, app_id: &str) -> Result { let path = format!("/v1/apps/{app_id}"); let doc: Document = self.request(Method::GET, &path, &[], None::<&()>).await?; Ok(doc.data) } - pub async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result { + async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result { let path = format!("/v1/apps/{app_id}"); let body = UpdateBody { data: UpdateData { diff --git a/crates/ascapi/src/app_info.rs b/crates/aso/src/app_info.rs similarity index 63% rename from crates/ascapi/src/app_info.rs rename to crates/aso/src/app_info.rs index f0454e5..e917aba 100644 --- a/crates/ascapi/src/app_info.rs +++ b/crates/aso/src/app_info.rs @@ -4,11 +4,12 @@ //! `AppInfo` rows itself (there is no create/delete endpoint) — this module //! only reads them and updates category/age-rating relationships. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{Document, ListDocument, Resource}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{Document, ListDocument, Resource}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appInfos"; @@ -23,19 +24,33 @@ pub struct AppInfoAttributes { pub type AppInfo = Resource; -impl Client { +/// AppInfos: the container for localized names and subtitles. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppInfosApi { /// `GET /v1/apps/{app_id}/appInfos`. An app usually has one current /// `AppInfo` (plus, briefly, a second pending one while an edit awaits /// review) — this is the parent resource `AppInfoLocalization`s hang /// off of, and the id you need for `create_app_info_localization`. - pub async fn list_app_infos(&self, app_id: &str) -> Result> { + async fn list_app_infos(&self, app_id: &str) -> Result>; + + async fn get_app_info(&self, id: &str) -> Result; +} + +#[async_trait] +impl AppInfosApi for Client { + async fn list_app_infos(&self, app_id: &str) -> Result> { let path = format!("/v1/apps/{app_id}/appInfos"); let doc: ListDocument = self.request(Method::GET, &path, &[], None::<&()>).await?; Ok(doc.data) } - pub async fn get_app_info(&self, id: &str) -> Result { + async fn get_app_info(&self, id: &str) -> Result { let path = format!("/v1/appInfos/{id}"); let doc: Document = self.request(Method::GET, &path, &[], None::<&()>).await?; diff --git a/crates/ascapi/src/app_info_localization.rs b/crates/aso/src/app_info_localization.rs similarity index 72% rename from crates/ascapi/src/app_info_localization.rs rename to crates/aso/src/app_info_localization.rs index 9890da7..d6307df 100644 --- a/crates/ascapi/src/app_info_localization.rs +++ b/crates/aso/src/app_info_localization.rs @@ -1,11 +1,12 @@ -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{ +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody, UpdateData, }; -use reqwest::Method; -use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appInfoLocalizations"; @@ -50,8 +51,39 @@ pub struct AppInfoLocalizationRelationships { pub app_info: ToOne, } -impl Client { - pub async fn list_app_info_localizations( +/// Per-locale app name, subtitle, and privacy policy URL. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppInfoLocalizationsApi { + async fn list_app_info_localizations( + &self, + app_info_id: &str, + ) -> Result>; + + /// `POST /v1/appInfoLocalizations` — adds a locale's name/subtitle to an + /// `AppInfo`. + async fn create_app_info_localization( + &self, + app_info_id: &str, + attributes: AppInfoLocalizationCreateAttributes, + ) -> Result; + + async fn update_app_info_localization( + &self, + id: &str, + attributes: AppInfoLocalizationUpdateAttributes, + ) -> Result; + + async fn delete_app_info_localization(&self, id: &str) -> Result<()>; +} + +#[async_trait] +impl AppInfoLocalizationsApi for Client { + async fn list_app_info_localizations( &self, app_info_id: &str, ) -> Result> { @@ -61,9 +93,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/appInfoLocalizations` — adds a locale's name/subtitle to an - /// `AppInfo`. - pub async fn create_app_info_localization( + async fn create_app_info_localization( &self, app_info_id: &str, attributes: AppInfoLocalizationCreateAttributes, @@ -88,7 +118,7 @@ impl Client { Ok(doc.data) } - pub async fn update_app_info_localization( + async fn update_app_info_localization( &self, id: &str, attributes: AppInfoLocalizationUpdateAttributes, @@ -106,7 +136,7 @@ impl Client { Ok(doc.data) } - pub async fn delete_app_info_localization(&self, id: &str) -> Result<()> { + async fn delete_app_info_localization(&self, id: &str) -> Result<()> { let path = format!("/v1/appInfoLocalizations/{id}"); self.request_no_content::<()>(Method::DELETE, &path, &[], None) .await diff --git a/crates/ascapi/src/app_screenshot.rs b/crates/aso/src/app_screenshot.rs similarity index 80% rename from crates/ascapi/src/app_screenshot.rs rename to crates/aso/src/app_screenshot.rs index bcae02d..777b526 100644 --- a/crates/ascapi/src/app_screenshot.rs +++ b/crates/aso/src/app_screenshot.rs @@ -10,16 +10,17 @@ //! 3. `PATCH /v1/appScreenshots/{id}` with `uploaded: true` and an MD5 //! checksum of the file commits the upload. //! -//! [`Client::upload_app_screenshot`] does all three steps in one call. +//! [`AppScreenshotsApi::upload_app_screenshot`] does all three steps in one call. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{ +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Deserializer, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody, UpdateData, }; -use reqwest::Method; -use serde::{Deserialize, Deserializer, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appScreenshots"; @@ -105,9 +106,67 @@ pub struct AppScreenshotCommitAttributes { pub source_file_checksum: Option, } -impl Client { +/// Screenshot image binaries within a set. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppScreenshotsApi { /// `GET /v1/appScreenshotSets/{id}/appScreenshots`. - pub async fn list_app_screenshots( + async fn list_app_screenshots(&self, app_screenshot_set_id: &str) + -> Result>; + + /// `POST /v1/appScreenshots` — reserves the asset and returns + /// pre-signed `uploadOperations`. Prefer [`AppScreenshotsApi::upload_app_screenshot`] + /// unless you need to drive the upload/commit steps yourself. + async fn create_app_screenshot( + &self, + app_screenshot_set_id: &str, + attributes: AppScreenshotCreateAttributes, + ) -> Result; + + /// PUTs `bytes` to every one of `screenshot`'s reserved + /// `uploadOperations`, slicing the buffer per operation's `offset` / + /// `length`. Does not commit the upload — call + /// [`AppScreenshotsApi::commit_app_screenshot`] (or use + /// [`AppScreenshotsApi::upload_app_screenshot`]) after this succeeds. + async fn upload_app_screenshot_bytes( + &self, + screenshot: &AppScreenshot, + bytes: &[u8], + ) -> Result<()>; + + /// `PATCH /v1/appScreenshots/{id}` with `uploaded: true` — tells App + /// Store Connect the binary is fully transferred so it can start + /// processing/validating the asset. + async fn commit_app_screenshot( + &self, + id: &str, + source_file_checksum: String, + ) -> Result; + + /// `DELETE /v1/appScreenshots/{id}`. + async fn delete_app_screenshot(&self, id: &str) -> Result<()>; + + /// Reserve, upload, and commit an image in one call: `bytes` becomes an + /// `AppScreenshot` under `app_screenshot_set_id`, named `file_name`. + /// This is the entry point most callers want; the lower-level + /// `create_app_screenshot` / `upload_app_screenshot_bytes` / + /// `commit_app_screenshot` are exposed for callers that need to + /// checkpoint between steps (e.g. a CLI resuming a failed upload). + async fn upload_app_screenshot( + &self, + app_screenshot_set_id: &str, + file_name: String, + bytes: Vec, + ) -> Result; +} + +#[async_trait] +impl AppScreenshotsApi for Client { + async fn list_app_screenshots( &self, app_screenshot_set_id: &str, ) -> Result> { @@ -117,10 +176,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/appScreenshots` — reserves the asset and returns - /// pre-signed `uploadOperations`. Prefer [`Client::upload_app_screenshot`] - /// unless you need to drive the upload/commit steps yourself. - pub async fn create_app_screenshot( + async fn create_app_screenshot( &self, app_screenshot_set_id: &str, attributes: AppScreenshotCreateAttributes, @@ -145,12 +201,7 @@ impl Client { Ok(doc.data) } - /// PUTs `bytes` to every one of `screenshot`'s reserved - /// `uploadOperations`, slicing the buffer per operation's `offset` / - /// `length`. Does not commit the upload — call - /// [`Client::commit_app_screenshot`] (or use - /// [`Client::upload_app_screenshot`]) after this succeeds. - pub async fn upload_app_screenshot_bytes( + async fn upload_app_screenshot_bytes( &self, screenshot: &AppScreenshot, bytes: &[u8], @@ -173,10 +224,7 @@ impl Client { Ok(()) } - /// `PATCH /v1/appScreenshots/{id}` with `uploaded: true` — tells App - /// Store Connect the binary is fully transferred so it can start - /// processing/validating the asset. - pub async fn commit_app_screenshot( + async fn commit_app_screenshot( &self, id: &str, source_file_checksum: String, @@ -197,20 +245,13 @@ impl Client { Ok(doc.data) } - /// `DELETE /v1/appScreenshots/{id}`. - pub async fn delete_app_screenshot(&self, id: &str) -> Result<()> { + async fn delete_app_screenshot(&self, id: &str) -> Result<()> { let path = format!("/v1/appScreenshots/{id}"); self.request_no_content::<()>(Method::DELETE, &path, &[], None) .await } - /// Reserve, upload, and commit an image in one call: `bytes` becomes an - /// `AppScreenshot` under `app_screenshot_set_id`, named `file_name`. - /// This is the entry point most callers want; the lower-level - /// `create_app_screenshot` / `upload_app_screenshot_bytes` / - /// `commit_app_screenshot` are exposed for callers that need to - /// checkpoint between steps (e.g. a CLI resuming a failed upload). - pub async fn upload_app_screenshot( + async fn upload_app_screenshot( &self, app_screenshot_set_id: &str, file_name: String, diff --git a/crates/ascapi/src/app_screenshot_set.rs b/crates/aso/src/app_screenshot_set.rs similarity index 74% rename from crates/ascapi/src/app_screenshot_set.rs rename to crates/aso/src/app_screenshot_set.rs index 24126bb..efd91c7 100644 --- a/crates/ascapi/src/app_screenshot_set.rs +++ b/crates/aso/src/app_screenshot_set.rs @@ -3,11 +3,14 @@ //! `AppStoreVersion`. Screenshots themselves are a separate resource //! ([`crate::app_screenshot`]) that belongs to a set. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, +}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appScreenshotSets"; @@ -81,9 +84,37 @@ pub struct AppScreenshotSetRelationships { pub app_store_version_localization: ToOne, } -impl Client { +/// Per-device-class screenshot buckets. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppScreenshotSetsApi { /// `GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets`. - pub async fn list_app_screenshot_sets( + async fn list_app_screenshot_sets( + &self, + app_store_version_localization_id: &str, + ) -> Result>; + + /// `POST /v1/appScreenshotSets` — adds a device/display class's + /// screenshot bucket to a localization. Screenshots are then added to + /// the returned set with [`AppScreenshotsApi::create_app_screenshot`](crate::app_screenshot::AppScreenshotsApi::create_app_screenshot) / + /// [`AppScreenshotsApi::upload_app_screenshot`](crate::app_screenshot::AppScreenshotsApi::upload_app_screenshot). + async fn create_app_screenshot_set( + &self, + app_store_version_localization_id: &str, + attributes: AppScreenshotSetCreateAttributes, + ) -> Result; + + /// `DELETE /v1/appScreenshotSets/{id}`. + async fn delete_app_screenshot_set(&self, id: &str) -> Result<()>; +} + +#[async_trait] +impl AppScreenshotSetsApi for Client { + async fn list_app_screenshot_sets( &self, app_store_version_localization_id: &str, ) -> Result> { @@ -95,11 +126,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/appScreenshotSets` — adds a device/display class's - /// screenshot bucket to a localization. Screenshots are then added to - /// the returned set with [`Client::create_app_screenshot`] / - /// [`Client::upload_app_screenshot`]. - pub async fn create_app_screenshot_set( + async fn create_app_screenshot_set( &self, app_store_version_localization_id: &str, attributes: AppScreenshotSetCreateAttributes, @@ -124,8 +151,7 @@ impl Client { Ok(doc.data) } - /// `DELETE /v1/appScreenshotSets/{id}`. - pub async fn delete_app_screenshot_set(&self, id: &str) -> Result<()> { + async fn delete_app_screenshot_set(&self, id: &str) -> Result<()> { let path = format!("/v1/appScreenshotSets/{id}"); self.request_no_content::<()>(Method::DELETE, &path, &[], None) .await diff --git a/crates/ascapi/src/app_store_version.rs b/crates/aso/src/app_store_version.rs similarity index 76% rename from crates/ascapi/src/app_store_version.rs rename to crates/aso/src/app_store_version.rs index 8d328b5..9180884 100644 --- a/crates/ascapi/src/app_store_version.rs +++ b/crates/aso/src/app_store_version.rs @@ -5,14 +5,15 @@ //! doesn't have one for yet — e.g. adding visionOS to an app that so far //! only ships on iOS/macOS — *is* how you add that platform. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{ +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateRelationshipsBody, UpdateRelationshipsData, }; -use reqwest::Method; -use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appStoreVersions"; @@ -67,10 +68,51 @@ pub struct AppStoreVersionRelationships { pub app: ToOne, } -impl Client { +/// Per-platform version records, the unit the store reviews. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppStoreVersionsApi { /// `GET /v1/apps/{app_id}/appStoreVersions`, optionally narrowed to one /// platform. - pub async fn list_app_store_versions( + async fn list_app_store_versions( + &self, + app_id: &str, + filter_platform: Option, + ) -> Result>; + + async fn get_app_store_version(&self, id: &str) -> Result; + + /// `POST /v1/appStoreVersions`. + async fn create_app_store_version( + &self, + app_id: &str, + attributes: AppStoreVersionCreateAttributes, + ) -> Result; + + /// `DELETE /v1/appStoreVersions/{id}` — only allowed while the version + /// hasn't been submitted for review. + async fn delete_app_store_version(&self, id: &str) -> Result<()>; + + /// `PATCH /v1/appStoreVersions/{id}` with a `build` relationship — + /// attaches (or replaces) the `Build` this version will submit. Needed + /// after a re-upload fixes an `INVALID_BINARY` version: the fixed + /// build must be attached here before the version can be resubmitted + /// for review (resubmission itself is outside this crate's scope — see + /// [`crate::build`]'s module doc). + async fn set_app_store_version_build( + &self, + id: &str, + build_id: &str, + ) -> Result; +} + +#[async_trait] +impl AppStoreVersionsApi for Client { + async fn list_app_store_versions( &self, app_id: &str, filter_platform: Option, @@ -86,15 +128,14 @@ impl Client { Ok(doc.data) } - pub async fn get_app_store_version(&self, id: &str) -> Result { + async fn get_app_store_version(&self, id: &str) -> Result { let path = format!("/v1/appStoreVersions/{id}"); let doc: Document = self.request(Method::GET, &path, &[], None::<&()>).await?; Ok(doc.data) } - /// `POST /v1/appStoreVersions`. - pub async fn create_app_store_version( + async fn create_app_store_version( &self, app_id: &str, attributes: AppStoreVersionCreateAttributes, @@ -119,21 +160,13 @@ impl Client { Ok(doc.data) } - /// `DELETE /v1/appStoreVersions/{id}` — only allowed while the version - /// hasn't been submitted for review. - pub async fn delete_app_store_version(&self, id: &str) -> Result<()> { + async fn delete_app_store_version(&self, id: &str) -> Result<()> { let path = format!("/v1/appStoreVersions/{id}"); self.request_no_content::<()>(Method::DELETE, &path, &[], None) .await } - /// `PATCH /v1/appStoreVersions/{id}` with a `build` relationship — - /// attaches (or replaces) the `Build` this version will submit. Needed - /// after a re-upload fixes an `INVALID_BINARY` version: the fixed - /// build must be attached here before the version can be resubmitted - /// for review (resubmission itself is outside this crate's scope — see - /// [`crate::build`]'s module doc). - pub async fn set_app_store_version_build( + async fn set_app_store_version_build( &self, id: &str, build_id: &str, @@ -159,6 +192,9 @@ impl Client { } } +/// The `relationships` payload for pointing an App Store Version at a +/// build. Its own type because App Store Connect wants a PATCH body that +/// carries relationships and no attributes. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AppStoreVersionBuildRelationship { diff --git a/crates/ascapi/src/app_store_version_localization.rs b/crates/aso/src/app_store_version_localization.rs similarity index 73% rename from crates/ascapi/src/app_store_version_localization.rs rename to crates/aso/src/app_store_version_localization.rs index 57646a3..f3644ab 100644 --- a/crates/ascapi/src/app_store_version_localization.rs +++ b/crates/aso/src/app_store_version_localization.rs @@ -1,11 +1,12 @@ -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{ +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody, UpdateData, }; -use reqwest::Method; -use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "appStoreVersionLocalizations"; @@ -54,8 +55,39 @@ pub struct AppStoreVersionLocalizationRelationships { pub app_store_version: ToOne, } -impl Client { - pub async fn list_app_store_version_localizations( +/// Per-locale description, keywords, and release notes. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppStoreVersionLocalizationsApi { + async fn list_app_store_version_localizations( + &self, + app_store_version_id: &str, + ) -> Result>; + + /// `POST /v1/appStoreVersionLocalizations` — adds a locale's + /// description/keywords/etc to an `AppStoreVersion`. + async fn create_app_store_version_localization( + &self, + app_store_version_id: &str, + attributes: AppStoreVersionLocalizationCreateAttributes, + ) -> Result; + + async fn update_app_store_version_localization( + &self, + id: &str, + fields: AppStoreVersionLocalizationFields, + ) -> Result; + + async fn delete_app_store_version_localization(&self, id: &str) -> Result<()>; +} + +#[async_trait] +impl AppStoreVersionLocalizationsApi for Client { + async fn list_app_store_version_localizations( &self, app_store_version_id: &str, ) -> Result> { @@ -66,9 +98,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/appStoreVersionLocalizations` — adds a locale's - /// description/keywords/etc to an `AppStoreVersion`. - pub async fn create_app_store_version_localization( + async fn create_app_store_version_localization( &self, app_store_version_id: &str, attributes: AppStoreVersionLocalizationCreateAttributes, @@ -98,7 +128,7 @@ impl Client { Ok(doc.data) } - pub async fn update_app_store_version_localization( + async fn update_app_store_version_localization( &self, id: &str, fields: AppStoreVersionLocalizationFields, @@ -116,7 +146,7 @@ impl Client { Ok(doc.data) } - pub async fn delete_app_store_version_localization(&self, id: &str) -> Result<()> { + async fn delete_app_store_version_localization(&self, id: &str) -> Result<()> { let path = format!("/v1/appStoreVersionLocalizations/{id}"); self.request_no_content::<()>(Method::DELETE, &path, &[], None) .await diff --git a/crates/ascapi/src/build.rs b/crates/aso/src/build.rs similarity index 71% rename from crates/ascapi/src/build.rs rename to crates/aso/src/build.rs index 243b396..d630d33 100644 --- a/crates/ascapi/src/build.rs +++ b/crates/aso/src/build.rs @@ -7,11 +7,12 @@ //! `AppStoreVersion`'s `appVersionState` (which only changes once a build is //! attached to the version and resubmitted — outside this crate's scope). -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{ListDocument, Resource}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ListDocument, Resource}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "builds"; @@ -27,13 +28,25 @@ pub struct BuildAttributes { pub type Build = Resource; -impl Client { +/// Uploaded builds, which a version must have attached. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BuildsApi { /// `GET /v1/apps/{app_id}/builds`, sorted newest-first by /// `uploadedDate` (client-side — this endpoint's `sort` query parameter /// is rejected by the API, unlike most other list endpoints), so the /// build most recently uploaded (e.g. by a fixplist re-upload) is /// `list_builds(app_id).await?.first()`. - pub async fn list_builds(&self, app_id: &str) -> Result> { + async fn list_builds(&self, app_id: &str) -> Result>; +} + +#[async_trait] +impl BuildsApi for Client { + async fn list_builds(&self, app_id: &str) -> Result> { let path = format!("/v1/apps/{app_id}/builds"); let doc: ListDocument = self.request(Method::GET, &path, &[], None::<&()>).await?; diff --git a/crates/ascapi/src/bundle_id.rs b/crates/aso/src/bundle_id.rs similarity index 71% rename from crates/ascapi/src/bundle_id.rs rename to crates/aso/src/bundle_id.rs index 1086c52..b096233 100644 --- a/crates/ascapi/src/bundle_id.rs +++ b/crates/aso/src/bundle_id.rs @@ -1,8 +1,9 @@ -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "bundleIds"; @@ -42,12 +43,28 @@ pub struct BundleIdCreateAttributes { pub platform: BundleIdPlatform, } -impl Client { +/// Registered reverse-DNS identifiers. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BundleIdsApi { /// `GET /v1/bundleIds`, optionally filtered to an exact identifier — /// check whether a bundle ID is already registered before trying to /// create it, or before creating an App Store Version under an app /// that uses it. - pub async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result> { + async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result>; + + /// `POST /v1/bundleIds` — registers a new bundle ID with the developer + /// account (Certificates, Identifiers & Profiles). + async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result; +} + +#[async_trait] +impl BundleIdsApi for Client { + async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result> { let mut query = Vec::new(); if let Some(identifier) = filter_identifier { query.push(("filter[identifier]", identifier)); @@ -58,9 +75,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/bundleIds` — registers a new bundle ID with the developer - /// account (Certificates, Identifiers & Profiles). - pub async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result { + async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result { let body = CreateBody { data: CreateData { resource_type: RESOURCE_TYPE, diff --git a/crates/aso/src/lib.rs b/crates/aso/src/lib.rs new file mode 100644 index 0000000..4e4df9d --- /dev/null +++ b/crates/aso/src/lib.rs @@ -0,0 +1,44 @@ +//! App Store Connect's +//! [App Metadata](https://developer.apple.com/documentation/appstoreconnectapi/app-metadata) +//! resources: apps, app infos and their localizations, app store versions +//! and their localizations, builds, bundle IDs, and screenshots. +//! +//! Everything here is an extension trait on +//! [`Client`](smbcloud_ascapi_core::Client), because that type belongs to +//! the core crate and Rust only allows inherent impls in the crate that +//! defines a type. Import [`prelude`] to get all of them at once. +//! +//! ```no_run +//! use smbcloud_ascapi_core::{ApiKey, Client}; +//! use smbcloud_ascapi_aso::prelude::*; +//! +//! # async fn example() -> smbcloud_ascapi_core::Result<()> { +//! let api_key = ApiKey::from_p8_file("L84N624YQH", "b4e8d369-…", "AuthKey.p8")?; +//! let client = Client::new(api_key); +//! let apps = client.list_apps(Some("xyz.smbcloud.mailx")).await?; +//! # Ok(()) +//! # } +//! ``` + +pub mod app; +pub mod app_info; +pub mod app_info_localization; +pub mod app_screenshot; +pub mod app_screenshot_set; +pub mod app_store_version; +pub mod app_store_version_localization; +pub mod build; +pub mod bundle_id; + +/// Every extension trait in this crate, for one glob import. +pub mod prelude { + pub use crate::app::AppsApi; + pub use crate::app_info::AppInfosApi; + pub use crate::app_info_localization::AppInfoLocalizationsApi; + pub use crate::app_screenshot::AppScreenshotsApi; + pub use crate::app_screenshot_set::AppScreenshotSetsApi; + pub use crate::app_store_version::AppStoreVersionsApi; + pub use crate::app_store_version_localization::AppStoreVersionLocalizationsApi; + pub use crate::build::BuildsApi; + pub use crate::bundle_id::BundleIdsApi; +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1f4f698..17bd5d9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,8 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "CLI for adding/updating App Store Connect app metadata, built on smbcloud-ascapi." +description = "CLI and MCP server for App Store Connect: issue Apple signing certificates and manage app metadata." +readme = "README.md" [[bin]] name = "ascapi" @@ -17,5 +18,9 @@ base64 = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } serde = { workspace = true } serde_json = { workspace = true } -smbcloud-ascapi = { workspace = true } +smbcloud-ascapi-aso = { workspace = true } +smbcloud-ascapi-core = { workspace = true } +smbcloud-ascapi-signing = { workspace = true } +smbcloud-ascapi-frontend = { workspace = true } +smbcloud-ascapi-mcp = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/cli/README.md b/crates/cli/README.md new file mode 100644 index 0000000..78e12a3 --- /dev/null +++ b/crates/cli/README.md @@ -0,0 +1,84 @@ +# App Store Connect Signing + +`ascapi` issues and inspects Apple signing certificates from the terminal +or as an MCP server, without a trip to the developer portal. It also +covers App Store Connect's App Metadata surface: apps, app infos, app +store versions, bundle IDs, screenshots, and their localizations. + +MCP Registry name: `mcp-name: io.github.smbcloudXYZ/ascapi` + +## Install + +```bash +cargo install smbcloud-ascapi-cli +``` + +## Credentials + +All three come from App Store Connect → Users and Access → Integrations → +App Store Connect API: + +```bash +export ASC_API_KEY= +export ASC_ISSUER_ID= +# Optional. Defaults to ~/.appstoreconnect/private_keys/AuthKey_.p8 +export ASC_PRIVATE_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8 +``` + +## Signing certificates + +```bash +ascapi certificates list --type distribution +ascapi certificates create --type mac-installer-distribution --out-dir ~/certs +``` + +`create` generates an RSA 2048 key pair locally, sends Apple only a +signing request, and writes both halves to `--out-dir`. Two things follow +from that and are worth knowing before you run it: + +- **Apple never has your private key.** A certificate whose key file is + lost is permanently unusable, and no re-download recovers it. Back up + the output directory somewhere encrypted. +- **An expired certificate cannot be renewed.** There is no such + operation; you issue a new one, and every provisioning profile that + embedded the old certificate has to be regenerated. + +The private key is written before the request is sent, so a failure +mid-flight leaves an unused key rather than a certificate whose key was +never saved. + +## MCP server + +```bash +ascapi --mcp +``` + +Speaks MCP over stdio and exposes 28 tools: every operation the command +line has except `certificates revoke`. That covers apps, bundle IDs, app +store versions, both kinds of localization, screenshot sets, screenshot +upload, and certificates. + +Twelve are read-only. Five delete something and are annotated +`destructiveHint`, so a client can gate them: version, localization, +screenshot set, and screenshot deletes, all of which can be recreated by +re-running the tool that made them. + +Credentials resolve per call, so the server starts and answers +`tools/list` even when unconfigured, then fails with a message naming what +is missing. + +Two deliberate absences: + +- **No revocation tool.** Revoking a signing certificate invalidates every + provisioning profile embedding it, for every teammate and every CI job, + at once and irreversibly. That is the one delete no confirmation string a + model types on your behalf makes safe, so `ascapi certificates revoke` + stays on the command line, where a human is the one typing. A test fails + the build if it ever appears in the tool list. +- **No key material in tool results.** `certificate_create` returns the + path it wrote the key to, never the key itself, because tool results are + read by a model and end up in transcripts. + +## Copyright + +© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)). diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 91d4000..2a227c9 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,20 +1,22 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; -use smbcloud_ascapi::app::AppUpdateAttributes; -use smbcloud_ascapi::app_info_localization::{ +use smbcloud_ascapi_aso::app::AppUpdateAttributes; +use smbcloud_ascapi_aso::app_info_localization::{ AppInfoLocalizationCreateAttributes, AppInfoLocalizationUpdateAttributes, }; -use smbcloud_ascapi::app_screenshot_set::{ +use smbcloud_ascapi_aso::app_screenshot_set::{ AppScreenshotSetCreateAttributes, ScreenshotDisplayType, }; -use smbcloud_ascapi::app_store_version::{AppStoreVersionCreateAttributes, Platform}; -use smbcloud_ascapi::app_store_version_localization::{ +use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Platform}; +use smbcloud_ascapi_aso::app_store_version_localization::{ AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields, }; -use smbcloud_ascapi::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform}; -use smbcloud_ascapi::certificate::{CertificateCreateAttributes, CertificateType}; -use smbcloud_ascapi::csr::generate_certificate_request; -use smbcloud_ascapi::{ApiKey, Client}; +use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform}; +use smbcloud_ascapi_aso::prelude::*; +use smbcloud_ascapi_core::{ApiKey, Client}; +use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType}; +use smbcloud_ascapi_signing::csr::generate_certificate_request; +use smbcloud_ascapi_signing::prelude::*; use std::path::PathBuf; /// Add/update App Store Connect app metadata (apps, app infos, app store @@ -23,13 +25,22 @@ use std::path::PathBuf; #[command(name = "ascapi", version, about)] struct Cli { /// App Store Connect API key ID (Users and Access → Integrations → App - /// Store Connect API). - #[arg(long, env = "ASC_API_KEY")] - key_id: String, + /// Store Connect API). Not required with `--mcp`, which resolves + /// credentials per tool call so an unconfigured server can still list + /// its tools. + #[arg(long, env = "ASC_API_KEY", required_unless_present = "mcp")] + key_id: Option, /// App Store Connect API issuer ID (same page as the key). - #[arg(long, env = "ASC_ISSUER_ID")] - issuer_id: String, + #[arg(long, env = "ASC_ISSUER_ID", required_unless_present = "mcp")] + issuer_id: Option, + + /// Run as an MCP server over stdio instead of executing a subcommand. + /// + /// stdout carries the JSON-RPC stream in this mode, so nothing else + /// may be written to it. + #[arg(long)] + mcp: bool, /// Path to the key's .p8 private key file. Defaults to /// `~/.appstoreconnect/private_keys/AuthKey_.p8`, matching @@ -43,7 +54,7 @@ struct Cli { dry_run: bool, #[command(subcommand)] - command: Command, + command: Option, } #[derive(Subcommand)] @@ -441,19 +452,38 @@ fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result { async fn main() -> Result<()> { let cli = Cli::parse(); + if cli.mcp { + return smbcloud_ascapi_mcp::serve().await; + } + + let key_id = cli + .key_id + .clone() + .context("--key-id is required (or set ASC_API_KEY)")?; + let issuer_id = cli + .issuer_id + .clone() + .context("--issuer-id is required (or set ASC_ISSUER_ID)")?; + let private_key_path = cli.private_key_path.clone().unwrap_or_else(|| { let mut path = dirs_home(); path.push(".appstoreconnect"); path.push("private_keys"); - path.push(format!("AuthKey_{}.p8", cli.key_id)); + path.push(format!("AuthKey_{key_id}.p8")); path }); - let api_key = ApiKey::from_p8_file(&cli.key_id, &cli.issuer_id, &private_key_path) + let api_key = ApiKey::from_p8_file(&key_id, &issuer_id, &private_key_path) .with_context(|| format!("loading App Store Connect API key from {private_key_path:?}"))?; let client = Client::new(api_key); - match cli.command { + let Some(command) = cli.command else { + anyhow::bail!( + "no subcommand given; run `ascapi --help`, or `ascapi --mcp` for the MCP server" + ) + }; + + match command { Command::Apps { command } => run_apps(&client, command, cli.dry_run).await, Command::BundleIds { command } => run_bundle_ids(&client, command, cli.dry_run).await, Command::AppStoreVersions { command } => { @@ -819,7 +849,9 @@ struct CertificateSummary { expired: Option, } -fn summarize(certificate: &smbcloud_ascapi::certificate::Certificate) -> CertificateSummary { +fn summarize( + certificate: &smbcloud_ascapi_signing::certificate::Certificate, +) -> CertificateSummary { let expiration_date = certificate.attributes.expiration_date.clone(); // Lexicographic comparison is sound for ISO-8601 UTC timestamps, which // is what Apple returns, and avoids a date-parsing dependency for a diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..d3ba34e --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "smbcloud-ascapi-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared transport for the App Store Connect API: JWT auth, the HTTP client, JSON:API envelopes, and error types." + +[lib] +path = "src/lib.rs" + +[dependencies] +jsonwebtoken = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/ascapi/src/auth.rs b/crates/core/src/auth.rs similarity index 100% rename from crates/ascapi/src/auth.rs rename to crates/core/src/auth.rs diff --git a/crates/ascapi/src/client.rs b/crates/core/src/client.rs similarity index 89% rename from crates/ascapi/src/client.rs rename to crates/core/src/client.rs index 5c88817..f058fe1 100644 --- a/crates/ascapi/src/client.rs +++ b/crates/core/src/client.rs @@ -20,9 +20,14 @@ struct CachedToken { /// Thin async HTTP client for the App Store Connect API. Handles JWT minting /// (cached and refreshed automatically) and JSON:API request/response -/// plumbing. Resource-specific calls (apps, app store versions, bundle IDs, -/// ...) are implemented as additional `impl Client` blocks alongside each -/// resource's types, in their own modules. +/// plumbing. +/// +/// Resource-specific calls live in the domain crates as extension traits on +/// this type: `smbcloud-ascapi-aso` for App Metadata, `smbcloud-ascapi-signing` +/// for certificates. [`Client::request`], [`Client::request_no_content`], and +/// [`Client::upload_bytes`] are the low-level seam those traits build on, and +/// are public for that reason rather than because callers should reach for +/// them directly. pub struct Client { http: reqwest::Client, api_key: ApiKey, @@ -71,7 +76,7 @@ impl Client { /// Send a request and decode a JSON:API response body into `T`. Use /// `request_no_content` instead for calls (typically `DELETE`) that /// return an empty `204` body. - pub(crate) async fn request( + pub async fn request( &self, method: Method, path: &str, @@ -86,7 +91,7 @@ impl Client { } /// Send a request that returns no body on success (typically `DELETE`). - pub(crate) async fn request_no_content( + pub async fn request_no_content( &self, method: Method, path: &str, @@ -129,7 +134,7 @@ impl Client { /// upload URLs are pre-signed and carry their own auth in /// `request_headers`; sending our JWT alongside would be wrong for a /// host that isn't `api.appstoreconnect.apple.com`. - pub(crate) async fn upload_bytes( + pub async fn upload_bytes( &self, method: Method, url: &str, diff --git a/crates/ascapi/src/error.rs b/crates/core/src/error.rs similarity index 100% rename from crates/ascapi/src/error.rs rename to crates/core/src/error.rs diff --git a/crates/ascapi/src/jsonapi.rs b/crates/core/src/jsonapi.rs similarity index 100% rename from crates/ascapi/src/jsonapi.rs rename to crates/core/src/jsonapi.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..4d76726 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,23 @@ +//! Shared transport for the App Store Connect API. +//! +//! Everything the domain crates need and nothing they disagree about: the +//! ES256 JWT auth, the HTTP client that caches those tokens, the JSON:API +//! envelope generics, and the error type. +//! +//! The domain crates ([`smbcloud-ascapi-aso`] for App Metadata, +//! [`smbcloud-ascapi-signing`] for certificates) add their resources as +//! extension traits on [`Client`], since Rust only allows inherent impls +//! in the crate that defines a type. Import a domain crate's `prelude` to +//! call its methods on a client. +//! +//! [`smbcloud-ascapi-aso`]: https://docs.rs/smbcloud-ascapi-aso +//! [`smbcloud-ascapi-signing`]: https://docs.rs/smbcloud-ascapi-signing + +pub mod auth; +pub mod client; +pub mod error; +pub mod jsonapi; + +pub use auth::ApiKey; +pub use client::Client; +pub use error::{Error, Result}; diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml new file mode 100644 index 0000000..d048b10 --- /dev/null +++ b/crates/frontend/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "smbcloud-ascapi-frontend" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared operations behind the ascapi command line and MCP surfaces: credential resolution, result shaping, and certificate issuance." + +[lib] +path = "src/lib.rs" + +[dependencies] +base64 = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +smbcloud-ascapi-aso = { workspace = true } +smbcloud-ascapi-core = { workspace = true } +smbcloud-ascapi-signing = { workspace = true } diff --git a/crates/frontend/src/certificates.rs b/crates/frontend/src/certificates.rs new file mode 100644 index 0000000..16308b4 --- /dev/null +++ b/crates/frontend/src/certificates.rs @@ -0,0 +1,247 @@ +//! Certificate operations shared by both front ends. + +use serde::Serialize; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_signing::certificate::CertificateType; +use smbcloud_ascapi_signing::prelude::*; + +use crate::time::{is_expired, now_iso8601}; + +/// Map a CLI/MCP certificate-type string onto the API enum. +pub fn certificate_type_from_str(value: &str) -> Result { + use CertificateType; + match value { + "development" => Ok(CertificateType::Development), + "distribution" => Ok(CertificateType::Distribution), + "mac_app_distribution" => Ok(CertificateType::MacAppDistribution), + "mac_installer_distribution" => Ok(CertificateType::MacInstallerDistribution), + "developer_id_application" => Ok(CertificateType::DeveloperIdApplication), + other => Err(format!( + "unknown certificate type {other:?}; expected one of development, distribution, \ + mac_app_distribution, mac_installer_distribution, developer_id_application" + )), + } +} + +/// Defines the MCP tool implementations on `$server`, with each tool's +/// wire name supplied by the embedder. +/// +/// Names are parameters rather than literals so a host that re-exposes +/// this contract can namespace them without forking the bodies, exactly as +/// `xcrs_mcp_tools!` allows in `smbcloud-cli`. +/// A certificate as reported to callers. +/// +/// Note what is missing: `certificateContent`. It is large, it is never +/// needed to decide what to do next, and keeping it out means a tool +/// result can never carry a certificate body into a model's context. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct CertificateSummary { + pub id: String, + pub name: Option, + pub certificate_type: Option, + pub serial_number: Option, + pub expiration_date: Option, + /// Computed locally: App Store Connect offers no filter for it and + /// returns expired certificates alongside valid ones. + pub expired: Option, +} + +impl CertificateSummary { + pub fn from_resource( + certificate: &smbcloud_ascapi_signing::certificate::Certificate, + now: &str, + ) -> Self { + let expiration_date = certificate.attributes.expiration_date.clone(); + let expired = expiration_date + .as_ref() + .map(|at| is_expired(at.as_str(), now)); + + Self { + id: certificate.id.clone(), + name: certificate.attributes.name.clone(), + certificate_type: certificate + .attributes + .certificate_type + .map(|t| t.as_api_str().to_string()), + serial_number: certificate.attributes.serial_number.clone(), + expiration_date, + expired, + } + } +} + +/// The result of issuing a certificate. +/// +/// Carries paths, never key material. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct IssuedCertificate { + pub certificate: CertificateSummary, + /// Path the private key was written to. The key itself is never + /// included: this is the whole point of returning a path. + pub private_key_path: String, + pub certificate_path: String, + /// Copy-pasteable commands to get the pair into a keychain. + pub next_steps: Vec, +} + +/// Generate a key pair, have Apple certify it, and write both halves. +/// +/// Shared by the CLI and the MCP tool so the ordering guarantee (key +/// written before the request is sent) has exactly one implementation. +pub async fn issue_certificate( + client: &Client, + certificate_type: CertificateType, + common_name: &str, + out_dir: &std::path::Path, +) -> Result { + use smbcloud_ascapi_signing::certificate::CertificateCreateAttributes; + + let request = smbcloud_ascapi_signing::csr::generate_certificate_request(common_name) + .map_err(|error| format!("generating the RSA 2048 key pair and CSR: {error}"))?; + + std::fs::create_dir_all(out_dir) + .map_err(|error| format!("creating {}: {error}", out_dir.display()))?; + + let stem = certificate_type.as_api_str().to_lowercase(); + let key_path = out_dir.join(format!("{stem}.key.pem")); + + // Before the network call, deliberately. A failure after Apple has + // issued the certificate would leave a certificate whose key was never + // persisted, which is unrecoverable and has consumed one of the team's + // limited slots. A failure here leaves an unused key, which costs + // nothing. + write_private_key(&key_path, request.private_key_pem()) + .map_err(|error| format!("writing the private key to {}: {error}", key_path.display()))?; + + let certificate = client + .create_certificate(CertificateCreateAttributes { + csr_content: request.csr_pem().to_string(), + certificate_type, + }) + .await + .map_err(|error| error.to_string())?; + + let cer_path = out_dir.join(format!("{stem}.cer")); + if let Some(content) = certificate.attributes.certificate_content.as_deref() { + let der = base64_decode(content)?; + std::fs::write(&cer_path, der) + .map_err(|error| format!("writing {}: {error}", cer_path.display()))?; + } + + Ok(IssuedCertificate { + certificate: CertificateSummary::from_resource(&certificate, &now_iso8601()), + private_key_path: key_path.display().to_string(), + certificate_path: cer_path.display().to_string(), + next_steps: vec![ + format!( + "openssl pkcs12 -export -inkey {} -in {} -out {stem}.p12", + key_path.display(), + cer_path.display() + ), + format!( + "security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign" + ), + "security find-identity -v -p codesigning".to_string(), + ], + }) +} + +/// Write a private key with owner-only permissions, set at creation time +/// rather than chmod'ed afterwards so the key is never briefly readable by +/// anyone else. +pub fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(pem.as_bytes())?; + file.sync_all() +} + +fn base64_decode(input: &str) -> Result, String> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(input.trim()) + .map_err(|error| format!("decoding the certificate Apple returned: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expiry_comparison_matches_calendar_order() { + assert!(is_expired("2026-08-05T20:56:53", "2026-08-06T04:32:19")); + assert!(!is_expired("2027-02-01T22:12:15", "2026-08-06T04:32:19")); + // Same day, hours apart: the case that decides whether a + // certificate expiring later today reads as already dead. + assert!(!is_expired("2026-08-06T04:37:57", "2026-08-06T04:32:19")); + assert!(is_expired("2026-08-06T04:31:00", "2026-08-06T04:32:19")); + } + + #[test] + fn now_is_a_fixed_width_sortable_timestamp() { + let now = now_iso8601(); + assert_eq!(now.len(), 19, "expected YYYY-MM-DDTHH:MM:SS, got {now:?}"); + assert_eq!(&now[4..5], "-"); + assert_eq!(&now[10..11], "T"); + // Sanity bound: a wildly wrong civil-date conversion would land + // outside this window and silently mark every certificate expired. + assert!( + now.as_str() > "2024-01-01T00:00:00", + "clock looks wrong: {now}" + ); + assert!( + now.as_str() < "2100-01-01T00:00:00", + "clock looks wrong: {now}" + ); + } + + #[test] + fn certificate_type_strings_are_the_documented_set() { + for value in [ + "development", + "distribution", + "mac_app_distribution", + "mac_installer_distribution", + "developer_id_application", + ] { + certificate_type_from_str(value) + .unwrap_or_else(|_| panic!("{value} should be accepted")); + } + let error = certificate_type_from_str("apple_distribution").expect_err("should reject"); + // The error has to list the valid values: a model that guessed + // wrong needs to correct itself without another round trip. + assert!(error.contains("distribution"), "unhelpful error: {error}"); + } + + #[test] + fn issued_certificate_never_serializes_key_material() { + let issued = IssuedCertificate { + certificate: CertificateSummary { + id: "ABC123".to_string(), + name: Some("Apple Distribution: Example".to_string()), + certificate_type: Some("DISTRIBUTION".to_string()), + serial_number: Some("1A2B3C".to_string()), + expiration_date: Some("2027-08-06T00:00:00".to_string()), + expired: Some(false), + }, + private_key_path: "/tmp/out/distribution.key.pem".to_string(), + certificate_path: "/tmp/out/distribution.cer".to_string(), + next_steps: vec!["security find-identity -v -p codesigning".to_string()], + }; + + let json = serde_json::to_string(&issued).expect("serializes"); + assert!(!json.contains("BEGIN PRIVATE KEY")); + assert!(!json.contains("BEGIN CERTIFICATE")); + // The path is the point: it tells the caller where to look without + // putting the secret into a transcript. + assert!(json.contains("distribution.key.pem")); + } +} diff --git a/crates/frontend/src/enums.rs b/crates/frontend/src/enums.rs new file mode 100644 index 0000000..dfa8245 --- /dev/null +++ b/crates/frontend/src/enums.rs @@ -0,0 +1,86 @@ +//! String to enum mapping for values that arrive as text from a command +//! line or a tool call. +//! +//! Each mapper's error lists the accepted values. That matters more for +//! the MCP surface than the CLI one: clap rejects a bad value before the +//! program runs, but a model that guessed wrong only learns from the +//! error text, and a bare "invalid value" costs it another round trip. + +use smbcloud_ascapi_aso::app_screenshot_set::ScreenshotDisplayType; +use smbcloud_ascapi_aso::app_store_version::Platform; +use smbcloud_ascapi_aso::bundle_id::BundleIdPlatform; + +pub fn platform_from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "ios" => Ok(Platform::Ios), + "mac_os" | "macos" => Ok(Platform::MacOs), + "tv_os" | "tvos" => Ok(Platform::TvOs), + "vision_os" | "visionos" => Ok(Platform::VisionOs), + other => Err(format!( + "unknown platform {other:?}; expected one of ios, mac_os, tv_os, vision_os" + )), + } +} + +pub fn bundle_id_platform_from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + // visionOS shares the iOS identifier namespace, so there is no + // separate value to offer here. + "ios" => Ok(BundleIdPlatform::Ios), + "mac_os" | "macos" => Ok(BundleIdPlatform::MacOs), + "universal" => Ok(BundleIdPlatform::Universal), + other => Err(format!( + "unknown bundle ID platform {other:?}; expected one of ios, mac_os, universal" + )), + } +} + +/// App Store Connect's display types are already screaming snake case, so +/// this accepts them verbatim and only normalizes the casing. +pub fn display_type_from_str(value: &str) -> Result { + let upper = value.to_ascii_uppercase(); + serde_json::from_value::(serde_json::Value::String(upper.clone())) + .map_err(|_| { + format!( + "unknown screenshot display type {value:?}; expected an App Store Connect \ + display type such as APP_IPHONE_67, APP_IPAD_PRO_129, APP_APPLE_VISION_PRO, \ + or APP_DESKTOP" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_accepts_both_spellings() { + assert_eq!(platform_from_str("visionos").unwrap(), Platform::VisionOs); + assert_eq!(platform_from_str("VISION_OS").unwrap(), Platform::VisionOs); + } + + #[test] + fn errors_name_the_accepted_values() { + // A model that guessed wrong has to be able to fix itself from the + // error alone, without another call. + let error = platform_from_str("watchos").unwrap_err(); + assert!( + error.contains("ios") && error.contains("vision_os"), + "{error}" + ); + + let error = display_type_from_str("iphone").unwrap_err(); + assert!(error.contains("APP_IPHONE_67"), "{error}"); + + let error = bundle_id_platform_from_str("visionos").unwrap_err(); + assert!(error.contains("universal"), "{error}"); + } + + #[test] + fn display_type_round_trips_a_real_value() { + assert_eq!( + display_type_from_str("app_iphone_67").unwrap(), + ScreenshotDisplayType::Iphone67 + ); + } +} diff --git a/crates/frontend/src/env.rs b/crates/frontend/src/env.rs new file mode 100644 index 0000000..95463c8 --- /dev/null +++ b/crates/frontend/src/env.rs @@ -0,0 +1,44 @@ +//! Credential resolution from the environment. + +use smbcloud_ascapi_core::ApiKey; +use smbcloud_ascapi_core::Error; + +/// Resolve an App Store Connect API key from the environment. +/// +/// Deliberately per-call rather than at startup: an MCP server that +/// refuses to start without credentials cannot answer `tools/list`, which +/// is the first thing every client asks and the only way a user discovers +/// what configuration is missing. Unconfigured servers should list their +/// tools and fail with a useful message when one is called. +pub fn api_key_from_env() -> Result { + let key_id = std::env::var("ASC_API_KEY") + .map_err(|_| "ASC_API_KEY is not set (App Store Connect API key ID)".to_string())?; + let issuer_id = std::env::var("ASC_ISSUER_ID") + .map_err(|_| "ASC_ISSUER_ID is not set (App Store Connect issuer ID)".to_string())?; + + let path = match std::env::var("ASC_PRIVATE_KEY_PATH") { + Ok(path) => std::path::PathBuf::from(path), + Err(_) => { + let home = std::env::var("HOME").map_err(|_| { + "neither ASC_PRIVATE_KEY_PATH nor HOME is set, so the .p8 key cannot be located" + .to_string() + })?; + std::path::PathBuf::from(home) + .join(".appstoreconnect") + .join("private_keys") + .join(format!("AuthKey_{key_id}.p8")) + } + }; + + ApiKey::from_p8_file(&key_id, &issuer_id, &path).map_err(|error| match error { + // The most common misconfiguration by a wide margin, and the + // default-path message needs to name the path it guessed. + Error::PrivateKeyRead { .. } => { + format!( + "could not read the App Store Connect private key at {}", + path.display() + ) + } + other => other.to_string(), + }) +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs new file mode 100644 index 0000000..d32de5e --- /dev/null +++ b/crates/frontend/src/lib.rs @@ -0,0 +1,30 @@ +//! Operations shared by the `ascapi` command line and its MCP server. +//! +//! Both surfaces need the same things: resolve credentials, call the App +//! Store Connect client, and shape the result into something worth showing +//! a human or handing a model. Putting that here means the two front ends +//! cannot drift, and in particular that the ordering guarantee in +//! [`certificates::issue_certificate`] (private key written before the +//! network call) has exactly one implementation. +//! +//! Two rules the result types are built around, because one of the +//! consumers is a language model: +//! +//! - **No key material in a result.** [`certificates::IssuedCertificate`] +//! carries the path a key was written to, never the key. Enforced by +//! construction rather than filtered at the edge, since a filter is one +//! refactor away from being bypassed. +//! - **No certificate bodies either.** They are large, never needed to +//! decide what to do next, and would otherwise land in a transcript. + +pub mod certificates; +pub mod enums; +pub mod env; +pub mod time; + +pub use certificates::{ + certificate_type_from_str, issue_certificate, CertificateSummary, IssuedCertificate, +}; +pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str}; +pub use env::api_key_from_env; +pub use time::{is_expired, now_iso8601}; diff --git a/crates/frontend/src/time.rs b/crates/frontend/src/time.rs new file mode 100644 index 0000000..260e086 --- /dev/null +++ b/crates/frontend/src/time.rs @@ -0,0 +1,45 @@ +//! Timestamp helpers for comparing against App Store Connect's expiry +//! strings without a date-parsing dependency. + +/// Whether an ISO-8601 UTC timestamp is in the past. +/// +/// Lexicographic comparison is sound for the fixed-width UTC timestamps +/// App Store Connect returns, and avoids a date-parsing dependency for a +/// field that is only ever compared and displayed. +pub fn is_expired(expiration_date: &str, now: &str) -> bool { + expiration_date < now +} + +/// Current UTC time as `YYYY-MM-DDTHH:MM:SS`, for comparison against +/// Apple's expiry strings. +pub fn now_iso8601() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // days_from_civil, inverted. Shifting the era to March makes the leap + // day the last day of the year, which removes every special case. + let days = secs.div_euclid(86_400); + let secs_of_day = secs.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + y, + m, + d, + secs_of_day / 3600, + (secs_of_day % 3600) / 60, + secs_of_day % 60 + ) +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..76979d2 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "smbcloud-ascapi-mcp" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "MCP server exposing the App Store Connect operations from smbcloud-ascapi over stdio." + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +smbcloud-ascapi-aso = { workspace = true } +smbcloud-ascapi-core = { workspace = true } +smbcloud-ascapi-signing = { workspace = true } +smbcloud-ascapi-frontend = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 0000000..5c467f6 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,881 @@ +//! App Store Connect MCP contract. +//! +//! Defines the canonical tool set exposed by `ascapi --mcp`. Each name is +//! supplied by the embedder through [`crate::ascapi_mcp_tools`], so the +//! standalone binary and any host that re-exposes this contract share one +//! implementation and one public surface. Same arrangement `xcrs` uses in +//! `smbcloud-cli`. +//! +//! # Two rules this server is built around +//! +//! **stdout belongs to the protocol.** The stdio transport carries +//! JSON-RPC on stdout, so a stray `println!` anywhere beneath a tool call +//! corrupts the stream, and the failure surfaces as a parse error in the +//! client a long way from its cause. Nothing in this path may write to +//! stdout; diagnostics go to stderr. +//! +//! **Tool results are read by a model.** Private keys and raw certificate +//! bodies never appear in a result; see +//! [`smbcloud_ascapi_frontend`] for where that is enforced. +//! +//! # What is deliberately absent +//! +//! Certificate revocation. `ascapi certificates revoke` exists on the +//! command line and is intentionally not a tool: revoking a distribution +//! certificate invalidates every provisioning profile embedding it, for +//! every teammate and every CI job, at once and irreversibly. That is not +//! an action a language model should take from a prompt, and no +//! confirmation string typed by the model rather than the human makes it +//! safer. Deletes that are scoped and recreatable (a version, a +//! localization, a screenshot) are exposed, annotated destructive. + +pub mod requests; +pub mod server; + +pub use requests::*; +pub use server::{serve, AscapiMcpServer}; + +#[macro_export] +macro_rules! ascapi_mcp_tools { + ( + $server:ty, + $app_list_name:literal, + $app_get_name:literal, + $app_update_name:literal, + $app_info_list_name:literal, + $build_list_name:literal, + $bundle_id_list_name:literal, + $bundle_id_create_name:literal, + $app_store_version_list_name:literal, + $app_store_version_get_name:literal, + $app_store_version_create_name:literal, + $app_store_version_delete_name:literal, + $app_store_version_set_build_name:literal, + $app_info_localization_list_name:literal, + $app_info_localization_create_name:literal, + $app_info_localization_update_name:literal, + $app_info_localization_delete_name:literal, + $app_store_version_localization_list_name:literal, + $app_store_version_localization_create_name:literal, + $app_store_version_localization_update_name:literal, + $app_store_version_localization_delete_name:literal, + $app_screenshot_set_list_name:literal, + $app_screenshot_set_create_name:literal, + $app_screenshot_set_delete_name:literal, + $app_screenshot_list_name:literal, + $app_screenshot_upload_name:literal, + $app_screenshot_delete_name:literal, + $certificate_list_name:literal, + $certificate_create_name:literal + ) => { + #[::rmcp::tool_router(router = ascapi_tool_router, vis = "pub(crate)")] + impl $server { + fn client() -> ::std::result::Result<::smbcloud_ascapi_core::Client, ::rmcp::model::ErrorData> { + let api_key = ::smbcloud_ascapi_frontend::api_key_from_env() + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + Ok(::smbcloud_ascapi_core::Client::new(api_key)) + } + + #[::rmcp::tool( + name = $app_list_name, + title = "List apps", + annotations(title = "List apps", read_only_hint = true, idempotent_hint = true), + description = "Purpose: find the App Store Connect apps on this account, and translate a bundle identifier into the numeric app id every other tool wants. When to use vs siblings: call this first when you only know a bundle identifier; use app_get once you have an id and want one app's full attributes. Behavior: returns each app's id, name, bundle identifier, SKU, and primary locale, optionally filtered to one bundle identifier. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: returns apps only, not their versions, builds, or localizations, each of which has its own tool." + )] + async fn app_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_apps(request.bundle_id.as_deref()) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_get_name, + title = "Get one app", + annotations(title = "Get one app", read_only_hint = true, idempotent_hint = true), + description = "Purpose: fetch one app's full attributes by its App Store Connect id. When to use vs siblings: use app_list when you have a bundle identifier rather than an id, and app_info_list when you want the AppInfo records that hold localized names. Behavior: returns the single app resource with its attributes. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: attributes only; related resources such as versions and builds are fetched with their own tools." + )] + async fn app_get( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppIdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .get_app(&request.app_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_update_name, + title = "Update app attributes", + annotations(title = "Update app attributes", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: change an app's primary locale or content rights declaration. When to use vs siblings: this edits the app record itself; localized names and subtitles live on AppInfo localizations and are edited with app_info_localization_update. Behavior: sends only the fields provided, leaving the rest untouched, and returns the updated app. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: only these two attributes are editable through this endpoint; most app metadata belongs to a version or a localization instead." + )] + async fn app_update( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppUpdateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let attributes = ::smbcloud_ascapi_aso::app::AppUpdateAttributes { + primary_locale: request.primary_locale, + content_rights_declaration: request.content_rights_declaration, + }; + let value = client + .update_app(&request.app_id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_info_list_name, + title = "List app infos", + annotations(title = "List app infos", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list an app's AppInfo records, which are the containers for localized names, subtitles, and privacy policy URLs. When to use vs siblings: call this to get the app_info_id that app_info_localization_list and app_info_localization_create need. Behavior: returns each AppInfo with its state and id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: an app typically has one editable AppInfo plus historical ones for shipped versions, and this returns all of them without saying which is editable." + )] + async fn app_info_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppIdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_app_infos(&request.app_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $build_list_name, + title = "List builds", + annotations(title = "List builds", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the builds uploaded for an app, so one can be attached to a version. When to use vs siblings: pair with app_store_version_set_build, which needs a build id from here. Behavior: returns each build's id, version string, upload date, and processing state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: a build that is still processing cannot be attached yet, and this reports that state rather than waiting for it." + )] + async fn build_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppIdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_builds(&request.app_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $bundle_id_list_name, + title = "List bundle IDs", + annotations(title = "List bundle IDs", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the bundle identifiers registered to the team, or check whether one exists before trying to register it. When to use vs siblings: call this before bundle_id_create, which fails on an identifier that already exists. Behavior: returns each bundle ID's id, identifier, name, and platform, optionally filtered to an exact identifier. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: registration state only; whether an app exists for the identifier is a separate question answered by app_list." + )] + async fn bundle_id_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BundleIdListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_bundle_ids(request.identifier.as_deref()) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $bundle_id_create_name, + title = "Register a bundle ID", + annotations(title = "Register a bundle ID", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: register a new reverse-DNS bundle identifier with the developer account. When to use vs siblings: call bundle_id_list first, since registering an identifier that already exists fails. Behavior: registers the identifier under the given platform namespace and returns the created resource. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: identifiers cannot be renamed or deleted through this API once registered, so a typo here is permanent; visionOS apps register as ios because visionOS shares the iOS namespace." + )] + async fn bundle_id_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BundleIdCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let platform = ::smbcloud_ascapi_frontend::bundle_id_platform_from_str(&request.platform) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + let attributes = ::smbcloud_ascapi_aso::bundle_id::BundleIdCreateAttributes { + identifier: request.identifier, + name: request.name, + platform, + }; + let value = client + .create_bundle_id(attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_list_name, + title = "List app store versions", + annotations(title = "List app store versions", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list an app's per-platform version records, which are what the store actually reviews and releases. When to use vs siblings: use this to find the version id that the localization and build tools need. Behavior: returns each version's id, platform, version string, and app store state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: versions are per platform, so one app can have several concurrent editable versions and this returns all of them." + )] + async fn app_store_version_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppStoreVersionListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let platform = match request.platform.as_deref() { + Some(value) => Some( + ::smbcloud_ascapi_frontend::platform_from_str(value) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?, + ), + None => None, + }; + let value = client + .list_app_store_versions(&request.app_id, platform) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_get_name, + title = "Get one app store version", + annotations(title = "Get one app store version", read_only_hint = true, idempotent_hint = true), + description = "Purpose: fetch one App Store Version's attributes by id. When to use vs siblings: use app_store_version_list when you have an app id rather than a version id. Behavior: returns the single version resource with its platform, version string, and release state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: attributes only; its localizations and attached build are fetched with their own tools." + )] + async fn app_store_version_get( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .get_app_store_version(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_create_name, + title = "Create an app store version", + annotations(title = "Create an app store version", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: create a new version record for an app on a given platform, which is how a new platform such as visionOS is added to an existing app. When to use vs siblings: call app_store_version_list first, because an app can hold only one editable version per platform at a time. Behavior: creates the version and returns it. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: creating a version does not submit anything; localizations, screenshots, and a build still have to be attached before review." + )] + async fn app_store_version_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppStoreVersionCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let platform = ::smbcloud_ascapi_frontend::platform_from_str(&request.platform) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + let attributes = ::smbcloud_ascapi_aso::app_store_version::AppStoreVersionCreateAttributes { + platform, + version_string: request.version_string, + copyright: request.copyright, + }; + let value = client + .create_app_store_version(&request.app_id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_delete_name, + title = "Delete an app store version", + annotations(title = "Delete an app store version", read_only_hint = false, destructive_hint = true, idempotent_hint = true), + description = "Purpose: delete an editable App Store Version record. When to use vs siblings: use this to discard a version created by mistake, typically after app_store_version_list shows a duplicate. Behavior: deletes the version and everything attached to it, including its localizations and screenshots, then returns the deleted id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: destructive and not undoable, though the version can be recreated; a version already submitted or released cannot be deleted and Apple rejects the attempt." + )] + async fn app_store_version_delete( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .delete_app_store_version(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "deleted": request.id }))?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_set_build_name, + title = "Attach a build to a version", + annotations(title = "Attach a build to a version", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: attach an uploaded build to an App Store Version, which a version needs before it can be submitted. When to use vs siblings: get the build id from build_list and the version id from app_store_version_list. Behavior: points the version's build relationship at the given build, replacing whatever was attached. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: the build must have finished processing, and its platform must match the version's; Apple rejects the pairing otherwise." + )] + async fn app_store_version_set_build( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::SetBuildRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .set_app_store_version_build(&request.version_id, &request.build_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "versionId": request.version_id, "buildId": request.build_id }))?, + ])) + } + + #[::rmcp::tool( + name = $app_info_localization_list_name, + title = "List app info localizations", + annotations(title = "List app info localizations", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the localized names and subtitles attached to an AppInfo. When to use vs siblings: this covers name, subtitle, and privacy policy URL; description, keywords, and release notes live on version localizations instead. Behavior: returns each localization's id, locale, name, and subtitle. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: only the editable AppInfo accepts changes, and this does not distinguish editable from historical records." + )] + async fn app_info_localization_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppInfoLocalizationListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_app_info_localizations(&request.app_info_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_info_localization_create_name, + title = "Add an app info localization", + annotations(title = "Add an app info localization", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: add a localized app name and subtitle for a new locale. When to use vs siblings: use app_info_localization_update when the locale already exists, since creating a duplicate fails. Behavior: creates the localization under the AppInfo and returns it. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: the subtitle is capped at 30 characters and the name at 30 by App Store Connect, which rejects longer values rather than truncating them." + )] + async fn app_info_localization_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppInfoLocalizationCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let attributes = ::smbcloud_ascapi_aso::app_info_localization::AppInfoLocalizationCreateAttributes { + locale: request.locale, + name: request.name, + subtitle: request.subtitle, + privacy_policy_url: request.privacy_policy_url, + }; + let value = client + .create_app_info_localization(&request.app_info_id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_info_localization_update_name, + title = "Edit an app info localization", + annotations(title = "Edit an app info localization", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: change the localized name, subtitle, or privacy policy URL for a locale that already exists. When to use vs siblings: use app_info_localization_create for a locale that has no record yet. Behavior: sends only the fields provided and leaves the others untouched, then returns the updated localization. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: the locale itself cannot be changed; delete and recreate to move copy to a different locale." + )] + async fn app_info_localization_update( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::AppInfoLocalizationUpdateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let attributes = ::smbcloud_ascapi_aso::app_info_localization::AppInfoLocalizationUpdateAttributes { + name: request.name, + subtitle: request.subtitle, + privacy_policy_url: request.privacy_policy_url, + }; + let value = client + .update_app_info_localization(&request.id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_info_localization_delete_name, + title = "Delete an app info localization", + annotations(title = "Delete an app info localization", read_only_hint = false, destructive_hint = true, idempotent_hint = true), + description = "Purpose: remove a locale's app name and subtitle. When to use vs siblings: use this to drop a language from the product page; use app_info_localization_update to change copy rather than remove it. Behavior: deletes the localization and returns the deleted id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: destructive but recreatable with app_info_localization_create; the app's primary locale cannot be deleted and Apple rejects the attempt." + )] + async fn app_info_localization_delete( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .delete_app_info_localization(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "deleted": request.id }))?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_localization_list_name, + title = "List version localizations", + annotations(title = "List version localizations", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the per-locale description, keywords, promotional text, and release notes for a version. When to use vs siblings: this is the version-scoped copy; the app name and subtitle live on AppInfo localizations instead. Behavior: returns each localization's id, locale, and copy fields. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: screenshots hang off these localizations but are listed separately with app_screenshot_set_list." + )] + async fn app_store_version_localization_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::VersionLocalizationListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_app_store_version_localizations(&request.version_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_localization_create_name, + title = "Add a version localization", + annotations(title = "Add a version localization", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: add the description, keywords, and release notes for a locale on a specific version. When to use vs siblings: use the update tool when the locale already exists on this version. Behavior: creates the localization with whichever fields are provided and returns it. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: App Store Connect enforces field limits, notably 4000 characters of description, 100 for keywords, and 170 for promotional text, and rejects longer values outright." + )] + async fn app_store_version_localization_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::VersionLocalizationCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let fields = ::smbcloud_ascapi_aso::app_store_version_localization::AppStoreVersionLocalizationFields { + description: request.description, + keywords: request.keywords, + marketing_url: request.marketing_url, + promotional_text: request.promotional_text, + support_url: request.support_url, + whats_new: request.whats_new, + }; + let attributes = ::smbcloud_ascapi_aso::app_store_version_localization::AppStoreVersionLocalizationCreateAttributes { + locale: request.locale, + fields, + }; + let value = client + .create_app_store_version_localization(&request.version_id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_localization_update_name, + title = "Edit a version localization", + annotations(title = "Edit a version localization", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: change the description, keywords, promotional text, or release notes for an existing locale on a version. When to use vs siblings: promotional text is the one field editable without shipping a new build, which makes this the tool for timely copy changes. Behavior: sends only the fields provided and leaves the rest untouched. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: which fields are editable depends on the version's review state, and Apple rejects edits to a version under review." + )] + async fn app_store_version_localization_update( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::VersionLocalizationUpdateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let fields = ::smbcloud_ascapi_aso::app_store_version_localization::AppStoreVersionLocalizationFields { + description: request.description, + keywords: request.keywords, + marketing_url: request.marketing_url, + promotional_text: request.promotional_text, + support_url: request.support_url, + whats_new: request.whats_new, + }; + let value = client + .update_app_store_version_localization(&request.id, fields) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_store_version_localization_delete_name, + title = "Delete a version localization", + annotations(title = "Delete a version localization", read_only_hint = false, destructive_hint = true, idempotent_hint = true), + description = "Purpose: remove a locale's copy from a version. When to use vs siblings: use the update tool to change copy; use this only to drop a language from this version entirely. Behavior: deletes the localization along with its screenshot sets and returns the deleted id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: destructive and takes the locale's screenshots with it, though everything can be recreated; the primary locale cannot be removed." + )] + async fn app_store_version_localization_delete( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .delete_app_store_version_localization(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "deleted": request.id }))?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_set_list_name, + title = "List screenshot sets", + annotations(title = "List screenshot sets", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the per-device-class screenshot buckets on a version localization. When to use vs siblings: each set holds the images for one display type, so call this to find the set id that app_screenshot_list and app_screenshot_upload need. Behavior: returns each set's id and display type. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: reports the sets, not how many images each holds or whether the set satisfies Apple's per-device requirements." + )] + async fn app_screenshot_set_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::ScreenshotSetListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_app_screenshot_sets(&request.localization_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_set_create_name, + title = "Create a screenshot set", + annotations(title = "Create a screenshot set", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: create the bucket that holds screenshots for one device class on a version localization. When to use vs siblings: call app_screenshot_set_list first, since a localization holds at most one set per display type. Behavior: creates the set for the given display type and returns it, ready for app_screenshot_upload. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: the display type must be one App Store Connect recognizes, and image dimensions are validated later at upload rather than here." + )] + async fn app_screenshot_set_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::ScreenshotSetCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let display_type = ::smbcloud_ascapi_frontend::display_type_from_str(&request.display_type) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + let attributes = ::smbcloud_ascapi_aso::app_screenshot_set::AppScreenshotSetCreateAttributes { + screenshot_display_type: display_type, + }; + let value = client + .create_app_screenshot_set(&request.localization_id, attributes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_set_delete_name, + title = "Delete a screenshot set", + annotations(title = "Delete a screenshot set", read_only_hint = false, destructive_hint = true, idempotent_hint = true), + description = "Purpose: remove a device class's screenshot bucket and every image in it. When to use vs siblings: use app_screenshot_delete to remove a single image instead of the whole set. Behavior: deletes the set and its screenshots, then returns the deleted id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: destructive and takes every image with it, though the set and its images can be recreated by uploading again." + )] + async fn app_screenshot_set_delete( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .delete_app_screenshot_set(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "deleted": request.id }))?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_list_name, + title = "List screenshots", + annotations(title = "List screenshots", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list the images in a screenshot set, with their upload and processing state. When to use vs siblings: call this after app_screenshot_upload to confirm an image finished processing, since upload returns before Apple has validated the file. Behavior: returns each screenshot's id, file name, size, and asset delivery state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: an image that failed Apple's dimension checks appears here with an error state rather than being absent, so the state is worth reading." + )] + async fn app_screenshot_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::ScreenshotListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let value = client + .list_app_screenshots(&request.screenshot_set_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_upload_name, + title = "Upload a screenshot", + annotations(title = "Upload a screenshot", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: upload one image into a screenshot set, running the whole three-step reservation, upload, and commit dance in a single call. When to use vs siblings: create the set with app_screenshot_set_create first, then verify with app_screenshot_list, since Apple validates asynchronously. Behavior: reads the file from this machine, reserves the asset, PUTs the bytes to the pre-signed URL, and commits it with its checksum. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: an unreadable path fails before any network call; a wrong image size is accepted here and rejected later, surfacing in app_screenshot_list. Limitations: the path is read by this process, so it must be reachable from wherever the server runs, not from the client's machine." + )] + async fn app_screenshot_upload( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::ScreenshotUploadRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let path = ::std::path::PathBuf::from(&request.file_path); + let bytes = ::std::fs::read(&path).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request( + format!("reading {}: {error}", path.display()), + None, + ) + })?; + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .ok_or_else(|| { + ::rmcp::model::ErrorData::invalid_request( + format!("{} has no file name", path.display()), + None, + ) + })?; + let value = client + .upload_app_screenshot(&request.screenshot_set_id, file_name, bytes) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&value)?, + ])) + } + + #[::rmcp::tool( + name = $app_screenshot_delete_name, + title = "Delete a screenshot", + annotations(title = "Delete a screenshot", read_only_hint = false, destructive_hint = true, idempotent_hint = true), + description = "Purpose: remove one uploaded screenshot. When to use vs siblings: use app_screenshot_set_delete to clear an entire device class at once. Behavior: deletes the image and returns the deleted id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: destructive but trivially recoverable by uploading the file again, which makes it the safest of the delete tools." + )] + async fn app_screenshot_delete( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + client + .delete_app_screenshot(&request.id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "deleted": request.id }))?, + ])) + } + + #[::rmcp::tool( + name = $certificate_list_name, + title = "List signing certificates", + annotations(title = "List signing certificates", read_only_hint = true, idempotent_hint = true), + description = "Purpose: report every signing certificate the team holds, with the expiry of each, so an agent can tell what is usable before a build is attempted. When to use vs siblings: call this first to see whether a valid certificate already exists; use certificate_create only when none of the required type exists, or the existing one has expired. Behavior: returns id, name, type, serial number, expiry, and a computed expired flag, soonest expiry first. The flag is computed here because App Store Connect offers no filter for it and returns expired certificates alongside valid ones. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: certificate bodies are omitted deliberately, and this reports certificates only, not provisioning profiles, so a valid certificate here does not by itself mean a build will sign." + )] + async fn certificate_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::CertificateListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let filter = match request.certificate_type.as_deref() { + Some(value) => Some( + ::smbcloud_ascapi_frontend::certificate_type_from_str(value) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?, + ), + None => None, + }; + let mut certificates = client.list_certificates(filter).await.map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + certificates.sort_by(|a, b| { + a.attributes.expiration_date.cmp(&b.attributes.expiration_date) + }); + let now = ::smbcloud_ascapi_frontend::now_iso8601(); + let summaries: Vec<_> = certificates + .iter() + .map(|certificate| ::smbcloud_ascapi_frontend::CertificateSummary::from_resource(certificate, &now)) + .collect(); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&summaries)?, + ])) + } + + #[::rmcp::tool( + name = $certificate_create_name, + title = "Issue a signing certificate", + annotations(title = "Issue a signing certificate", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: issue a new Apple signing certificate end to end, generating the key pair locally and writing both halves to disk, so a machine can sign without anyone visiting the developer portal. When to use vs siblings: call certificate_list first and use this only when no valid certificate of the needed type exists; an expired certificate cannot be renewed, so replacing one also means calling this. Behavior: generates an RSA 2048 key pair, the only kind Apple accepts, writes the private key with owner-only permissions, sends a signing request, then writes the issued certificate beside the key and returns both paths. The key is written before the request is sent, so a failure mid-flight leaves an unused key rather than a certificate whose key was never saved. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: Apple caps how many certificates of each type a team may hold and returns an error at the cap, which is resolved by revoking one from the command line, never from here. Limitations: the response carries paths, never key material; Apple keeps no copy of the private key, so losing the file makes the certificate permanently useless." + )] + async fn certificate_create( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::CertificateCreateRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + #[allow(unused_imports)] + use ::smbcloud_ascapi_signing::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let certificate_type = ::smbcloud_ascapi_frontend::certificate_type_from_str(&request.certificate_type) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + let common_name = request + .common_name + .unwrap_or_else(|| "smbcloud-ascapi".to_string()); + let out_dir = ::std::path::PathBuf::from(&request.out_dir); + let outcome = ::smbcloud_ascapi_frontend::issue_certificate( + &client, + certificate_type, + &common_name, + &out_dir, + ) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error, None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&outcome)?, + ])) + } + } + }; +} diff --git a/crates/mcp/src/requests.rs b/crates/mcp/src/requests.rs new file mode 100644 index 0000000..355aa61 --- /dev/null +++ b/crates/mcp/src/requests.rs @@ -0,0 +1,246 @@ +//! Tool parameter types. +//! +//! Every field carries a doc comment, because rustdoc comments become the +//! JSON Schema `description` a model reads when deciding how to call a +//! tool, and a parameter without one is a guess waiting to happen. The +//! contract test in [`crate::server`] fails the build if any is missing. + +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] +pub struct AppListRequest { + /// Only return the app with this bundle identifier, e.g. + /// xyz.smbcloud.mailx. Omit to list every app on the account. + #[serde(default)] + pub bundle_id: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppIdRequest { + /// App Store Connect app id, the numeric string from the app's URL in + /// App Store Connect. Not the bundle identifier; use app_list to + /// translate one into the other. + pub app_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppUpdateRequest { + /// App Store Connect app id. + pub app_id: String, + /// BCP-47 locale to make the app's primary language, e.g. en-US. + #[serde(default)] + pub primary_locale: Option, + /// Content rights declaration, e.g. DOES_NOT_USE_THIRD_PARTY_CONTENT + /// or USES_THIRD_PARTY_CONTENT. + #[serde(default)] + pub content_rights_declaration: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] +pub struct BundleIdListRequest { + /// Only return the bundle ID matching this identifier exactly. Omit to + /// list every registered identifier. + #[serde(default)] + pub identifier: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct BundleIdCreateRequest { + /// The reverse-DNS identifier to register, e.g. xyz.smbcloud.mailx. + pub identifier: String, + /// Human-readable name shown in the developer portal. + pub name: String, + /// Platform namespace: ios, mac_os, or universal. visionOS apps + /// register as ios, because visionOS shares the iOS namespace. + pub platform: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppStoreVersionListRequest { + /// App Store Connect app id whose versions to list. + pub app_id: String, + /// Only return versions for this platform: ios, mac_os, tv_os, or + /// vision_os. Omit to list every platform's versions. + #[serde(default)] + pub platform: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct IdRequest { + /// App Store Connect resource id. + pub id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppStoreVersionCreateRequest { + /// App Store Connect app id to add the version to. + pub app_id: String, + /// Platform for this version: ios, mac_os, tv_os, or vision_os. Adding + /// a platform to an existing app means creating a version for it here. + pub platform: String, + /// Marketing version string, e.g. 1.2.0. + pub version_string: String, + /// Copyright line, e.g. "2026 Splitfire AB". Optional. + #[serde(default)] + pub copyright: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct SetBuildRequest { + /// App Store Version id to attach the build to. + pub version_id: String, + /// Build id to attach. Use build_list to find one for the app. + pub build_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppInfoLocalizationListRequest { + /// AppInfo id whose localizations to list. Use app_info_list to find + /// the AppInfo for an app. + pub app_info_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppInfoLocalizationCreateRequest { + /// AppInfo id to add the localization to. + pub app_info_id: String, + /// BCP-47 locale, e.g. sv-SE or id-ID. + pub locale: String, + /// Localized app name shown on the product page. + pub name: String, + /// Localized subtitle, up to 30 characters. + #[serde(default)] + pub subtitle: Option, + /// Localized privacy policy URL. + #[serde(default)] + pub privacy_policy_url: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct AppInfoLocalizationUpdateRequest { + /// AppInfoLocalization id to update. + pub id: String, + /// New localized app name. Omit to leave unchanged. + #[serde(default)] + pub name: Option, + /// New localized subtitle. Omit to leave unchanged. + #[serde(default)] + pub subtitle: Option, + /// New privacy policy URL. Omit to leave unchanged. + #[serde(default)] + pub privacy_policy_url: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct VersionLocalizationListRequest { + /// App Store Version id whose localizations to list. + pub version_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct VersionLocalizationCreateRequest { + /// App Store Version id to add the localization to. + pub version_id: String, + /// BCP-47 locale, e.g. sv-SE or id-ID. + pub locale: String, + /// Long description shown on the product page, up to 4000 characters. + #[serde(default)] + pub description: Option, + /// Comma-separated keyword field, up to 100 characters. Apple indexes + /// it for search and never shows it. + #[serde(default)] + pub keywords: Option, + /// Marketing URL. + #[serde(default)] + pub marketing_url: Option, + /// Promotional text, up to 170 characters. Editable without a new + /// build, unlike the description. + #[serde(default)] + pub promotional_text: Option, + /// Support URL. + #[serde(default)] + pub support_url: Option, + /// Release notes for this version. + #[serde(default)] + pub whats_new: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct VersionLocalizationUpdateRequest { + /// AppStoreVersionLocalization id to update. + pub id: String, + /// New description. Omit to leave unchanged. + #[serde(default)] + pub description: Option, + /// New keyword field. Omit to leave unchanged. + #[serde(default)] + pub keywords: Option, + /// New marketing URL. Omit to leave unchanged. + #[serde(default)] + pub marketing_url: Option, + /// New promotional text. Omit to leave unchanged. + #[serde(default)] + pub promotional_text: Option, + /// New support URL. Omit to leave unchanged. + #[serde(default)] + pub support_url: Option, + /// New release notes. Omit to leave unchanged. + #[serde(default)] + pub whats_new: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct ScreenshotSetListRequest { + /// AppStoreVersionLocalization id whose screenshot sets to list. + pub localization_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct ScreenshotSetCreateRequest { + /// AppStoreVersionLocalization id to create the set under. + pub localization_id: String, + /// Device class for this set, e.g. APP_IPHONE_67, APP_IPAD_PRO_129, + /// APP_APPLE_VISION_PRO, or APP_DESKTOP. + pub display_type: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct ScreenshotListRequest { + /// AppScreenshotSet id whose screenshots to list. + pub screenshot_set_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct ScreenshotUploadRequest { + /// AppScreenshotSet id to upload into. + pub screenshot_set_id: String, + /// Path to the image file on this machine. Read from disk by the + /// server, so it must be a path this process can open. + pub file_path: String, +} + +#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)] +pub struct CertificateListRequest { + /// Only list certificates of this type. One of development, + /// distribution, mac_app_distribution, mac_installer_distribution, + /// developer_id_application. Omit to list every type. + #[serde(default)] + pub certificate_type: Option, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct CertificateCreateRequest { + /// The kind of certificate to issue. One of development, + /// distribution, mac_app_distribution, mac_installer_distribution, + /// developer_id_application. + pub certificate_type: String, + /// Directory to write the private key and issued certificate into. It + /// is created if missing; the key is written with owner-only + /// permissions. + pub out_dir: String, + /// Subject common name for the signing request. Apple replaces the + /// subject on the certificate it issues, so this only labels the + /// request itself. Defaults to smbcloud-ascapi. + #[serde(default)] + pub common_name: Option, +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 0000000..d4a4807 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,361 @@ +//! The `ascapi --mcp` server. +//! +//! Holds no state: every tool resolves its App Store Connect credentials +//! from the environment per call, so an unconfigured server still starts +//! and still answers `tools/list`, which is how a client discovers what +//! configuration it needs. +//! +//! The tool names are supplied here rather than baked into the contract +//! module, so a host that re-exposes these tools can namespace them +//! without forking the implementations. Same arrangement as `xcrs` in +//! `smbcloud-cli`. + +use crate::ascapi_mcp_tools; +use anyhow::{anyhow, Result}; +use rmcp::{ + model::{Implementation, ServerCapabilities, ServerInfo}, + transport::stdio, + ServerHandler, ServiceExt, +}; + +#[derive(Debug, Clone, Default)] +pub struct AscapiMcpServer; + +impl AscapiMcpServer { + pub fn new() -> Self { + Self + } +} + +ascapi_mcp_tools!( + AscapiMcpServer, + "app_list", + "app_get", + "app_update", + "app_info_list", + "build_list", + "bundle_id_list", + "bundle_id_create", + "app_store_version_list", + "app_store_version_get", + "app_store_version_create", + "app_store_version_delete", + "app_store_version_set_build", + "app_info_localization_list", + "app_info_localization_create", + "app_info_localization_update", + "app_info_localization_delete", + "app_store_version_localization_list", + "app_store_version_localization_create", + "app_store_version_localization_update", + "app_store_version_localization_delete", + "app_screenshot_set_list", + "app_screenshot_set_create", + "app_screenshot_set_delete", + "app_screenshot_list", + "app_screenshot_upload", + "app_screenshot_delete", + "certificate_list", + "certificate_create" +); + +#[rmcp::tool_handler(router = Self::ascapi_tool_router())] +impl ServerHandler for AscapiMcpServer { + fn get_info(&self) -> ServerInfo { + let mut implementation = Implementation::from_build_env(); + implementation.name = "ascapi".to_string(); + implementation.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(implementation) + .with_instructions( + "ascapi exposes App Store Connect signing certificates (certificate_list, \ + certificate_create). Certificates are issued by generating an RSA 2048 key \ + pair locally and sending only a signing request to Apple, so the private key \ + never leaves this machine and Apple keeps no copy of it: a lost key file \ + makes its certificate permanently unusable. Revocation is deliberately not \ + offered here, because revoking a distribution certificate invalidates every \ + provisioning profile embedding it for the whole team at once; run \ + `ascapi certificates revoke` from a terminal instead. Requires ASC_API_KEY, \ + ASC_ISSUER_ID, and a readable .p8 key.", + ) + } +} + +/// Serve over stdio until the client disconnects. +/// +/// stdout carries the JSON-RPC stream from here on, so nothing in this +/// path may print to it. Diagnostics belong on stderr. +pub async fn serve() -> Result<()> { + let running = AscapiMcpServer::new() + .serve(stdio()) + .await + .map_err(|error| anyhow!("Failed to start ascapi MCP server: {error}"))?; + running + .waiting() + .await + .map_err(|error| anyhow!("ascapi MCP server stopped unexpectedly: {error}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The canonical tool names. Adding or renaming one is a + /// public-contract change, and this list is what makes that + /// deliberate rather than accidental. + const CANONICAL_TOOL_NAMES: [&str; 28] = [ + "app_list", + "app_get", + "app_update", + "app_info_list", + "build_list", + "bundle_id_list", + "bundle_id_create", + "app_store_version_list", + "app_store_version_get", + "app_store_version_create", + "app_store_version_delete", + "app_store_version_set_build", + "app_info_localization_list", + "app_info_localization_create", + "app_info_localization_update", + "app_info_localization_delete", + "app_store_version_localization_list", + "app_store_version_localization_create", + "app_store_version_localization_update", + "app_store_version_localization_delete", + "app_screenshot_set_list", + "app_screenshot_set_create", + "app_screenshot_set_delete", + "app_screenshot_list", + "app_screenshot_upload", + "app_screenshot_delete", + "certificate_list", + "certificate_create", + ]; + + /// Never a tool, at any point, for any caller. + /// + /// Revoking a signing certificate invalidates every provisioning + /// profile embedding it, for every teammate and every CI job, at once + /// and irreversibly. No confirmation string a model types on a human's + /// behalf makes that safe. It lives on the command line only, where a + /// person is the one at the keyboard. + /// + /// The scoped deletes below are a different matter and *are* exposed: + /// a version, a localization, a screenshot set, or an image can all be + /// recreated by re-running the tool that made them. They carry + /// `destructive_hint` so a client can gate them on its own terms. + const FORBIDDEN_TOOL_NAMES: [&str; 4] = [ + "certificate_revoke", + "certificate_delete", + "certificate_nuke", + "app_delete", + ]; + + #[test] + fn tool_router_exposes_exactly_the_canonical_tools() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + + assert_eq!( + tools.len(), + CANONICAL_TOOL_NAMES.len(), + "expected exactly {} canonical tools, found {}", + CANONICAL_TOOL_NAMES.len(), + tools.len() + ); + + let mut actual: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + actual.sort_unstable(); + let mut expected = CANONICAL_TOOL_NAMES; + expected.sort_unstable(); + assert_eq!(actual, expected); + } + + #[test] + fn no_irreversible_tool_is_ever_exposed() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + assert!( + !FORBIDDEN_TOOL_NAMES.contains(&tool.name.as_ref()), + "{} is irreversible or team-wide and must not be reachable over MCP", + tool.name + ); + } + } + + #[test] + fn every_delete_tool_is_annotated_destructive() { + // The naming convention is load-bearing: a client deciding whether + // to prompt reads the annotation, not the name, so the two must + // agree or a delete slips through a confirmation gate. + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + let deletes: Vec<_> = tools + .iter() + .filter(|tool| tool.name.ends_with("_delete")) + .collect(); + assert!( + !deletes.is_empty(), + "expected some delete tools; if they were removed, drop this test with them" + ); + for tool in deletes { + let annotations = tool + .annotations + .as_ref() + .unwrap_or_else(|| panic!("{} is missing annotations", tool.name)); + assert_eq!( + annotations.destructive_hint, + Some(true), + "{} deletes something and must be annotated destructive", + tool.name + ); + } + } + + #[test] + fn read_only_tools_are_never_annotated_destructive() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + let Some(annotations) = tool.annotations.as_ref() else { + continue; + }; + if annotations.read_only_hint == Some(true) { + assert_ne!( + annotations.destructive_hint, + Some(true), + "{} claims to be read-only and destructive at once", + tool.name + ); + } + } + } + + #[test] + fn tool_names_carry_no_server_prefix() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + assert!( + !tool.name.starts_with("ascapi_") && !tool.name.starts_with("smb_"), + "{} should not repeat a server prefix", + tool.name + ); + } + } + + #[test] + fn every_tool_has_a_meaningful_concise_title() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + let title = tool + .title + .as_deref() + .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name)); + assert!(!title.trim().is_empty(), "{} has a blank title", tool.name); + assert!( + title.len() <= 40, + "{} title should be concise (<= 40 chars), got {title:?}", + tool.name + ); + assert_ne!( + title, + tool.name.as_ref(), + "{} title should be a human-readable label, not the raw tool name", + tool.name + ); + } + } + + #[test] + fn every_tool_has_a_front_loaded_transparent_description() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + let description = tool + .description + .as_deref() + .unwrap_or_else(|| panic!("{} is missing a description", tool.name)); + + for required in [ + "Purpose:", + "When to use", + "Behavior:", + "Prerequisites:", + "Failure modes:", + "Limitations:", + ] { + assert!( + description.contains(required), + "{} description is missing the {required:?} section", + tool.name + ); + } + assert!( + description.starts_with("Purpose:"), + "{} description should front-load its purpose", + tool.name + ); + // Loosely bounded rather than snapshotted, so wording can + // evolve without a brittle exact-text assertion. + assert!( + description.len() >= 150, + "{} description is too terse for behavioral transparency ({} chars)", + tool.name, + description.len() + ); + } + } + + #[test] + fn every_tool_has_internally_consistent_annotations() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + let annotations = tool + .annotations + .as_ref() + .unwrap_or_else(|| panic!("{} is missing annotations", tool.name)); + let annotations_title = annotations + .title + .as_deref() + .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name)); + assert_eq!( + Some(annotations_title), + tool.title.as_deref(), + "{} top-level title and annotations title must agree", + tool.name + ); + assert!( + !(annotations.read_only_hint == Some(true) + && annotations.destructive_hint == Some(true)), + "{} annotations contradict: read_only_hint and destructive_hint are both true", + tool.name + ); + } + } + + #[test] + fn every_input_schema_property_has_a_description() { + let tools = AscapiMcpServer::ascapi_tool_router().list_all(); + for tool in &tools { + let Some(properties) = tool + .input_schema + .get("properties") + .and_then(|value| value.as_object()) + else { + continue; + }; + for (property_name, property_schema) in properties { + let description = property_schema + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + !description.trim().is_empty(), + "{}.{} is missing a parameter description", + tool.name, + property_name + ); + } + } + } +} diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml new file mode 100644 index 0000000..f7fbf26 --- /dev/null +++ b/crates/signing/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "smbcloud-ascapi-signing" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Apple code signing through App Store Connect: certificates, plus local RSA key pair and CSR generation." + +[lib] +path = "src/lib.rs" + +[dependencies] +async-trait = { workspace = true } +rand = { workspace = true } +rcgen = { workspace = true } +reqwest = { workspace = true } +rsa = { workspace = true, features = ["pem"] } +serde = { workspace = true, features = ["derive"] } +smbcloud-ascapi-core = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/ascapi/src/certificate.rs b/crates/signing/src/certificate.rs similarity index 88% rename from crates/ascapi/src/certificate.rs rename to crates/signing/src/certificate.rs index 7990e37..68693a8 100644 --- a/crates/ascapi/src/certificate.rs +++ b/crates/signing/src/certificate.rs @@ -18,13 +18,14 @@ //! regenerated. //! //! See [`crate::csr`] for producing the `csr_content` that -//! [`Client::create_certificate`] wants. +//! [`CertificatesApi::create_certificate`] wants. -use crate::client::Client; -use crate::error::Result; -use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource}; +use async_trait::async_trait; use reqwest::Method; use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; pub const RESOURCE_TYPE: &str = "certificates"; @@ -123,7 +124,14 @@ pub struct CertificateCreateAttributes { pub certificate_type: CertificateType, } -impl Client { +/// Signing certificates. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait CertificatesApi { /// `GET /v1/certificates`, optionally filtered by type. /// /// Apple returns expired certificates too, and gives no filter for @@ -131,7 +139,40 @@ impl Client { /// for yourself. That is deliberate on their side: an expired /// certificate is still meaningful for identifying what a previously /// shipped build was signed with. - pub async fn list_certificates( + async fn list_certificates( + &self, + filter_type: Option, + ) -> Result>; + + /// `POST /v1/certificates` — issues a new certificate for a CSR. + /// + /// The response carries `certificate_content`, the only copy of the + /// signed certificate you are handed at creation time. It can be + /// fetched again later with [`CertificatesApi::list_certificates`], but the + /// private key it belongs to cannot, so store the two together + /// immediately. + /// + /// Apple caps how many certificates of each type a team may hold at + /// once (two for distribution). At the cap this returns a 409; revoke + /// something with [`CertificatesApi::revoke_certificate`] first. + async fn create_certificate( + &self, + attributes: CertificateCreateAttributes, + ) -> Result; + + /// `DELETE /v1/certificates/{id}` — revokes a certificate. + /// + /// Irreversible, and wider-reaching than it looks: every provisioning + /// profile that embeds this certificate stops working for everyone on + /// the team at once, including builds already in CI. Revoking is for + /// reclaiming a slot at Apple's per-type cap or for a key you believe + /// is compromised, not for tidying up. + async fn revoke_certificate(&self, id: &str) -> Result<()>; +} + +#[async_trait] +impl CertificatesApi for Client { + async fn list_certificates( &self, filter_type: Option, ) -> Result> { @@ -149,18 +190,7 @@ impl Client { Ok(doc.data) } - /// `POST /v1/certificates` — issues a new certificate for a CSR. - /// - /// The response carries `certificate_content`, the only copy of the - /// signed certificate you are handed at creation time. It can be - /// fetched again later with [`Client::list_certificates`], but the - /// private key it belongs to cannot, so store the two together - /// immediately. - /// - /// Apple caps how many certificates of each type a team may hold at - /// once (two for distribution). At the cap this returns a 409; revoke - /// something with [`Client::revoke_certificate`] first. - pub async fn create_certificate( + async fn create_certificate( &self, attributes: CertificateCreateAttributes, ) -> Result { @@ -177,14 +207,7 @@ impl Client { Ok(doc.data) } - /// `DELETE /v1/certificates/{id}` — revokes a certificate. - /// - /// Irreversible, and wider-reaching than it looks: every provisioning - /// profile that embeds this certificate stops working for everyone on - /// the team at once, including builds already in CI. Revoking is for - /// reclaiming a slot at Apple's per-type cap or for a key you believe - /// is compromised, not for tidying up. - pub async fn revoke_certificate(&self, id: &str) -> Result<()> { + async fn revoke_certificate(&self, id: &str) -> Result<()> { self.request_no_content( Method::DELETE, &format!("/v1/certificates/{id}"), diff --git a/crates/ascapi/src/csr.rs b/crates/signing/src/csr.rs similarity index 99% rename from crates/ascapi/src/csr.rs rename to crates/signing/src/csr.rs index 1ae9411..41beb8c 100644 --- a/crates/ascapi/src/csr.rs +++ b/crates/signing/src/csr.rs @@ -20,10 +20,10 @@ //! [`rsa`] crate and is handed to rcgen as an externally supplied key //! pair purely for the CSR's self-signature. -use crate::error::{Error, Result}; use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; use rsa::pkcs8::{EncodePrivateKey, LineEnding}; use rsa::RsaPrivateKey; +use smbcloud_ascapi_core::{Error, Result}; use zeroize::Zeroizing; /// Apple rejects anything else. Not a tunable. diff --git a/crates/signing/src/lib.rs b/crates/signing/src/lib.rs new file mode 100644 index 0000000..032f809 --- /dev/null +++ b/crates/signing/src/lib.rs @@ -0,0 +1,22 @@ +//! Apple code signing through App Store Connect. +//! +//! Two halves that only make sense together: [`csr`] generates an RSA key +//! pair and a signing request locally, and [`certificate`] asks Apple to +//! certify it. +//! +//! The division of labour is the whole point. Apple never receives, and +//! never returns, a private key. You keep it; they vouch for its public +//! half. A certificate whose key file is lost is permanently unusable, and +//! an expired certificate cannot be renewed, only replaced by a new key +//! pair and a new request. +//! +//! The API calls are an extension trait on +//! [`Client`](smbcloud_ascapi_core::Client), since that type belongs to +//! the core crate. Import [`prelude`] to call them. + +pub mod certificate; +pub mod csr; + +pub mod prelude { + pub use crate::certificate::CertificatesApi; +} diff --git a/server.json b/server.json new file mode 100644 index 0000000..d7c2285 --- /dev/null +++ b/server.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.smbcloudXYZ/ascapi", + "title": "The App Stores Cooolest API", + "description": "Forget Fastlane, this is the app stores coolest API.", + "version": "0.1.0", + "websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-ascapi", + "repository": { + "url": "https://github.com/smbcloudXYZ/smbcloud-ascapi", + "source": "github", + "id": "1303134569" + }, + "packages": [ + { + "registryType": "cargo", + "registryBaseUrl": "https://crates.io", + "identifier": "smbcloud-ascapi-cli", + "version": "0.1.0", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "named", + "name": "--mcp", + "description": "Run App Store Connect Signing as an MCP server over stdio.", + "isRequired": true + } + ], + "environmentVariables": [ + { + "name": "ASC_API_KEY", + "description": "App Store Connect API key ID (Users and Access → Integrations → App Store Connect API).", + "isRequired": true, + "isSecret": false + }, + { + "name": "ASC_ISSUER_ID", + "description": "App Store Connect API issuer ID, shown on the same page as the key.", + "isRequired": true, + "isSecret": false + }, + { + "name": "ASC_PRIVATE_KEY_PATH", + "description": "Path to the .p8 private key for that API key. Defaults to ~/.appstoreconnect/private_keys/AuthKey_.p8, matching Xcode's own convention.", + "isRequired": false, + "isSecret": true + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "categories": [ + "developer-tools", + "automation" + ], + "keywords": [ + "code signing", + "App Store Connect", + "signing certificate", + "provisioning", + "iOS release", + "macOS release", + "certificate signing request", + "fastlane match alternative", + "Apple developer", + "CSR" + ] + } + } +}