Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [4.1.2] - 2026-07-21

### Changed

- Tightened public input contracts across App Store metadata, TestFlight, commerce, provisioning, users, webhooks, analytics, metrics, and Xcode Cloud so documented enums, nullable values, canonical identifiers, list bounds, and relationship linkages are validated before network access.
- App, review, accessibility, pricing, provisioning, export-compliance, TestFlight, and commerce projections now preserve sparse Apple responses, honest paging totals, current App Store Connect 4.4.1 fields, and validated resource lineage without inventing missing values.
- POST and PATCH calls now require each Apple operation's exact success status, never replay after an ambiguous outcome, and expose machine-readable `unknown` or `committed_unverified` recovery state when completion cannot be proved.

### Fixed

- Preserve explicit JSON `null` separately from omission for supported Apple PATCH and create attributes, including webhook, user, provisioning, TestFlight, subscription, in-app purchase, and App Store version fields.
- Reject mixed-type relationship ID arrays, invalid email and resource identifiers, out-of-range list controls, duplicate linkages, malformed pagination, and response identity mismatches instead of silently dropping, clamping, or accepting them.
- Keep export-compliance build attachment on Apple's supported `PATCH /v1/builds/{id}` HTTP 200 contract, and retain inspection guidance for every ambiguous or unverifiable mutation.
- Expose Xcode metrics goal keys, current App attributes, complete metadata localizations, and paging-aware review, app, provisioning, and availability totals.
- Release automation now carries exact release IDs through draft creation, normalization, and publication, and paginates existing-release lookup to keep reruns reliable as release history grows.

### Compatibility

- The public catalog remains at 502 tools and no worker filter key, tool name, or successful response key is removed.
- Previously accepted malformed or out-of-range inputs now fail locally; explicit null clears only attributes Apple marks nullable, while omission continues to leave update fields unchanged.
- Clients should inspect `operationCommitState`, `outcomeUnknown`, `operationCommitted`, `retrySafe`, and `inspectionRequired` before retrying a failed mutation.

## [4.1.1] - 2026-07-21

### Changed
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
```bash
# 1. Install via Mint
brew install mint
mint install zelentsov-dev/asc-mcp@v4.1.1
mint install zelentsov-dev/asc-mcp@v4.1.2

# 2. Add to Claude Code with env vars (simplest setup)
claude mcp add asc-mcp \
Expand Down Expand Up @@ -88,7 +88,7 @@ Or use a JSON config file — see [Configuration](#configuration) below.
brew install mint

# Install asc-mcp from GitHub
mint install zelentsov-dev/asc-mcp@v4.1.1
mint install zelentsov-dev/asc-mcp@v4.1.2

# Register in Claude Code
claude mcp add asc-mcp -- ~/.mint/bin/asc-mcp
Expand All @@ -99,13 +99,13 @@ To install a specific branch or tag:
```bash
mint install zelentsov-dev/asc-mcp@main # main branch
mint install zelentsov-dev/asc-mcp@develop # develop branch
mint install zelentsov-dev/asc-mcp@v4.1.1 # specific tag
mint install zelentsov-dev/asc-mcp@v4.1.2 # specific tag
```

To update to the latest version:

```bash
mint install zelentsov-dev/asc-mcp@v4.1.1 --force
mint install zelentsov-dev/asc-mcp@v4.1.2 --force
```

### Migrating from v4.0.x
Expand Down Expand Up @@ -451,7 +451,7 @@ swift run asc-mcp openapi-contract-check \

The manifest is pinned to Apple API 4.4.1 by version, SHA-256, path count, and operation count. It currently maps 476 Apple operations, explicitly defers 424, and scopes out 363, covering all 1,263 operations without overlap. CI fails when the Apple document changes, a mapped operation moves or disappears, a public tool or worker drifts from the manifest, an input field loses its binding, response lineage becomes invalid, or a deferred decision expires. Unexposed optional Apple parameters are warnings so they remain visible in the generated backlog.

Manifest schema v2 also accounts for every optional Apple query and request-body input as publicly bound, internally controlled, intentionally omitted with a reviewed reason, or still unclassified. The checked-in `optionalInputCoveragePin` records the exact current totals and a SHA-256 digest of the sorted input identities and dispositions; `--strict` rejects a missing pin or any count- or identity-level drift. The pin makes phased remediation auditable and regression-safe, but it is not a claim that every optional Apple input is already public. The v4.1.1 pin is 2,905 total: 1,103 bound, 40 internally controlled, 1,762 intentionally omitted, and 0 unclassified. Its identity SHA-256 is `733827d5ad0fc66d541bdd51922567a7f7cd7a941b882ca2e89a0ea6d6a1cfee`.
Manifest schema v2 also accounts for every optional Apple query and request-body input as publicly bound, internally controlled, intentionally omitted with a reviewed reason, or still unclassified. The checked-in `optionalInputCoveragePin` records the exact current totals and a SHA-256 digest of the sorted input identities and dispositions; `--strict` rejects a missing pin or any count- or identity-level drift. The pin makes phased remediation auditable and regression-safe, but it is not a claim that every optional Apple input is already public. The v4.1.2 pin is 2,905 total: 1,108 bound, 40 internally controlled, 1,757 intentionally omitted, and 0 unclassified. Its identity SHA-256 is `2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368`.

`--strict` is the merge- and tag-time release gate. Every declared `target` or `broken` tool remains an error in reports, and a regression test pins their exact state. The current baseline has no `target` or `broken` implementations and no implementation drift, so any implementation that leaves `asBuilt`, any structural contract error, or any optional-input coverage drift blocks both merges and releases. `--structural-strict` remains available only for local phased remediation work.

Expand Down
55 changes: 54 additions & 1 deletion Sources/asc-mcp/Core/ASCError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ public enum ASCError: LocalizedError, Sendable {
case api(String, Int)
case apiResponse(ASCAPIErrorResponse, Int)
case network(String)
indirect case mutationOutcomeUnknown(method: String, cause: ASCError)
indirect case mutationCommittedUnverified(
method: String,
expectedStatusCode: Int,
actualStatusCode: Int,
cause: ASCError?
)
indirect case deleteOutcomeUnknown(ASCError)
case deleteCommittedUnverified(statusCode: Int)
case authentication(String)
Expand All @@ -23,10 +30,23 @@ public enum ASCError: LocalizedError, Sendable {
return "API error (\(code)): \(message)"
case .network(let message):
return "Network error: \(message)"
case .mutationOutcomeUnknown(let method, let cause):
return "\(method) outcome is unknown: \(cause.localizedDescription) Inspect the exact target before another mutation attempt."
case .mutationCommittedUnverified(
let method,
let expectedStatusCode,
let actualStatusCode,
let cause
):
if let cause, actualStatusCode == expectedStatusCode {
return "\(method) was accepted with HTTP \(actualStatusCode), but response verification failed and completion is unverified. Inspect the exact target before another mutation attempt. Cause: \(cause.localizedDescription)"
}
let suffix = cause.map { " Cause: \($0.localizedDescription)" } ?? ""
return "\(method) was accepted with HTTP \(actualStatusCode), but HTTP \(expectedStatusCode) was required and completion is unverified. Inspect the exact target before another mutation attempt.\(suffix)"
case .deleteOutcomeUnknown(let cause):
return "DELETE outcome is unknown: \(cause.localizedDescription) Inspect the exact target before another delete attempt."
case .deleteCommittedUnverified(let statusCode):
return "DELETE was accepted with unexpected HTTP \(statusCode), but completion is unverified. Inspect the exact target before another delete attempt."
return "DELETE returned HTTP \(statusCode), but completion is unverified because the success response did not match the required contract. Inspect the exact target before another delete attempt."
case .authentication(let message):
return "Authentication error: \(message)"
case .parsing(let message):
Expand All @@ -48,13 +68,45 @@ public enum ASCError: LocalizedError, Sendable {
])
case .network(let message):
return structuredError(type: "network", message: message)
case .mutationOutcomeUnknown(let method, let cause):
return .object([
"type": .string("mutation_unknown"),
"method": .string(method),
"operationCommitState": .string("unknown"),
"outcomeUnknown": .bool(true),
"retrySafe": .bool(false),
"inspectionRequired": .bool(true),
"cause": cause.structuredValue
])
case .mutationCommittedUnverified(
let method,
let expectedStatusCode,
let actualStatusCode,
let cause
):
var object: [String: Value] = [
"type": .string("mutation_unverified"),
"method": .string(method),
"expectedStatusCode": .int(expectedStatusCode),
"statusCode": .int(actualStatusCode),
"operationCommitState": .string("committed_unverified"),
"operationCommitted": .bool(true),
"outcomeUnknown": .bool(false),
"retrySafe": .bool(false),
"inspectionRequired": .bool(true)
]
if let cause {
object["cause"] = cause.structuredValue
}
return .object(object)
case .deleteOutcomeUnknown(let cause):
return .object([
"type": .string("delete_unknown"),
"method": .string("DELETE"),
"operationCommitState": .string("unknown"),
"outcomeUnknown": .bool(true),
"retrySafe": .bool(false),
"inspectionRequired": .bool(true),
"cause": cause.structuredValue
])
case .deleteCommittedUnverified(let statusCode):
Expand All @@ -64,6 +116,7 @@ public enum ASCError: LocalizedError, Sendable {
"statusCode": .int(statusCode),
"operationCommitState": .string("committed_unverified"),
"operationCommitted": .bool(true),
"outcomeUnknown": .bool(false),
"retrySafe": .bool(false),
"inspectionRequired": .bool(true)
])
Expand Down
2 changes: 1 addition & 1 deletion Sources/asc-mcp/Core/ServerVersion.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation

enum ServerVersion {
static let current = "4.1.1"
static let current = "4.1.2"
}
4 changes: 4 additions & 0 deletions Sources/asc-mcp/Helpers/ASCNonIdempotentWriteRecovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ enum ASCNonIdempotentWriteRecovery {
return .outcomeUnknown
}
switch ascError {
case .mutationCommittedUnverified, .deleteCommittedUnverified:
return .committedUnverified
case .mutationOutcomeUnknown, .deleteOutcomeUnknown:
return .outcomeUnknown
case .api(_, let statusCode)
where (400...499).contains(statusCode) && statusCode != 408:
return .rejected
Expand Down
11 changes: 11 additions & 0 deletions Sources/asc-mcp/Helpers/CommerceInputValidation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import MCP

func validatedCommerceLimit(_ value: Value?, defaultValue: Int, maximum: Int) throws -> Int {
guard let value else {
return defaultValue
}
guard let limit = value.intValue, (1...maximum).contains(limit) else {
throw ASCError.parsing("'limit' must be an integer from 1 through \(maximum)")
}
return limit
}
15 changes: 15 additions & 0 deletions Sources/asc-mcp/Helpers/MCPResultBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,25 @@ enum MCPResult {
object["operationCommitState"] = .string("unknown")
object["outcomeUnknown"] = .bool(true)
object["retrySafe"] = .bool(false)
object["inspectionRequired"] = .bool(true)
}
if case .mutationOutcomeUnknown = ascError {
object["operationCommitState"] = .string("unknown")
object["outcomeUnknown"] = .bool(true)
object["retrySafe"] = .bool(false)
object["inspectionRequired"] = .bool(true)
}
if case .deleteCommittedUnverified = ascError {
object["operationCommitState"] = .string("committed_unverified")
object["operationCommitted"] = .bool(true)
object["outcomeUnknown"] = .bool(false)
object["retrySafe"] = .bool(false)
object["inspectionRequired"] = .bool(true)
}
if case .mutationCommittedUnverified = ascError {
object["operationCommitState"] = .string("committed_unverified")
object["operationCommitted"] = .bool(true)
object["outcomeUnknown"] = .bool(false)
object["retrySafe"] = .bool(false)
object["inspectionRequired"] = .bool(true)
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ enum ToolMetadataPolicy {
"_upload",
"_generate",
"_deactivate",
"_disable",
"_delete",
"_remove",
"_revoke",
Expand Down Expand Up @@ -260,7 +261,9 @@ enum ToolMetadataPolicy {
"details": .object([:]),
"apps": .object(["type": .string("array"), "items": appSummarySchema]),
"count": .object(["type": .string("integer")]),
"totalCount": .object(["type": .string("integer")]),
"totalCount": .object([
"type": .array([.string("integer"), .string("null")])
]),
"hasNextPage": .object(["type": .string("boolean")]),
"next_url": .object(["type": .string("string")])
]),
Expand Down
4 changes: 4 additions & 0 deletions Sources/asc-mcp/Helpers/UploadTransactionRecovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,10 @@ private extension ASCError {
return cause.uploadRecoveryHTTPStatusCode
case .deleteCommittedUnverified(let statusCode):
return statusCode
case .mutationOutcomeUnknown(_, let cause):
return cause.uploadRecoveryHTTPStatusCode
case .mutationCommittedUnverified(_, _, let actualStatusCode, _):
return actualStatusCode
default:
return nil
}
Expand Down
16 changes: 8 additions & 8 deletions Sources/asc-mcp/Models/AppLifecycle/AppLifecycleModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ struct CreateAppStoreVersionRequest: Codable, Sendable {
struct Attributes: Codable, Sendable {
let platform: String
let versionString: String
let copyright: String?
let reviewType: String?
let releaseType: String?
let earliestReleaseDate: String?
let copyright: NullableAttributeValue?
let reviewType: NullableAttributeValue?
let releaseType: NullableAttributeValue?
let earliestReleaseDate: NullableAttributeValue?
let usesIdfa: NullableAttributeValue?
}
struct Relationships: Codable, Sendable {
Expand All @@ -37,10 +37,10 @@ struct CreateAppStoreVersionRequest: Codable, Sendable {
init(
platform: String,
versionString: String,
copyright: String? = nil,
reviewType: String? = nil,
releaseType: String?,
earliestReleaseDate: String?,
copyright: NullableAttributeValue? = nil,
reviewType: NullableAttributeValue? = nil,
releaseType: NullableAttributeValue? = nil,
earliestReleaseDate: NullableAttributeValue? = nil,
usesIdfa: NullableAttributeValue? = nil,
appId: String
) {
Expand Down
14 changes: 9 additions & 5 deletions Sources/asc-mcp/Models/AppStoreConnect/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ public struct ASCApp: Codable, Sendable {
public let subscriptionStatusUrlVersion: String?
public let subscriptionStatusUrlForSandbox: String?
public let subscriptionStatusUrlVersionForSandbox: String?
public let availableInNewTerritories: Bool?
public let accessibilityUrl: String?
public let contentRightsDeclaration: String?
public let streamlinedPurchasingEnabled: Bool?
}

public struct Relationships: Codable, Sendable {
Expand Down Expand Up @@ -189,7 +190,7 @@ public struct ASCAppsResponse: Codable, Sendable {
public let paging: Paging

public struct Paging: Codable, Sendable {
public let total: Int
public let total: Int?
public let limit: Int
}
}
Expand Down Expand Up @@ -231,9 +232,12 @@ extension ASCApp {
}

extension ASCAppsResponse {
/// Total number of apps
public var totalCount: Int {
return meta?.paging.total ?? data.count
/// Apple-reported total, or the current page count only when the page is terminal
public var totalCount: Int? {
if let total = meta?.paging.total {
return total
}
return links.next == nil ? data.count : nil
}

/// Whether there is a next page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public struct ASCAppStoreVersionLocalizationsResponse: Codable, Sendable {
public let paging: Paging?

public struct Paging: Codable, Sendable {
public let total: Int
public let total: Int?
public let limit: Int
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public struct ASCAppStoreVersionsResponse: Codable, Sendable {
public let paging: Paging?

public struct Paging: Codable, Sendable {
public let total: Int
public let total: Int?
public let limit: Int
}
}
Expand Down
Loading