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" + ] + } + } +}