diff --git a/CHANGELOG.md b/CHANGELOG.md index d188b29..f24bc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2455d49..071d725 100644 --- a/README.md +++ b/README.md @@ -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 \ @@ -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 @@ -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 @@ -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. diff --git a/Sources/asc-mcp/Core/ASCError.swift b/Sources/asc-mcp/Core/ASCError.swift index e4f6089..d0d9e68 100644 --- a/Sources/asc-mcp/Core/ASCError.swift +++ b/Sources/asc-mcp/Core/ASCError.swift @@ -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) @@ -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): @@ -48,6 +68,37 @@ 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"), @@ -55,6 +106,7 @@ public enum ASCError: LocalizedError, Sendable { "operationCommitState": .string("unknown"), "outcomeUnknown": .bool(true), "retrySafe": .bool(false), + "inspectionRequired": .bool(true), "cause": cause.structuredValue ]) case .deleteCommittedUnverified(let statusCode): @@ -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) ]) diff --git a/Sources/asc-mcp/Core/ServerVersion.swift b/Sources/asc-mcp/Core/ServerVersion.swift index d9fc0a9..fbde19e 100644 --- a/Sources/asc-mcp/Core/ServerVersion.swift +++ b/Sources/asc-mcp/Core/ServerVersion.swift @@ -1,5 +1,5 @@ import Foundation enum ServerVersion { - static let current = "4.1.1" + static let current = "4.1.2" } diff --git a/Sources/asc-mcp/Helpers/ASCNonIdempotentWriteRecovery.swift b/Sources/asc-mcp/Helpers/ASCNonIdempotentWriteRecovery.swift index fcb9e83..d4d4ff8 100644 --- a/Sources/asc-mcp/Helpers/ASCNonIdempotentWriteRecovery.swift +++ b/Sources/asc-mcp/Helpers/ASCNonIdempotentWriteRecovery.swift @@ -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 diff --git a/Sources/asc-mcp/Helpers/CommerceInputValidation.swift b/Sources/asc-mcp/Helpers/CommerceInputValidation.swift new file mode 100644 index 0000000..a72aa03 --- /dev/null +++ b/Sources/asc-mcp/Helpers/CommerceInputValidation.swift @@ -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 +} diff --git a/Sources/asc-mcp/Helpers/MCPResultBuilder.swift b/Sources/asc-mcp/Helpers/MCPResultBuilder.swift index 80ddafb..4bc5fa1 100644 --- a/Sources/asc-mcp/Helpers/MCPResultBuilder.swift +++ b/Sources/asc-mcp/Helpers/MCPResultBuilder.swift @@ -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) } diff --git a/Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift b/Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift index 7292234..f0a8325 100644 --- a/Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift +++ b/Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift @@ -130,6 +130,7 @@ enum ToolMetadataPolicy { "_upload", "_generate", "_deactivate", + "_disable", "_delete", "_remove", "_revoke", @@ -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")]) ]), diff --git a/Sources/asc-mcp/Helpers/UploadTransactionRecovery.swift b/Sources/asc-mcp/Helpers/UploadTransactionRecovery.swift index e835302..a2e7498 100644 --- a/Sources/asc-mcp/Helpers/UploadTransactionRecovery.swift +++ b/Sources/asc-mcp/Helpers/UploadTransactionRecovery.swift @@ -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 } diff --git a/Sources/asc-mcp/Models/AppLifecycle/AppLifecycleModels.swift b/Sources/asc-mcp/Models/AppLifecycle/AppLifecycleModels.swift index 27073c2..868d1e9 100644 --- a/Sources/asc-mcp/Models/AppLifecycle/AppLifecycleModels.swift +++ b/Sources/asc-mcp/Models/AppLifecycle/AppLifecycleModels.swift @@ -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 { @@ -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 ) { diff --git a/Sources/asc-mcp/Models/AppStoreConnect/AppModel.swift b/Sources/asc-mcp/Models/AppStoreConnect/AppModel.swift index 40d5a4d..55d36c3 100644 --- a/Sources/asc-mcp/Models/AppStoreConnect/AppModel.swift +++ b/Sources/asc-mcp/Models/AppStoreConnect/AppModel.swift @@ -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 { @@ -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 } } @@ -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 diff --git a/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionLocalizationModel.swift b/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionLocalizationModel.swift index b5005d8..d8ecf37 100644 --- a/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionLocalizationModel.swift +++ b/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionLocalizationModel.swift @@ -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 } } diff --git a/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionModel.swift b/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionModel.swift index 2e2bf79..9e3b93e 100644 --- a/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionModel.swift +++ b/Sources/asc-mcp/Models/AppStoreConnect/AppStoreVersionModel.swift @@ -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 } } diff --git a/Sources/asc-mcp/Models/Builds/BuildBetaDetailModels.swift b/Sources/asc-mcp/Models/Builds/BuildBetaDetailModels.swift index 80c0f9b..d7bc0cb 100644 --- a/Sources/asc-mcp/Models/Builds/BuildBetaDetailModels.swift +++ b/Sources/asc-mcp/Models/Builds/BuildBetaDetailModels.swift @@ -12,7 +12,7 @@ public struct ASCBuildBetaDetailResponse: Codable, Sendable { public struct ASCBuildBetaDetail: Codable, Sendable { public let type: String public let id: String - public let attributes: BuildBetaDetailAttributes + public let attributes: BuildBetaDetailAttributes? public let relationships: BuildBetaDetailRelationships? } @@ -47,7 +47,7 @@ public struct ASCBetaBuildLocalizationResponse: Codable, Sendable { public struct ASCBetaBuildLocalization: Codable, Sendable { public let type: String public let id: String - public let attributes: BetaBuildLocalizationAttributes + public let attributes: BetaBuildLocalizationAttributes? public let relationships: BetaBuildLocalizationRelationships? } @@ -75,7 +75,7 @@ public struct ASCBetaGroupsResponse: Codable, Sendable { public struct ASCBetaGroup: Codable, Sendable { public let type: String public let id: String - public let attributes: BetaGroupAttributes + public let attributes: BetaGroupAttributes? public let relationships: BetaGroupRelationships? } @@ -117,7 +117,7 @@ public struct ASCBetaTestersResponse: Codable, Sendable { public struct ASCBetaTester: Codable, Sendable { public let type: String public let id: String - public let attributes: BetaTesterAttributes + public let attributes: BetaTesterAttributes? public let relationships: BetaTesterRelationships? } @@ -212,7 +212,7 @@ public struct CreateBetaBuildLocalizationRequest: Codable, Sendable { public struct CreateBetaBuildLocalizationAttributes: Codable, Sendable { public let locale: String - public let whatsNew: String? + public let whatsNew: ASCNullable? } public struct CreateBetaBuildLocalizationRelationships: Codable, Sendable { @@ -235,7 +235,7 @@ public struct UpdateBetaBuildLocalizationRequest: Codable, Sendable { } public struct BetaBuildLocalizationUpdateAttributes: Codable, Sendable { - public let whatsNew: String? + public let whatsNew: ASCNullable? } } @@ -292,13 +292,13 @@ public struct UpdateBetaGroupRequest: Codable, Sendable { } public struct UpdateBetaGroupAttributes: Codable, Sendable { - public let name: String? - public let publicLinkEnabled: Bool? - public let publicLinkLimitEnabled: Bool? - public let publicLinkLimit: Int? - public let feedbackEnabled: Bool? - public let iosBuildsAvailableForAppleSiliconMac: Bool? - public let iosBuildsAvailableForAppleVision: Bool? + public let name: ASCNullable? + public let publicLinkEnabled: ASCNullable? + public let publicLinkLimitEnabled: ASCNullable? + public let publicLinkLimit: ASCNullable? + public let feedbackEnabled: ASCNullable? + public let iosBuildsAvailableForAppleSiliconMac: ASCNullable? + public let iosBuildsAvailableForAppleVision: ASCNullable? } } @@ -318,7 +318,7 @@ public struct UpdateBuildBetaDetailRequest: Codable, Sendable { } public struct BuildBetaDetailUpdateAttributes: Codable, Sendable { - public let autoNotifyEnabled: Bool? + public let autoNotifyEnabled: ASCNullable? } } diff --git a/Sources/asc-mcp/Models/ExportCompliance/ExportComplianceModels.swift b/Sources/asc-mcp/Models/ExportCompliance/ExportComplianceModels.swift index 71490b4..7aefd5e 100644 --- a/Sources/asc-mcp/Models/ExportCompliance/ExportComplianceModels.swift +++ b/Sources/asc-mcp/Models/ExportCompliance/ExportComplianceModels.swift @@ -4,12 +4,17 @@ import Foundation struct ASCExportComplianceDeclarationsResponse: Codable, Sendable { let data: [ASCExportComplianceDeclaration] - let links: ASCPagedDocumentLinks? + let links: ASCPagedDocumentLinks let meta: ASCPagingInformation? } struct ASCExportComplianceDeclarationResponse: Codable, Sendable { let data: ASCExportComplianceDeclaration + let links: ASCExportComplianceDocumentLinks +} + +struct ASCExportComplianceDocumentLinks: Codable, Sendable { + let `self`: String } struct ASCExportComplianceDeclaration: Codable, Sendable { @@ -36,6 +41,7 @@ struct ASCExportComplianceDeclaration: Codable, Sendable { struct ASCExportComplianceDocumentResponse: Codable, Sendable { let data: ASCExportComplianceDocument + let links: ASCExportComplianceDocumentLinks } struct ASCExportComplianceDocument: Codable, Sendable { diff --git a/Sources/asc-mcp/Models/InAppPurchases/InAppPurchaseModels.swift b/Sources/asc-mcp/Models/InAppPurchases/InAppPurchaseModels.swift index c0ea714..b22284c 100644 --- a/Sources/asc-mcp/Models/InAppPurchases/InAppPurchaseModels.swift +++ b/Sources/asc-mcp/Models/InAppPurchases/InAppPurchaseModels.swift @@ -47,8 +47,8 @@ public struct CreateInAppPurchaseV2Request: Codable, Sendable { public let name: String public let productId: String public let inAppPurchaseType: String - public let reviewNote: String? - public let familySharable: Bool? + public let reviewNote: ASCNullable? + public let familySharable: ASCNullable? } public struct CreateIAPRelationships: Codable, Sendable { @@ -71,9 +71,9 @@ public struct UpdateInAppPurchaseV2Request: Codable, Sendable { } public struct UpdateIAPAttributes: Codable, Sendable { - public let name: String? - public let reviewNote: String? - public let familySharable: Bool? + public let name: ASCNullable? + public let reviewNote: ASCNullable? + public let familySharable: ASCNullable? } } diff --git a/Sources/asc-mcp/Models/Metrics/MetricsModels.swift b/Sources/asc-mcp/Models/Metrics/MetricsModels.swift index 0a9f3cc..1c8ea88 100644 --- a/Sources/asc-mcp/Models/Metrics/MetricsModels.swift +++ b/Sources/asc-mcp/Models/Metrics/MetricsModels.swift @@ -54,10 +54,17 @@ public struct ASCMetricCategory: Codable, Sendable { public struct ASCMetric: Codable, Sendable { public let identifier: String? + public let goalKeys: [ASCMetricGoalKey]? public let unit: ASCMetricUnit? public let datasets: [ASCMetricDataset]? } +public struct ASCMetricGoalKey: Codable, Sendable { + public let goalKey: String? + public let lowerBound: Int? + public let upperBound: Int? +} + public struct ASCMetricUnit: Codable, Sendable { public let identifier: String? public let displayName: String? diff --git a/Sources/asc-mcp/Models/Provisioning/ProvisioningModels.swift b/Sources/asc-mcp/Models/Provisioning/ProvisioningModels.swift index db32c3c..0f863c9 100644 --- a/Sources/asc-mcp/Models/Provisioning/ProvisioningModels.swift +++ b/Sources/asc-mcp/Models/Provisioning/ProvisioningModels.swift @@ -6,6 +6,7 @@ import Foundation public struct ASCBundleIdsResponse: Codable, Sendable { public let data: [ASCBundleId] public let links: ASCPagedDocumentLinks? + public let meta: ASCPagingInformation? } /// Bundle ID single response @@ -17,7 +18,7 @@ public struct ASCBundleIdResponse: Codable, Sendable { public struct ASCBundleId: Codable, Sendable { public let type: String public let id: String - public let attributes: BundleIdAttributes + public let attributes: BundleIdAttributes? } /// Bundle ID attributes @@ -41,7 +42,46 @@ public struct CreateBundleIdRequest: Codable, Sendable { public let name: String public let identifier: String public let platform: String - public let seedId: String? + public let seedId: ASCNullable? + + /// Creates bundle ID attributes from ordinary optional values. + public init(name: String, identifier: String, platform: String, seedId: String?) { + self.name = name + self.identifier = identifier + self.platform = platform + self.seedId = seedId.map { .value($0) } + } + + /// Creates bundle ID attributes while preserving omission and explicit null. + public init( + name: String, + identifier: String, + platform: String, + nullableSeedId: ASCNullable? + ) { + self.name = name + self.identifier = identifier + self.platform = platform + self.seedId = nullableSeedId + } + + enum CodingKeys: String, CodingKey { + case name + case identifier + case platform + case seedId + } + + /// Decodes bundle ID attributes while preserving an explicit null seed ID. + /// - Parameter decoder: Decoder containing the bundle ID attributes. + /// - Throws: A decoding error when a present attribute has an invalid type. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + identifier = try container.decode(String.self, forKey: .identifier) + platform = try container.decode(String.self, forKey: .platform) + seedId = try container.decodeASCNullable(String.self, forKey: .seedId) + } } } @@ -51,6 +91,7 @@ public struct CreateBundleIdRequest: Codable, Sendable { public struct ASCDevicesResponse: Codable, Sendable { public let data: [ASCDevice] public let links: ASCPagedDocumentLinks? + public let meta: ASCPagingInformation? } /// Device single response @@ -62,7 +103,7 @@ public struct ASCDeviceResponse: Codable, Sendable { public struct ASCDevice: Codable, Sendable { public let type: String public let id: String - public let attributes: DeviceAttributes + public let attributes: DeviceAttributes? } /// Device attributes @@ -103,8 +144,37 @@ public struct UpdateDeviceRequest: Codable, Sendable { } public struct UpdateDeviceAttributes: Codable, Sendable { - public let name: String? - public let status: String? + public let name: ASCNullable? + public let status: ASCNullable? + + /// Creates device update attributes from ordinary optional values. + public init(name: String? = nil, status: String? = nil) { + self.name = name.map { .value($0) } + self.status = status.map { .value($0) } + } + + /// Creates device update attributes while preserving omission and explicit null. + public init( + nullableName: ASCNullable?, + nullableStatus: ASCNullable? + ) { + self.name = nullableName + self.status = nullableStatus + } + + enum CodingKeys: String, CodingKey { + case name + case status + } + + /// Decodes device update attributes while preserving explicit null values. + /// - Parameter decoder: Decoder containing the device update attributes. + /// - Throws: A decoding error when a present attribute has an invalid type. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeASCNullable(String.self, forKey: .name) + status = try container.decodeASCNullable(String.self, forKey: .status) + } } } @@ -114,13 +184,14 @@ public struct UpdateDeviceRequest: Codable, Sendable { public struct ASCCertificatesResponse: Codable, Sendable { public let data: [ASCCertificate] public let links: ASCPagedDocumentLinks? + public let meta: ASCPagingInformation? } /// Certificate resource public struct ASCCertificate: Codable, Sendable { public let type: String public let id: String - public let attributes: CertificateAttributes + public let attributes: CertificateAttributes? } /// Certificate attributes @@ -141,13 +212,14 @@ public struct CertificateAttributes: Codable, Sendable { public struct ASCProfilesResponse: Codable, Sendable { public let data: [ASCProfile] public let links: ASCPagedDocumentLinks? + public let meta: ASCPagingInformation? } /// Profile resource public struct ASCProfile: Codable, Sendable { public let type: String public let id: String - public let attributes: ProfileAttributes + public let attributes: ProfileAttributes? } /// Profile attributes @@ -219,6 +291,7 @@ public struct CreateProfileRequest: Codable, Sendable { public struct ASCBundleIdCapabilitiesResponse: Codable, Sendable { public let data: [ASCBundleIdCapability] public let links: ASCPagedDocumentLinks? + public let meta: ASCPagingInformation? } /// Single bundle ID capability response @@ -230,7 +303,7 @@ public struct ASCBundleIdCapabilityResponse: Codable, Sendable { public struct ASCBundleIdCapability: Codable, Sendable { public let type: String public let id: String - public let attributes: BundleIdCapabilityAttributes + public let attributes: BundleIdCapabilityAttributes? } /// Bundle ID capability attributes @@ -275,7 +348,36 @@ public struct EnableCapabilityRequest: Codable, Sendable { public struct EnableCapabilityAttributes: Codable, Sendable { public let capabilityType: String - public let settings: [CapabilitySetting]? + public let settings: ASCNullable<[CapabilitySetting]>? + + /// Creates capability attributes from ordinary optional settings. + public init(capabilityType: String, settings: [CapabilitySetting]?) { + self.capabilityType = capabilityType + self.settings = settings.map { .value($0) } + } + + /// Creates capability attributes while preserving omission and explicit null. + public init( + capabilityType: String, + nullableSettings: ASCNullable<[CapabilitySetting]>? + ) { + self.capabilityType = capabilityType + self.settings = nullableSettings + } + + enum CodingKeys: String, CodingKey { + case capabilityType + case settings + } + + /// Decodes capability attributes while preserving explicit null settings. + /// - Parameter decoder: Decoder containing capability attributes. + /// - Throws: A decoding error when a present attribute has an invalid type. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + capabilityType = try container.decode(String.self, forKey: .capabilityType) + settings = try container.decodeASCNullable([CapabilitySetting].self, forKey: .settings) + } } public struct EnableCapabilityRelationships: Codable, Sendable { diff --git a/Sources/asc-mcp/Models/Shared/ASCNullable.swift b/Sources/asc-mcp/Models/Shared/ASCNullable.swift new file mode 100644 index 0000000..ac5b920 --- /dev/null +++ b/Sources/asc-mcp/Models/Shared/ASCNullable.swift @@ -0,0 +1,49 @@ +import Foundation + +/// A nullable App Store Connect value that preserves explicit JSON null separately from omission. +public enum ASCNullable: Codable, Sendable { + case value(Value) + case null + + /// Decodes either a concrete value or an explicit JSON null. + /// - Parameter decoder: Decoder positioned at the nullable value. + /// - Throws: A decoding error when the payload is neither the expected value nor null. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else { + self = .value(try container.decode(Value.self)) + } + } + + /// Encodes the concrete value or an explicit JSON null. + /// - Parameter encoder: Encoder that receives the nullable value. + /// - Throws: An encoding error when the value cannot be written. + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .value(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +extension ASCNullable: Equatable where Value: Equatable {} + +extension KeyedDecodingContainer { + func decodeASCNullable( + _ type: Value.Type, + forKey key: Key + ) throws -> ASCNullable? { + guard contains(key) else { + return nil + } + if try decodeNil(forKey: key) { + return .null + } + return .value(try decode(type, forKey: key)) + } +} diff --git a/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift b/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift index b9e1714..dd036a2 100644 --- a/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift +++ b/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift @@ -81,6 +81,6 @@ public struct UpdateIntroductoryOfferRequest: Codable, Sendable { } public struct Attributes: Codable, Sendable { - public let endDate: String? + public let endDate: NullableAttributeValue? } } diff --git a/Sources/asc-mcp/Models/Subscriptions/OfferCodeModels.swift b/Sources/asc-mcp/Models/Subscriptions/OfferCodeModels.swift index f98a037..4a3826b 100644 --- a/Sources/asc-mcp/Models/Subscriptions/OfferCodeModels.swift +++ b/Sources/asc-mcp/Models/Subscriptions/OfferCodeModels.swift @@ -139,7 +139,7 @@ public struct UpdateOfferCodeCustomCodeRequest: Codable, Sendable { } public struct Attributes: Codable, Sendable { - public let active: Bool? + public let active: ASCNullable? } } @@ -214,7 +214,7 @@ public struct UpdateOfferCodeRequest: Codable, Sendable { } public struct Attributes: Codable, Sendable { - public let active: Bool? + public let active: ASCNullable? } } diff --git a/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift b/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift index 87adb1a..7e3b97c 100644 --- a/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift +++ b/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift @@ -127,10 +127,10 @@ public struct CreateSubscriptionRequest: Codable, Sendable { public struct Attributes: Codable, Sendable { public let name: String public let productId: String - public let subscriptionPeriod: String - public let familySharable: Bool? - public let groupLevel: Int? - public let reviewNote: String? + public let subscriptionPeriod: ASCNullable? + public let familySharable: ASCNullable? + public let groupLevel: ASCNullable? + public let reviewNote: ASCNullable? } public struct Relationships: Codable, Sendable { @@ -153,11 +153,11 @@ public struct UpdateSubscriptionRequest: Codable, Sendable { } public struct Attributes: Codable, Sendable { - public let name: String? - public let familySharable: Bool? - public let groupLevel: Int? - public let reviewNote: String? - public let subscriptionPeriod: NullableAttributeValue? + public let name: ASCNullable? + public let familySharable: ASCNullable? + public let groupLevel: ASCNullable? + public let reviewNote: ASCNullable? + public let subscriptionPeriod: ASCNullable? } } diff --git a/Sources/asc-mcp/Models/Users/UserModels.swift b/Sources/asc-mcp/Models/Users/UserModels.swift index 3131d76..434e4d8 100644 --- a/Sources/asc-mcp/Models/Users/UserModels.swift +++ b/Sources/asc-mcp/Models/Users/UserModels.swift @@ -64,18 +64,46 @@ public struct UpdateUserRequest: Codable, Sendable { } public struct UpdateUserAttributes: Codable, Sendable { - public let roles: [String]? - public let allAppsVisible: Bool? - public let provisioningAllowed: Bool? + public let roles: ASCNullable<[String]>? + public let allAppsVisible: ASCNullable? + public let provisioningAllowed: ASCNullable? + /// Creates user update attributes from ordinary optional values. public init( roles: [String]? = nil, allAppsVisible: Bool? = nil, provisioningAllowed: Bool? = nil ) { - self.roles = roles - self.allAppsVisible = allAppsVisible - self.provisioningAllowed = provisioningAllowed + self.roles = roles.map { .value($0) } + self.allAppsVisible = allAppsVisible.map { .value($0) } + self.provisioningAllowed = provisioningAllowed.map { .value($0) } + } + + /// Creates user update attributes while preserving omission and explicit null. + public init( + nullableRoles: ASCNullable<[String]>?, + nullableAllAppsVisible: ASCNullable?, + nullableProvisioningAllowed: ASCNullable? + ) { + self.roles = nullableRoles + self.allAppsVisible = nullableAllAppsVisible + self.provisioningAllowed = nullableProvisioningAllowed + } + + enum CodingKeys: String, CodingKey { + case roles + case allAppsVisible + case provisioningAllowed + } + + /// Decodes user update attributes while preserving explicit null values. + /// - Parameter decoder: Decoder containing the user update attributes. + /// - Throws: A decoding error when a present attribute has an invalid type. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + roles = try container.decodeASCNullable([String].self, forKey: .roles) + allAppsVisible = try container.decodeASCNullable(Bool.self, forKey: .allAppsVisible) + provisioningAllowed = try container.decodeASCNullable(Bool.self, forKey: .provisioningAllowed) } } @@ -149,8 +177,64 @@ public struct CreateUserInvitationRequest: Codable, Sendable { public let firstName: String public let lastName: String public let roles: [String] - public let allAppsVisible: Bool? - public let provisioningAllowed: Bool? + public let allAppsVisible: ASCNullable? + public let provisioningAllowed: ASCNullable? + + /// Creates invitation attributes from ordinary optional access-control values. + public init( + email: String, + firstName: String, + lastName: String, + roles: [String], + allAppsVisible: Bool?, + provisioningAllowed: Bool? + ) { + self.email = email + self.firstName = firstName + self.lastName = lastName + self.roles = roles + self.allAppsVisible = allAppsVisible.map { .value($0) } + self.provisioningAllowed = provisioningAllowed.map { .value($0) } + } + + /// Creates invitation attributes while preserving omission and explicit null. + public init( + email: String, + firstName: String, + lastName: String, + roles: [String], + nullableAllAppsVisible: ASCNullable?, + nullableProvisioningAllowed: ASCNullable? + ) { + self.email = email + self.firstName = firstName + self.lastName = lastName + self.roles = roles + self.allAppsVisible = nullableAllAppsVisible + self.provisioningAllowed = nullableProvisioningAllowed + } + + enum CodingKeys: String, CodingKey { + case email + case firstName + case lastName + case roles + case allAppsVisible + case provisioningAllowed + } + + /// Decodes invitation attributes while preserving explicit null values. + /// - Parameter decoder: Decoder containing the invitation attributes. + /// - Throws: A decoding error when a present attribute has an invalid type. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + email = try container.decode(String.self, forKey: .email) + firstName = try container.decode(String.self, forKey: .firstName) + lastName = try container.decode(String.self, forKey: .lastName) + roles = try container.decode([String].self, forKey: .roles) + allAppsVisible = try container.decodeASCNullable(Bool.self, forKey: .allAppsVisible) + provisioningAllowed = try container.decodeASCNullable(Bool.self, forKey: .provisioningAllowed) + } } public struct CreateUserInvitationRelationships: Codable, Sendable { diff --git a/Sources/asc-mcp/Models/Webhooks/WebhookModels.swift b/Sources/asc-mcp/Models/Webhooks/WebhookModels.swift index 8dc34e8..4d00118 100644 --- a/Sources/asc-mcp/Models/Webhooks/WebhookModels.swift +++ b/Sources/asc-mcp/Models/Webhooks/WebhookModels.swift @@ -200,14 +200,62 @@ public struct ASCWebhookUpdateRequest: Codable, Sendable { } public struct Attributes: Codable, Sendable { - public let enabled: Bool? - public let eventTypes: [String]? - public let name: String? - public let secret: String? - public let url: String? + private let values: [String: JSONValue] + + public var enabled: Bool? { + guard case .bool(let value)? = values["enabled"] else { return nil } + return value + } + + public var eventTypes: [String]? { + guard case .array(let values)? = values["eventTypes"] else { return nil } + return values.compactMap { + guard case .string(let value) = $0 else { return nil } + return value + } + } + + public var name: String? { stringValue(for: "name") } + public var secret: String? { stringValue(for: "secret") } + public var url: String? { stringValue(for: "url") } + + public init( + enabled: Bool?, + eventTypes: [String]?, + name: String?, + secret: String?, + url: String? + ) { + var values: [String: JSONValue] = [:] + if let enabled { values["enabled"] = .bool(enabled) } + if let eventTypes { values["eventTypes"] = .array(eventTypes.map(JSONValue.string)) } + if let name { values["name"] = .string(name) } + if let secret { values["secret"] = .string(secret) } + if let url { values["url"] = .string(url) } + self.values = values + } + + init(values: [String: JSONValue]) { + self.values = values + } public var hasChanges: Bool { - enabled != nil || eventTypes != nil || name != nil || secret != nil || url != nil + !values.isEmpty + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + values = try container.decode([String: JSONValue].self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + private func stringValue(for key: String) -> String? { + guard case .string(let value)? = values[key] else { return nil } + return value } } } diff --git a/Sources/asc-mcp/Resources/OperationManifest/manifest.json b/Sources/asc-mcp/Resources/OperationManifest/manifest.json index b4b6bdf..227f597 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/manifest.json +++ b/Sources/asc-mcp/Resources/OperationManifest/manifest.json @@ -8,11 +8,11 @@ }, "optionalInputCoveragePin": { "total": 2905, - "bound": 1103, + "bound": 1108, "internalControl": 40, - "intentionallyOmitted": 1762, + "intentionallyOmitted": 1757, "unclassified": 0, - "identitySHA256": "733827d5ad0fc66d541bdd51922567a7f7cd7a941b882ca2e89a0ea6d6a1cfee" + "identitySHA256": "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368" }, "optionalParameterFamilyRules": [ { diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/accessibility.json b/Sources/asc-mcp/Resources/OperationManifest/tools/accessibility.json index 5618755..4cc5231 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/accessibility.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/accessibility.json @@ -141,6 +141,8 @@ "fields": [ { "outputField": "success", "localRole": "successfulRead" }, { "outputField": "accessibility_declarations", "operationId": "apps_accessibilityDeclarations_getToManyRelated", "jsonPointer": "/data" }, + { "outputField": "count", "localRole": "Count resources in the returned page." }, + { "outputField": "total", "operationId": "apps_accessibilityDeclarations_getToManyRelated", "jsonPointer": "/meta/paging/total" }, { "outputField": "next_url", "operationId": "apps_accessibilityDeclarations_getToManyRelated", "jsonPointer": "/links/next" } ] }, @@ -174,6 +176,8 @@ { "outputField": "success", "localRole": "successfulRead" }, { "outputField": "relationships", "operationId": "apps_accessibilityDeclarations_getToManyRelationship", "jsonPointer": "/data" }, { "outputField": "declaration_ids", "operationId": "apps_accessibilityDeclarations_getToManyRelationship", "jsonPointer": "/data/*/id" }, + { "outputField": "count", "localRole": "Count relationship linkages in the returned page." }, + { "outputField": "total", "operationId": "apps_accessibilityDeclarations_getToManyRelationship", "jsonPointer": "/meta/paging/total" }, { "outputField": "next_url", "operationId": "apps_accessibilityDeclarations_getToManyRelationship", "jsonPointer": "/links/next" } ] }, diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/analytics.json b/Sources/asc-mcp/Resources/OperationManifest/tools/analytics.json index 6b63457..ec69c26 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/analytics.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/analytics.json @@ -442,7 +442,7 @@ { "toolField": "limit", "sourceKind": "local", - "localRole": "clampedRawTSVRowProjectionLimit1To200" + "localRole": "validatedRawTSVRowProjectionLimit1To200" } ], "response": { @@ -487,7 +487,7 @@ } ] }, - "note": "All Apple 4.4.1 request parameters are bound. Partial because summary_only, the locally clamped 1-to-200 row limit, decompression, TSV parsing, and monetary aggregation are local behavior; the tool does not expose a typed output contract. Decoded reports are integrity-checked and limited to 64 MiB, 256 columns, 1000000 scanned rows, 10000000 scanned data cells, 100000 matched rows, and 1000000 retained cells. Financial aggregation uses Decimal, prefers Apple's signed Extended Partner Share, falls back to Quantity multiplied by per-unit Partner Share, and returns both compatibility numeric values and exact decimal strings." + "note": "All Apple 4.4.1 request parameters are bound. Partial because summary_only, the locally validated 1-to-200 row limit, decompression, TSV parsing, and monetary aggregation are local behavior; the tool does not expose a typed output contract. Decoded reports are integrity-checked and limited to 64 MiB, 256 columns, 1000000 scanned rows, 10000000 scanned data cells, 100000 matched rows, and 1000000 retained cells. Financial aggregation uses Decimal, prefers Apple's signed Extended Partner Share, falls back to Quantity multiplied by per-unit Partner Share, and returns both compatibility numeric values and exact decimal strings." }, { "tool": "analytics_get_instance", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/app_events.json b/Sources/asc-mcp/Resources/OperationManifest/tools/app_events.json index 1f4acb1..c898799 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/app_events.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/app_events.json @@ -58,7 +58,7 @@ "sourceKind": "requestBody", "operationId": "appEvents_createInstance", "jsonPointer": "/data/attributes/purchaseRequirement", - "localRole": "Accept only NO_COST_ASSOCIATED, IN_APP_PURCHASE, or explicit null before network access." + "localRole": "Forward any non-null string or explicit null using Apple's nullable purchaseRequirement attribute." }, { "toolField": "primary_locale", @@ -107,7 +107,7 @@ } ] }, - "note": "The current create contract exposes every Apple 4.4.1 attribute, preserves explicit null, validates each non-null deep link as an absolute URI, restricts purchase requirements to the current supported values, strictly validates territory schedules, and rejects malformed values before network access; partial only because the tool has no outputSchema." + "note": "The current create contract exposes every Apple 4.4.1 attribute, preserves explicit null, validates each non-null deep link as an absolute URI, forwards Apple's unconstrained purchase-requirement string, strictly validates territory schedules, and rejects malformed values before network access; partial only because the tool has no outputSchema." }, { "tool": "app_events_create_localization", @@ -611,7 +611,7 @@ "sourceKind": "requestBody", "operationId": "appEvents_updateInstance", "jsonPointer": "/data/attributes/purchaseRequirement", - "localRole": "Accept only NO_COST_ASSOCIATED, IN_APP_PURCHASE, or explicit null before network access." + "localRole": "Forward any non-null string or explicit null using Apple's nullable purchaseRequirement attribute." }, { "toolField": "primary_locale", @@ -660,7 +660,7 @@ } ] }, - "note": "The current update contract exposes every Apple 4.4.1 writable attribute, preserves explicit null, validates each non-null deep link as an absolute URI, restricts purchase requirements to the current supported values, strictly validates territory schedules, and rejects no-op or malformed updates before network access; partial only because the tool has no outputSchema." + "note": "The current update contract exposes every Apple 4.4.1 writable attribute, preserves explicit null, validates each non-null deep link as an absolute URI, forwards Apple's unconstrained purchase-requirement string, strictly validates territory schedules, and rejects no-op or malformed updates before network access; partial only because the tool has no outputSchema." }, { "tool": "app_events_update_localization", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json b/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json index 2774351..3b29ea6 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json @@ -302,7 +302,7 @@ { "toolField": "version_id", "sourceKind": "parameter", "operationId": "appStoreVersions_getInstance", "location": "path", "appleName": "id" }, { "toolField": "version_state", "sourceKind": "local", "localRole": "selectVersionByAppVersionStateWithDeprecatedAppStoreStateFallback" }, { "toolField": "platform", "sourceKind": "local", "localRole": "selectOrValidateVersionPlatform" }, - { "toolField": "include_media", "sourceKind": "local", "localRole": "enableConditionalMediaReads" } + { "toolField": "include_media", "sourceKind": "local", "localRole": "enableConditionalMediaReadsOnlyWhenLocaleIsExplicit" } ], "response": { "mode": "aggregate", @@ -316,12 +316,14 @@ "fields": [ { "outputField": "success", "localRole": "successfulAggregate" }, { "outputField": "version", "localRole": "resolvedVersionProjection" }, + { "outputField": "localization", "operationId": "appStoreVersions_appStoreVersionLocalizations_getToManyRelated", "jsonPointer": "/data/*", "localRole": "singleExactLocaleProjection" }, { "outputField": "localizations", "operationId": "appStoreVersions_appStoreVersionLocalizations_getToManyRelated", "jsonPointer": "/data" }, + { "outputField": "totalLocalizations", "localRole": "completedLocalizationCount" }, { "outputField": "appPreviewSets", "operationId": "appStoreVersionLocalizations_appPreviewSets_getToManyRelated", "jsonPointer": "/data" }, { "outputField": "screenshotSets", "operationId": "appStoreVersionLocalizations_appScreenshotSets_getToManyRelated", "jsonPointer": "/data" } ] }, - "note": "Partial because the tool aggregates several Apple operations; version resolution follows all collection pages, prefers appVersionState with deprecated appStoreState fallback, validates ownership, and propagates media failures." + "note": "Partial because the tool aggregates several Apple operations; version resolution and localization retrieval follow every Apple page, state selection prefers appVersionState with deprecated appStoreState fallback, ownership is validated, an exact-locale lookup must resolve to exactly one matching resource, and include_media requires an explicit locale." }, { "tool": "apps_list", @@ -382,10 +384,14 @@ "fields": [ { "outputField": "success", "localRole": "successfulRead" }, { "outputField": "apps", "operationId": "apps_getCollection", "jsonPointer": "/data" }, + { "outputField": "count", "localRole": "currentPageResourceCount" }, + { "outputField": "totalCount", "operationId": "apps_getCollection", "jsonPointer": "/meta/paging/total", "localRole": "applePagingTotalOrTerminalPageCountOtherwiseNull" }, + { "outputField": "hasNextPage", "localRole": "nextLinkPresence" }, + { "outputField": "links", "operationId": "apps_getCollection", "jsonPointer": "/links" }, { "outputField": "next_url", "operationId": "apps_getCollection", "jsonPointer": "/links/next" } ] }, - "note": "Partial because the public projection intentionally exposes only a subset of current App attributes and collection query controls." + "note": "Partial because the public projection intentionally exposes only a subset of current App attributes and collection query controls. count is the current page size; totalCount uses Apple paging.total, falls back to count only on a terminal page, and is null when Apple omits total while another page exists." }, { "tool": "apps_list_localizations", @@ -451,11 +457,15 @@ ], "fields": [ { "outputField": "success", "localRole": "successfulRead" }, + { "outputField": "appId", "localRole": "validatedAppIdentifier" }, + { "outputField": "versionId", "localRole": "validatedVersionIdentifier" }, + { "outputField": "count", "localRole": "currentPageResourceCount" }, + { "outputField": "totalLocalizations", "operationId": "appStoreVersions_appStoreVersionLocalizations_getToManyRelated", "jsonPointer": "/meta/paging/total", "localRole": "applePagingTotalOrTerminalPageCountOtherwiseNull" }, { "outputField": "localizations", "operationId": "appStoreVersions_appStoreVersionLocalizations_getToManyRelated", "jsonPointer": "/data" }, { "outputField": "next_url", "operationId": "appStoreVersions_appStoreVersionLocalizations_getToManyRelated", "jsonPointer": "/links/next" } ] }, - "note": "The handler validates app-to-version ownership, then returns only localizations whose appStoreVersion linkage matches version_id." + "note": "The handler validates app-to-version ownership, then returns only localizations whose appStoreVersion linkage matches version_id. count is the current page size; totalLocalizations uses Apple paging.total, falls back to count only on a terminal page, and is null when Apple omits total while another page exists." }, { "tool": "apps_list_search_keywords", @@ -767,7 +777,7 @@ { "outputField": "updatedFields", "localRole": "providedAttributeNames" } ] }, - "note": "The handler validates app-to-version and version-to-localization ownership, preserves omitted versus explicit-null localization attributes, then patches the resolved localization." + "note": "The handler validates app-to-version ownership and requires the filter to resolve exactly one resource whose type, locale attribute, and version linkage all match before PATCH. It preserves omitted versus explicit-null localization attributes, then patches the resolved localization." } ], "implementationAliases": [] diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_groups.json b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_groups.json index 48accfb..0c053ce 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_groups.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_groups.json @@ -46,7 +46,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "beta_groups_add_testers", @@ -93,7 +93,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "beta_groups_create", @@ -459,7 +459,7 @@ } ] }, - "note": "Exact related-resource list with complete Apple 4.4.1 appDevices projections; the worker does not expose the corresponding group builds read." + "note": "Exact related-resource list with complete Apple 4.4.1 appDevices projections and fail-closed 1 through 200 limit validation; the worker does not expose the corresponding group builds read." }, { "tool": "beta_groups_remove_builds", @@ -506,7 +506,7 @@ } ] }, - "note": "Exact relationship removal; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship removal; Apple returns 204 without a response document." }, { "tool": "beta_groups_remove_testers", @@ -553,7 +553,7 @@ } ] }, - "note": "Exact relationship removal; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship removal; Apple returns 204 without a response document." }, { "tool": "beta_groups_update", @@ -652,7 +652,7 @@ } ] }, - "note": "Exposes every current writable beta-group attribute and rejects empty PATCH requests before network access." + "note": "Exposes every current writable beta-group attribute, preserves explicit JSON null separately from omission, validates nullable value types, and rejects empty PATCH requests before network access." }, { "tool": "beta_groups_get_recruitment_criteria", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_license.json b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_license.json index 763ed48..4e9173e 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_license.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_license.json @@ -135,7 +135,7 @@ } ] }, - "note": "Supports Apple scalar-or-array app filtering, pagination, total, and app relationship linkage projection." + "note": "Supports Apple scalar-or-array app filtering, pagination, total, app relationship linkage projection, and fail-closed 1 through 200 limit validation." }, { "tool": "beta_license_update", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_testers.json b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_testers.json index 608548f..c5df706 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/beta_testers.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/beta_testers.json @@ -46,7 +46,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "beta_testers_add_to_groups", @@ -93,7 +93,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "beta_testers_create", @@ -176,7 +176,7 @@ } ] }, - "note": "Apple requires only email; betaGroups and builds relationships are independently optional and validated when supplied, and the response projects the complete Apple 4.4.1 appDevices attribute." + "note": "Apple requires only email; the handler validates email format before network access, betaGroups and builds relationships are independently optional and validated when supplied, and the response projects the complete Apple 4.4.1 appDevices attribute." }, { "tool": "beta_testers_delete", @@ -532,7 +532,7 @@ } ] }, - "note": "Exact related-resource list with a reduced app projection." + "note": "Exact related-resource list with a reduced app projection and fail-closed 1 through 200 limit validation." }, { "tool": "beta_testers_remove_from_app", @@ -658,7 +658,7 @@ } ] }, - "note": "Exact relationship removal; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship removal; Apple returns 204 without a response document." }, { "tool": "beta_testers_remove_from_groups", @@ -705,7 +705,7 @@ } ] }, - "note": "Exact relationship removal; Apple returns no response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship removal; Apple returns 204 without a response document." }, { "tool": "beta_testers_search", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/build_beta.json b/Sources/asc-mcp/Resources/OperationManifest/tools/build_beta.json index 2b901ff..fbb791f 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/build_beta.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/build_beta.json @@ -49,7 +49,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns 204 without a response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "builds_add_to_beta_groups", @@ -99,7 +99,7 @@ } ] }, - "note": "Exact relationship mutation; Apple returns 204 without a response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship mutation; only Apple's 204 no-content response is accepted." }, { "tool": "builds_get_beta_detail", @@ -366,7 +366,7 @@ } ] }, - "note": "The endpoint returns only testers assigned individually, not every tester who obtains access through beta groups, and projects the complete Apple 4.4.1 appDevices attribute. This tool duplicates builds_list_individual_testers." + "note": "The endpoint returns only testers assigned individually, not every tester who obtains access through beta groups, projects the complete Apple 4.4.1 appDevices attribute, and rejects limits outside 1 through 200 before network access. This tool duplicates builds_list_individual_testers." }, { "tool": "builds_list_beta_localizations", @@ -430,7 +430,7 @@ } ] }, - "note": "Correct paginated related-resource read projecting locale, whatsNew, and build linkage only." + "note": "Correct paginated related-resource read projecting locale, whatsNew, and build linkage only; limits outside 1 through 200 are rejected before network access." }, { "tool": "builds_list_individual_testers", @@ -495,7 +495,7 @@ } ] }, - "note": "Correct endpoint with complete Apple 4.4.1 appDevices projections; public functionality duplicates builds_get_beta_testers." + "note": "Correct endpoint with complete Apple 4.4.1 appDevices projections and fail-closed 1 through 200 limit validation; public functionality duplicates builds_get_beta_testers." }, { "tool": "builds_remove_individual_testers", @@ -545,7 +545,7 @@ } ] }, - "note": "Exact relationship removal; Apple returns 204 without a response document." + "note": "Rejects empty, malformed, or mixed-type ID arrays atomically before the exact relationship removal; Apple returns 204 without a response document." }, { "tool": "builds_send_beta_notification", @@ -752,7 +752,7 @@ } ] }, - "note": "Create-or-update workflow uses Apple-side build and locale filters to find an existing resource across the full collection, then serializes only locale and whatsNew. Deprecated BetaAppLocalization-only inputs remain discoverable and are rejected before network access with replacement tool guidance." + "note": "Create-or-update workflow uses Apple-side build and locale filters to find an existing resource across the full collection, then serializes only locale and whatsNew while preserving explicit JSON null separately from omission. Deprecated BetaAppLocalization-only inputs remain discoverable and are rejected before network access with replacement tool guidance." }, { "tool": "builds_update_beta_detail", @@ -825,7 +825,7 @@ } ] }, - "note": "Serializes only autoNotifyEnabled, rejects empty PATCH requests, and rejects deprecated read-only state inputs before network access." + "note": "Serializes only autoNotifyEnabled, preserves explicit JSON null separately from omission, rejects invalid nullable value types and empty PATCH requests, and rejects deprecated read-only state inputs before network access." } ], "implementationAliases": [] diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/custom_pages.json b/Sources/asc-mcp/Resources/OperationManifest/tools/custom_pages.json index 295f3fe..b41aa3f 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/custom_pages.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/custom_pages.json @@ -415,7 +415,8 @@ { "outputField": "custom_product_page.appId", "operationId": "appCustomProductPages_updateInstance", "jsonPointer": "/data/relationships/app/data/id", "localRole": "Returned only after parent relationship validation." }, { "outputField": "custom_product_page.state", "localRole": "Legacy compatibility placeholder set to null." } ] - } + }, + "note": "The closed input schema and runtime both require at least one mutable field, name or visible, in addition to page_id." }, { "tool": "custom_pages_update_localization", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/export_compliance.json b/Sources/asc-mcp/Resources/OperationManifest/tools/export_compliance.json index 2456715..42ecf68 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/export_compliance.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/export_compliance.json @@ -193,7 +193,7 @@ } ] }, - "note": "Uses builds_updateInstance as the replacement documented by Apple at https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_. Pinned OpenAPI 4.4.1 omits a deprecated flag for the direct relationship PATCH, while current Apple DocC marks it deprecated at https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_-relationships-appencryptiondeclaration and directs clients to Modify a build. The pinned-spec-deprecated declaration-to-build POST also remains deferred. Any ambiguous update or failed post-verification is an unverified mutation with retrySafe=false." + "note": "Uses builds_updateInstance as the replacement documented by Apple at https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_ and accepts only that operation's exact HTTP 200 response. Pinned OpenAPI 4.4.1 omits a deprecated flag for the direct relationship PATCH, while current Apple DocC marks it deprecated at https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_-relationships-appencryptiondeclaration and directs clients to Modify a build; its HTTP 204 contract is not used here. The pinned-spec-deprecated declaration-to-build POST also remains deferred. Any ambiguous update or failed post-verification is an unverified mutation with retrySafe=false." }, { "tool": "export_compliance_check_release_readiness", @@ -329,7 +329,7 @@ { "outputField": "inspection", "localRole": "For committed-unverified or commit-unknown outcomes, call export_compliance_list_declarations with the original app_id before any retry." } ] }, - "note": "Sends exactly the four required questionnaire attributes and required app linkage from OpenAPI 4.4.1; obsolete platform, usesEncryption, and compliance-code inputs are not exposed. The non-idempotent POST distinguishes committed-but-unverified success, unknown commit state, and deterministic rejection so an ambiguous response never invites a blind duplicate create." + "note": "Sends exactly the four required questionnaire attributes and required app linkage from OpenAPI 4.4.1; obsolete platform, usesEncryption, and compliance-code inputs are not exposed. A confirmed response requires the mandatory configured-origin canonical resource self-link. The non-idempotent POST distinguishes committed-but-unverified success, unknown commit state, and deterministic rejection so an ambiguous response never invites a blind duplicate create." }, { "tool": "export_compliance_create_document", @@ -581,7 +581,7 @@ "sources": [{ "operationId": "appEncryptionDeclarations_getInstance", "statusCode": "200", "mediaType": "application/json" }], "fields": [{ "outputField": "declaration", "operationId": "appEncryptionDeclarations_getInstance", "jsonPointer": "/data" }] }, - "note": "Omits deprecated platform, usesEncryption, uploadedDate, documentUrl, documentName, and documentType attributes." + "note": "Omits deprecated platform, usesEncryption, uploadedDate, documentUrl, documentName, and documentType attributes. The required document self-link must resolve to the returned canonical declaration on the configured origin." }, { "tool": "export_compliance_get_document", @@ -611,7 +611,7 @@ "sources": [{ "operationId": "appEncryptionDeclarationDocuments_getInstance", "statusCode": "200", "mediaType": "application/json" }], "fields": [{ "outputField": "document", "localRole": "Redact downloadUrl and expose download availability only when Apple actually returns a URL; omit asset-token and upload-operation availability claims because those fields were not requested." }] }, - "note": "The public result contains no signed URL, asset token, upload operation URL, or request header, and it does not emit false or zero availability claims for unrequested fields." + "note": "The public result contains no signed URL, asset token, upload operation URL, or request header, and it does not emit false or zero availability claims for unrequested fields. The required document self-link must resolve to the returned canonical document on the configured origin." }, { "tool": "export_compliance_inspect_document", @@ -733,7 +733,7 @@ { "outputField": "next_url", "operationId": "apps_appEncryptionDeclarations_getToManyRelated", "jsonPointer": "/links/next" } ] }, - "note": "Strict pagination rejects cross-app paths, changed projection, a caller-supplied limit that differs from the continuation, invalid or duplicate limit or cursor parameters, and unknown query names. A continuation call can use only app_id plus the unmodified next_url even when the first page used a nondefault limit." + "note": "Strict pagination rejects cross-app paths, changed projection, a caller-supplied limit that differs from the continuation, invalid or duplicate limit or cursor parameters, and unknown query names. A continuation call can use only app_id plus the unmodified next_url even when the first page used a nondefault limit. Apple responses require collection links, stay on the configured app path, preserve strict next-link query and cursor lineage, stay within the effective requested limit and paging metadata, and reject invalid or duplicate declaration identities." }, { "tool": "export_compliance_update_document", @@ -769,7 +769,7 @@ { "outputField": "inspection", "localRole": "For committed-unverified or commit-unknown outcomes, call export_compliance_get_document with the same document_id before any retry." } ] }, - "note": "Requires at least one update field and preserves missing versus explicit JSON null for both optional Apple attributes without promising server-side reversal semantics. The PATCH distinguishes committed-but-unverified success, unknown commit state, and deterministic rejection." + "note": "Requires at least one update field and preserves missing versus explicit JSON null for both optional Apple attributes without promising server-side reversal semantics. A confirmed response requires the mandatory configured-origin canonical resource self-link. The PATCH distinguishes committed-but-unverified success, unknown commit state, and deterministic rejection." }, { "tool": "export_compliance_upload_document", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/iap.json b/Sources/asc-mcp/Resources/OperationManifest/tools/iap.json index 8f228df..4e3fcef 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/iap.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/iap.json @@ -446,7 +446,7 @@ "tool": "iap_delete", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -479,7 +479,7 @@ "tool": "iap_delete_image", "kind": "direct", "status": "deprecated", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -512,7 +512,7 @@ "tool": "iap_delete_localization", "kind": "direct", "status": "deprecated", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -545,7 +545,7 @@ "tool": "iap_delete_review_screenshot", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -2407,7 +2407,7 @@ ], "fields": [] }, - "note": "Reads the schedule, then both price collections; territory_id is applied to both collection filters and results are summarized locally." + "note": "Reads the schedule, exhausts every validated page of both manual and automatic price collections, applies territory_id to both filters, and summarizes only the complete result." }, { "tool": "iap_set_availability", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/pre_release.json b/Sources/asc-mcp/Resources/OperationManifest/tools/pre_release.json index e6f55d2..5fff32e 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/pre_release.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/pre_release.json @@ -257,7 +257,7 @@ } ] }, - "note": "Exact related-resource list projecting current Build expiration, minimum OS, audience, processing, and encryption attributes." + "note": "Exact related-resource list projecting current Build expiration, minimum OS, audience, processing, and encryption attributes, with limits outside 1 through 200 rejected before network access." } ], "implementationAliases": [] diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/pricing.json b/Sources/asc-mcp/Resources/OperationManifest/tools/pricing.json index 217b9b6..06a3736 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/pricing.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/pricing.json @@ -129,6 +129,34 @@ "operationId": "appAvailabilitiesV2_createInstance", "jsonPointer": "/included" }, + { + "outputField": "availability.territory_availabilities_included_count", + "localRole": "Count included territory availability resources returned by Apple." + }, + { + "outputField": "availability.territory_availabilities_total", + "operationId": "appAvailabilitiesV2_createInstance", + "jsonPointer": "/data/relationships/territoryAvailabilities/meta/paging/total" + }, + { + "outputField": "availability.territory_availabilities_limit", + "operationId": "appAvailabilitiesV2_createInstance", + "jsonPointer": "/data/relationships/territoryAvailabilities/meta/paging/limit" + }, + { + "outputField": "availability.territory_availabilities_related_url", + "operationId": "appAvailabilitiesV2_createInstance", + "jsonPointer": "/data/relationships/territoryAvailabilities/links/related" + }, + { + "outputField": "availability.territory_availabilities_relationship_url", + "operationId": "appAvailabilitiesV2_createInstance", + "jsonPointer": "/data/relationships/territoryAvailabilities/links/self" + }, + { + "outputField": "availability.territory_availabilities_truncated", + "localRole": "Compare Apple's relationship total with the included resource count; emit null when Apple omits paging total." + }, { "outputField": "submitted_existing_territory_availability_count", "localRole": "Count the validated existing relationship IDs submitted to Apple." @@ -177,7 +205,7 @@ "operationId": "apps_appAvailabilityV2_getToOneRelated", "location": "query", "appleName": "limit[territoryAvailabilities]", - "localRole": "clamped to Apple's maximum of 50 and enables the matching include when supplied" + "localRole": "validated as an integer from 1 through 50 and enables the matching include when supplied" } ], "response": { @@ -219,6 +247,10 @@ "operationId": "apps_appAvailabilityV2_getToOneRelated", "jsonPointer": "/included" }, + { + "outputField": "availability.territory_availabilities_included_count", + "localRole": "Count included territory availability resources returned by Apple." + }, { "outputField": "availability.territory_availabilities_total", "operationId": "apps_appAvailabilityV2_getToOneRelated", @@ -283,7 +315,7 @@ "operationId": "appAvailabilitiesV2_getInstance", "location": "query", "appleName": "limit[territoryAvailabilities]", - "localRole": "clamped to Apple's maximum of 50 and enables the matching include when supplied" + "localRole": "validated as an integer from 1 through 50 and enables the matching include when supplied" } ], "response": { @@ -325,6 +357,10 @@ "operationId": "appAvailabilitiesV2_getInstance", "jsonPointer": "/included" }, + { + "outputField": "availability.territory_availabilities_included_count", + "localRole": "Count included territory availability resources returned by Apple." + }, { "outputField": "availability.territory_availabilities_total", "operationId": "appAvailabilitiesV2_getInstance", @@ -427,6 +463,12 @@ "operationId": "apps_appPriceSchedule_getToOneRelated", "jsonPointer": "/data/relationships/baseTerritory/data/id" }, + { + "outputField": "price_schedule.base_territory", + "operationId": "apps_appPriceSchedule_getToOneRelated", + "jsonPointer": "/included", + "localRole": "Select the included territory matching the baseTerritory relationship linkage." + }, { "outputField": "price_schedule.manual_prices", "operationId": "apps_appPriceSchedule_getToOneRelated", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/promoted.json b/Sources/asc-mcp/Resources/OperationManifest/tools/promoted.json index d0ac713..458ab2c 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/promoted.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/promoted.json @@ -142,7 +142,7 @@ "sources": [], "fields": [] }, - "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and returns a structured deprecation error with current IAP and subscription image replacements." + "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and points only to active version-scoped replacements: iap_get_version_image, iap_list_version_images, iap_upload_version_image, iap_get_version_image_resource, iap_delete_version_image, subscriptions_list_version_images, subscriptions_upload_version_image, subscriptions_get_version_image, and subscriptions_delete_version_image." }, { "tool": "promoted_get", @@ -212,7 +212,7 @@ "sources": [], "fields": [] }, - "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and returns a structured deprecation error with current IAP and subscription image replacements." + "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and points only to active version-scoped replacements: iap_get_version_image, iap_list_version_images, iap_upload_version_image, iap_get_version_image_resource, iap_delete_version_image, subscriptions_list_version_images, subscriptions_upload_version_image, subscriptions_get_version_image, and subscriptions_delete_version_image." }, { "tool": "promoted_get_image_for_purchase", @@ -234,7 +234,7 @@ "sources": [], "fields": [] }, - "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and returns a structured deprecation error with current IAP and subscription image replacements." + "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and points only to active version-scoped replacements: iap_get_version_image, iap_list_version_images, iap_upload_version_image, iap_get_version_image_resource, iap_delete_version_image, subscriptions_list_version_images, subscriptions_upload_version_image, subscriptions_get_version_image, and subscriptions_delete_version_image." }, { "tool": "promoted_list", @@ -384,7 +384,7 @@ { "outputField": "details.recovery", "localRole": "Executable promoted_list inspection guidance after an unknown or committed-unverified outcome." } ] }, - "note": "The MCP field is a real JSON string array containing 1...200 canonical unique IDs. Every relationship page is capped at 200 resources; links.self and links.next must stay in the exact configured-origin or root-relative scope, and each next cursor must be non-empty and advancing. Optional paging metadata must report limit 200, a total no smaller than the page count, and a nextCursor exactly consistent with links.next. The handler exhausts pagination with stable-total and terminal cumulative-count validation and requires exact set-and-count equality with current membership. An identical requested order returns a verified no-op without PATCH. A changed order sends ordered JSON:API linkage objects, accepts only HTTP 204, and returns mutation success only after a second exhaustive relationship read exactly matches the requested order." + "note": "The MCP field is a non-empty JSON string array of canonical unique IDs with no artificial item-count maximum; Apple's relationship request schema defines no maxItems. Every relationship page remains capped at 200 resources; links.self and links.next must stay in the exact configured-origin or root-relative scope, and each next cursor must be non-empty and advancing. Optional paging metadata must report limit 200, a total no smaller than the page count, and a nextCursor exactly consistent with links.next. The handler exhausts pagination with stable-total and terminal cumulative-count validation and requires exact set-and-count equality with current membership. An identical requested order returns a verified no-op without PATCH. A changed order sends ordered JSON:API linkage objects, accepts only HTTP 204, and returns mutation success only after a second exhaustive relationship read exactly matches the requested order." }, { "tool": "promoted_update", @@ -477,7 +477,7 @@ "sources": [], "fields": [] }, - "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and returns a structured deprecation error with current IAP and subscription image replacements." + "note": "The pinned executable OpenAPI 4.4.1 has no promotedPurchaseImages resource or promotionImage relationship. The handler sends no Apple request and points only to active version-scoped replacements: iap_get_version_image, iap_list_version_images, iap_upload_version_image, iap_get_version_image_resource, iap_delete_version_image, subscriptions_list_version_images, subscriptions_upload_version_image, subscriptions_get_version_image, and subscriptions_delete_version_image." } ], "implementationAliases": [] diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/provisioning.json b/Sources/asc-mcp/Resources/OperationManifest/tools/provisioning.json index c717368..12f1273 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/provisioning.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/provisioning.json @@ -22,7 +22,7 @@ { "toolField": "name", "sourceKind": "requestBody", "operationId": "bundleIds_createInstance", "jsonPointer": "/data/attributes/name" }, { "toolField": "identifier", "sourceKind": "requestBody", "operationId": "bundleIds_createInstance", "jsonPointer": "/data/attributes/identifier" }, { "toolField": "platform", "sourceKind": "requestBody", "operationId": "bundleIds_createInstance", "jsonPointer": "/data/attributes/platform" }, - { "toolField": "seed_id", "sourceKind": "requestBody", "operationId": "bundleIds_createInstance", "jsonPointer": "/data/attributes/seedId" } + { "toolField": "seed_id", "sourceKind": "requestBody", "operationId": "bundleIds_createInstance", "jsonPointer": "/data/attributes/seedId", "localRole": "preserves omission, explicit null, and string values" } ], "response": { "mode": "projection", @@ -50,7 +50,8 @@ "inputs": [ { "sourceKind": "fixed", "jsonPointer": "/data/type", "fixedValue": "profiles" }, { "sourceKind": "fixed", "jsonPointer": "/data/relationships/bundleId/data/type", "fixedValue": "bundleIds" }, - { "sourceKind": "fixed", "jsonPointer": "/data/relationships/certificates/data/*/type", "fixedValue": "certificates" } + { "sourceKind": "fixed", "jsonPointer": "/data/relationships/certificates/data/*/type", "fixedValue": "certificates" }, + { "sourceKind": "fixed", "jsonPointer": "/data/relationships/devices/data/*/type", "fixedValue": "devices" } ] } ], @@ -76,7 +77,7 @@ "tool": "provisioning_delete_bundle_id", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "bundleIds_deleteInstance", "method": "delete", "path": "/v1/bundleIds/{id}", "role": "primary" } @@ -99,7 +100,7 @@ "tool": "provisioning_delete_profile", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "profiles_deleteInstance", "method": "delete", "path": "/v1/profiles/{id}", "role": "primary" } @@ -122,7 +123,7 @@ "tool": "provisioning_disable_capability", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "bundleIdCapabilities_deleteInstance", "method": "delete", "path": "/v1/bundleIdCapabilities/{id}", "role": "primary" } @@ -162,7 +163,7 @@ "fields": [ { "toolField": "bundle_id_resource_id", "sourceKind": "requestBody", "operationId": "bundleIdCapabilities_createInstance", "jsonPointer": "/data/relationships/bundleId/data/id" }, { "toolField": "capability_type", "sourceKind": "requestBody", "operationId": "bundleIdCapabilities_createInstance", "jsonPointer": "/data/attributes/capabilityType" }, - { "toolField": "settings", "sourceKind": "requestBody", "operationId": "bundleIdCapabilities_createInstance", "jsonPointer": "/data/attributes/settings", "localRole": "JSON string type-checked and decoded locally as an array before request encoding" } + { "toolField": "settings", "sourceKind": "requestBody", "operationId": "bundleIdCapabilities_createInstance", "jsonPointer": "/data/attributes/settings", "localRole": "native array input preserving omission, explicit null, and array values; nested fields reject unknown keys and enforce Apple 4.4.1 enums and scalar types" } ], "response": { "mode": "projection", @@ -173,7 +174,7 @@ { "outputField": "capability", "operationId": "bundleIdCapabilities_createInstance", "jsonPointer": "/data" } ] }, - "note": "Exact Apple create operation. Settings remain a JSON-encoded string input, while the response preserves enabledByDefault, visible, enabled, and supportsWildcard flags." + "note": "Exact Apple create operation. Settings use Apple's nullable array contract, while the response preserves empty arrays and enabledByDefault, visible, enabled, and supportsWildcard flags." }, { "tool": "provisioning_get_bundle_id", @@ -368,7 +369,8 @@ ], "fields": [ { "outputField": "bundle_ids", "operationId": "bundleIds_getCollection", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "bundleIds_getCollection", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "bundleIds_getCollection", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "bundleIds_getCollection", "jsonPointer": "/meta/paging/total" } ] }, "note": "All Apple 4.4.1 collection filters, sort, limit, and continuation are exposed. Sparse fields follow the global projection policy; includes and their nested limits are intentionally omitted." @@ -394,7 +396,8 @@ ], "fields": [ { "outputField": "capabilities", "operationId": "bundleIds_bundleIdCapabilities_getToManyRelated", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "bundleIds_bundleIdCapabilities_getToManyRelated", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "bundleIds_bundleIdCapabilities_getToManyRelated", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "bundleIds_bundleIdCapabilities_getToManyRelated", "jsonPointer": "/meta/paging/total" } ] }, "note": "Apple 4.4.1 limit and continuation are honored. Capability settings preserve enabledByDefault, visible, enabled, and supportsWildcard; sparse fields follow the global projection policy." @@ -438,7 +441,8 @@ ], "fields": [ { "outputField": "certificates", "operationId": "certificates_getCollection", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "certificates_getCollection", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "certificates_getCollection", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "certificates_getCollection", "jsonPointer": "/meta/paging/total" } ] }, "note": "All Apple 4.4.1 certificate filters, sort, limit, and continuation are exposed. Lists intentionally omit certificateContent and passTypeId resources; detail lookup returns certificateContent." @@ -469,7 +473,8 @@ ], "fields": [ { "outputField": "devices", "operationId": "devices_getCollection", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "devices_getCollection", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "devices_getCollection", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "devices_getCollection", "jsonPointer": "/meta/paging/total" } ] }, "note": "All Apple 4.4.1 device filters, sort, limit, and continuation are exposed, including UNIVERSAL platform support. Sparse fields follow the global projection policy." @@ -527,7 +532,8 @@ ], "fields": [ { "outputField": "profiles", "operationId": "profiles_getCollection", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "profiles_getCollection", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "profiles_getCollection", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "profiles_getCollection", "jsonPointer": "/meta/paging/total" } ] }, "note": "All Apple 4.4.1 profile filters, sort, limit, and continuation are exposed. Lists intentionally omit profileContent and relationship resources; detail/create responses return profileContent." @@ -569,7 +575,7 @@ "tool": "provisioning_revoke_certificate", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "certificates_deleteInstance", "method": "delete", "path": "/v1/certificates/{id}", "role": "primary" } @@ -608,8 +614,8 @@ "fields": [ { "toolField": "device_id", "sourceKind": "parameter", "operationId": "devices_updateInstance", "location": "path", "appleName": "id" }, { "toolField": "device_id", "sourceKind": "requestBody", "operationId": "devices_updateInstance", "jsonPointer": "/data/id" }, - { "toolField": "name", "sourceKind": "requestBody", "operationId": "devices_updateInstance", "jsonPointer": "/data/attributes/name" }, - { "toolField": "status", "sourceKind": "requestBody", "operationId": "devices_updateInstance", "jsonPointer": "/data/attributes/status" } + { "toolField": "name", "sourceKind": "requestBody", "operationId": "devices_updateInstance", "jsonPointer": "/data/attributes/name", "localRole": "preserves omission, explicit null, and string values" }, + { "toolField": "status", "sourceKind": "requestBody", "operationId": "devices_updateInstance", "jsonPointer": "/data/attributes/status", "localRole": "preserves omission, explicit null, and validated ENABLED or DISABLED values" } ], "response": { "mode": "projection", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json b/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json index 737f24a..ff93773 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json @@ -268,7 +268,7 @@ { "outputField": "total", "operationId": "reviewSubmissions_getCollection", "jsonPointer": "/meta/paging/total" } ] }, - "note": "App ownership is mandatory; scalar, strict CSV, and unique-array filters are validated before the request, and continuation URLs preserve every originating query value." + "note": "App ownership is mandatory; scalar, strict CSV, and unique-array filters are validated before the request, and continuation URLs preserve every originating query value. Apple collection responses are bounded by the effective requested limit, require scoped self/next links, preserve cursor consistency, and reject invalid or duplicate resource identities." }, { "tool": "review_submissions_list_items", @@ -315,7 +315,7 @@ { "outputField": "total", "operationId": "reviewSubmissions_items_getToManyRelated", "jsonPointer": "/meta/paging/total" } ] }, - "note": "All Apple relationship identity fields are requested so legacy PPO and scoped Game Center items remain identifiable, while compound includes stay limited to the eight relationships this worker can add; the linkage-only twin endpoint remains deferred." + "note": "All Apple relationship identity fields are requested so legacy PPO and scoped Game Center items remain identifiable, while compound includes stay limited to the eight relationships this worker can add; the linkage-only twin endpoint remains deferred. Apple collection responses are bounded by the effective requested limit, require scoped self/next links, preserve cursor consistency, and reject invalid or duplicate resource identities." }, { "tool": "review_submissions_remove_item", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/reviews.json b/Sources/asc-mcp/Resources/OperationManifest/tools/reviews.json index c0d0bb9..1c87c26 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/reviews.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/reviews.json @@ -74,7 +74,7 @@ } ] }, - "note": "Apple POST creates or replaces the response for the related review and projects Apple's current responseBody, lastModifiedDate, state, and review relationship. Partial because the local 5000-character limit is stricter than OpenAPI 4.4.1 and the tool has no declared output schema." + "note": "Apple POST creates or replaces the response for the related review and projects Apple's current responseBody, lastModifiedDate, state, and review relationship. Partial because the tool has no declared output schema." }, { "tool": "reviews_delete_response", @@ -206,7 +206,7 @@ } ] }, - "note": "Stable review projection with response relationship presence. Sparse fieldsets follow the manifest-wide policy; include is intentionally omitted because related response lookup has a dedicated tool and the review already contains its territory attribute." + "note": "Stable review projection with response relationship presence; fields backed by optional Apple attributes are omitted when absent. Sparse fieldsets follow the manifest-wide policy; include is intentionally omitted because related response lookup has a dedicated tool and the review already contains its territory attribute." }, { "tool": "reviews_get_response", @@ -375,7 +375,8 @@ "sourceKind": "parameter", "operationId": "apps_customerReviews_getToManyRelated", "location": "query", - "appleName": "filter[territory]" + "appleName": "filter[territory]", + "localRole": "caseInsensitiveInputNormalizedAndValidatedAgainstAppleTerritoryEnum" }, { "toolField": "territories", @@ -383,7 +384,7 @@ "operationId": "apps_customerReviews_getToManyRelated", "location": "query", "appleName": "filter[territory]", - "localRole": "commaSeparatedArrayQuery" + "localRole": "caseInsensitiveInputsNormalizedAndValidatedAgainstAppleTerritoryEnumThenEncodedAsCommaSeparatedArrayQuery" }, { "toolField": "sort", @@ -432,7 +433,7 @@ "outputField": "reviews", "operationId": "apps_customerReviews_getToManyRelated", "jsonPointer": "/data", - "localRole": "reviewProjection" + "localRole": "reviewProjectionOmittingOptionalAttributesAbsentFromAppleResources" }, { "outputField": "reviews.response", @@ -518,7 +519,8 @@ "sourceKind": "parameter", "operationId": "appStoreVersions_customerReviews_getToManyRelated", "location": "query", - "appleName": "filter[territory]" + "appleName": "filter[territory]", + "localRole": "caseInsensitiveInputNormalizedAndValidatedAgainstAppleTerritoryEnum" }, { "toolField": "territories", @@ -526,7 +528,7 @@ "operationId": "appStoreVersions_customerReviews_getToManyRelated", "location": "query", "appleName": "filter[territory]", - "localRole": "commaSeparatedArrayQuery" + "localRole": "caseInsensitiveInputsNormalizedAndValidatedAgainstAppleTerritoryEnumThenEncodedAsCommaSeparatedArrayQuery" }, { "toolField": "sort", @@ -575,7 +577,7 @@ "outputField": "reviews", "operationId": "appStoreVersions_customerReviews_getToManyRelated", "jsonPointer": "/data", - "localRole": "reviewProjection" + "localRole": "reviewProjectionOmittingOptionalAttributesAbsentFromAppleResources" }, { "outputField": "reviews.response", @@ -674,7 +676,7 @@ "operationId": "apps_customerReviews_getToManyRelated", "location": "query", "appleName": "filter[territory]", - "localRole": "valueAllOmitsAppleFilter" + "localRole": "valueAllOmitsAppleFilterOtherwiseCaseInsensitiveInputIsNormalizedAndValidatedAgainstAppleTerritoryEnum" }, { "toolField": "has_published_response", @@ -732,13 +734,17 @@ "outputField": "duplicates_skipped", "localRole": "duplicateReviewIdCountExcludedFromAggregation" }, + { + "outputField": "unaggregatable_reviews_skipped", + "localRole": "uniqueReviewCountExcludedBecauseAppleOmittedRatingOrRequiredPeriodDate" + }, { "outputField": "pages_fetched", "localRole": "paginationRequestCount" }, { "outputField": "complete", - "localRole": "requestedPeriodCompleteness" + "localRole": "requestedPeriodCompletenessAndNoUnaggregatableSparseReviews" }, { "outputField": "period_start", @@ -762,7 +768,7 @@ } ] }, - "note": "The handler validates and normalizes inputs, then streams Apple pagination into deduplicated numeric accumulators for the requested rolling UTC period. Fixed descending sort, territory and published-response filters are protected on every continuation; repeated or invalid next URLs fail instead of returning partial results. last_quarter remains a compatibility alias for last_3_months." + "note": "The handler validates and normalizes inputs, then streams Apple pagination into deduplicated numeric accumulators for the requested rolling UTC period. Fixed descending sort, territory and published-response filters are protected on every continuation; repeated or invalid next URLs fail instead of returning partial results. Spec-valid reviews missing a rating or required period date are counted as unaggregatable and make complete false instead of failing the response. last_quarter remains a compatibility alias for last_3_months." }, { "tool": "reviews_summarizations", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/subscriptions.json b/Sources/asc-mcp/Resources/OperationManifest/tools/subscriptions.json index ea8d7bd..2fd1fca 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/subscriptions.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/subscriptions.json @@ -718,7 +718,7 @@ ], "fields": [] }, - "note": "Creates one subscription price and preserves omission, concrete values, and explicit null for every nullable Apple 4.4.1 price attribute." + "note": "Creates one subscription price with Apple's required subscription and price-point relationships, an optional territory relationship, and omission/value/explicit-null preservation for every nullable price attribute." }, { "tool": "subscriptions_create_promotional_offer", @@ -1093,7 +1093,7 @@ "tool": "subscriptions_delete", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1126,7 +1126,7 @@ "tool": "subscriptions_delete_group", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1159,7 +1159,7 @@ "tool": "subscriptions_delete_group_localization", "kind": "direct", "status": "deprecated", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1192,7 +1192,7 @@ "tool": "subscriptions_delete_image", "kind": "direct", "status": "deprecated", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1225,7 +1225,7 @@ "tool": "subscriptions_delete_intro_offer", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1258,7 +1258,7 @@ "tool": "subscriptions_delete_localization", "kind": "direct", "status": "deprecated", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1291,7 +1291,7 @@ "tool": "subscriptions_delete_price", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1324,7 +1324,7 @@ "tool": "subscriptions_delete_promotional_offer", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1357,7 +1357,7 @@ "tool": "subscriptions_delete_review_screenshot", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -1390,7 +1390,7 @@ "tool": "subscriptions_delete_winback_offer", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -3489,20 +3489,6 @@ "role": "primary", "condition": null, "optionalParameterClassifications": [ - { - "location": "query", - "appleName": "filter[planType]", - "disposition": "intentionallyOmitted", - "reason": "The current tool retains territory-focused pricing semantics; plan-aware selection requires a dedicated result contract.", - "reviewAtSpec": "4.4.1" - }, - { - "location": "query", - "appleName": "filter[upfrontPricePointId]", - "disposition": "intentionallyOmitted", - "reason": "The current tool retains territory-focused pricing semantics; plan-aware selection requires a dedicated result contract.", - "reviewAtSpec": "4.4.1" - }, { "location": "query", "appleName": "include", @@ -3535,6 +3521,20 @@ "location": "query", "appleName": "filter[territory]" }, + { + "toolField": "upfront_price_point_ids", + "sourceKind": "parameter", + "operationId": "subscriptionPricePoints_equalizations_getToManyRelated", + "location": "query", + "appleName": "filter[upfrontPricePointId]" + }, + { + "toolField": "plan_types", + "sourceKind": "parameter", + "operationId": "subscriptionPricePoints_equalizations_getToManyRelated", + "location": "query", + "appleName": "filter[planType]" + }, { "toolField": "limit", "sourceKind": "parameter", @@ -3556,7 +3556,7 @@ ], "fields": [] }, - "note": "Maps current equalizations. Apple 4.4.1 also provides adjustedEqualizations, which this public tool does not expose." + "note": "Maps current equalizations with territory, subscription, upfront-price-point, and plan-type filters. Apple 4.4.1 also provides adjustedEqualizations through subscriptions_list_price_point_adjusted_equalizations." }, { "tool": "subscriptions_list_price_points", @@ -3572,20 +3572,6 @@ "role": "primary", "condition": null, "optionalParameterClassifications": [ - { - "location": "query", - "appleName": "filter[planType]", - "disposition": "intentionallyOmitted", - "reason": "The current tool retains territory-focused pricing semantics; plan-aware selection requires a dedicated result contract.", - "reviewAtSpec": "4.4.1" - }, - { - "location": "query", - "appleName": "filter[upfrontPricePointId]", - "disposition": "intentionallyOmitted", - "reason": "The current tool retains territory-focused pricing semantics; plan-aware selection requires a dedicated result contract.", - "reviewAtSpec": "4.4.1" - }, { "location": "query", "appleName": "include", @@ -3611,6 +3597,20 @@ "location": "query", "appleName": "filter[territory]" }, + { + "toolField": "upfront_price_point_ids", + "sourceKind": "parameter", + "operationId": "subscriptions_pricePoints_getToManyRelated", + "location": "query", + "appleName": "filter[upfrontPricePointId]" + }, + { + "toolField": "plan_types", + "sourceKind": "parameter", + "operationId": "subscriptions_pricePoints_getToManyRelated", + "location": "query", + "appleName": "filter[planType]" + }, { "toolField": "limit", "sourceKind": "parameter", @@ -3648,13 +3648,6 @@ "role": "primary", "condition": null, "optionalParameterClassifications": [ - { - "location": "query", - "appleName": "filter[planType]", - "disposition": "intentionallyOmitted", - "reason": "The current tool retains territory-focused pricing semantics; plan-aware selection requires a dedicated result contract.", - "reviewAtSpec": "4.4.1" - }, { "location": "query", "appleName": "include", @@ -3687,6 +3680,13 @@ "location": "query", "appleName": "filter[subscriptionPricePoint]" }, + { + "toolField": "plan_types", + "sourceKind": "parameter", + "operationId": "subscriptions_prices_getToManyRelated", + "location": "query", + "appleName": "filter[planType]" + }, { "toolField": "limit", "sourceKind": "parameter", @@ -3707,7 +3707,7 @@ ], "fields": [] }, - "note": "Apple 4.4 pricing is plan-aware; the public tool lacks filter[planType], so results may mix MONTHLY and UPFRONT plans." + "note": "Apple 4.4.1 pricing is plan-aware; callers can filter MONTHLY and UPFRONT plans while preserving the complete query across pagination." }, { "tool": "subscriptions_list_promotional_offer_prices", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/users.json b/Sources/asc-mcp/Resources/OperationManifest/tools/users.json index f97a2a7..395ec7b 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/users.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/users.json @@ -37,7 +37,7 @@ "tool": "users_cancel_invitation", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "userInvitations_deleteInstance", "method": "delete", "path": "/v1/userInvitations/{id}", "role": "primary" } @@ -96,17 +96,18 @@ "path": "/v1/userInvitations", "role": "primary", "inputs": [ - { "sourceKind": "fixed", "jsonPointer": "/data/type", "fixedValue": "userInvitations" } + { "sourceKind": "fixed", "jsonPointer": "/data/type", "fixedValue": "userInvitations" }, + { "sourceKind": "fixed", "jsonPointer": "/data/relationships/visibleApps/data/*/type", "fixedValue": "apps" } ] } ], "fields": [ - { "toolField": "email", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/email" }, + { "toolField": "email", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/email", "localRole": "validated as an email address before the request" }, { "toolField": "first_name", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/firstName" }, { "toolField": "last_name", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/lastName" }, { "toolField": "roles", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/roles", "localRole": "validated as a non-empty unique array against Apple 4.4.1 UserRole values" }, - { "toolField": "all_apps_visible", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/allAppsVisible", "localRole": "defaults to true when omitted" }, - { "toolField": "provisioning_allowed", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/provisioningAllowed" }, + { "toolField": "all_apps_visible", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/allAppsVisible", "localRole": "preserves omission, explicit null, and Boolean values" }, + { "toolField": "provisioning_allowed", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/attributes/provisioningAllowed", "localRole": "preserves omission, explicit null, and Boolean values" }, { "toolField": "visible_app_ids", "sourceKind": "requestBody", "operationId": "userInvitations_createInstance", "jsonPointer": "/data/relationships/visibleApps/data/*/id", "localRole": "explicit arrays are validated for non-empty unique string IDs and preserved, including an explicit empty relationship" } ], "response": { @@ -214,7 +215,8 @@ ], "fields": [ { "outputField": "apps", "operationId": "users_visibleApps_getToManyRelated", "jsonPointer": "/data" }, - { "outputField": "next_url", "operationId": "users_visibleApps_getToManyRelated", "jsonPointer": "/links/next" } + { "outputField": "next_url", "operationId": "users_visibleApps_getToManyRelated", "jsonPointer": "/links/next" }, + { "outputField": "total", "operationId": "users_visibleApps_getToManyRelated", "jsonPointer": "/meta/paging/total" } ] }, "note": "Covers every non-fieldset Apple 4.4.1 input for the related visible-app collection. The formatter intentionally returns a stable selected app projection." @@ -223,7 +225,7 @@ "tool": "users_remove", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "users_deleteInstance", "method": "delete", "path": "/v1/users/{id}", "role": "primary" } @@ -246,7 +248,7 @@ "tool": "users_remove_visible_apps", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { @@ -295,9 +297,9 @@ "fields": [ { "toolField": "user_id", "sourceKind": "parameter", "operationId": "users_updateInstance", "location": "path", "appleName": "id" }, { "toolField": "user_id", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/id" }, - { "toolField": "roles", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/roles", "localRole": "validated against Apple 4.4.1 UserRole values; ACCESS_TO_REPORTS remains accepted with a deprecation warning" }, - { "toolField": "all_apps_visible", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/allAppsVisible" }, - { "toolField": "provisioning_allowed", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/provisioningAllowed" }, + { "toolField": "roles", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/roles", "localRole": "preserves explicit null or validates a non-empty array against Apple 4.4.1 UserRole values; ACCESS_TO_REPORTS remains accepted with a deprecation warning" }, + { "toolField": "all_apps_visible", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/allAppsVisible", "localRole": "preserves omission, explicit null, and Boolean values" }, + { "toolField": "provisioning_allowed", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/attributes/provisioningAllowed", "localRole": "preserves omission, explicit null, and Boolean values" }, { "toolField": "visible_app_ids", "sourceKind": "requestBody", "operationId": "users_updateInstance", "jsonPointer": "/data/relationships/visibleApps/data/*/id", "localRole": "each non-empty unique ID is encoded as an apps resource linkage; an empty array clears the relationship" } ], "response": { diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/webhooks.json b/Sources/asc-mcp/Resources/OperationManifest/tools/webhooks.json index 4d4a75b..92c0bde 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/webhooks.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/webhooks.json @@ -42,7 +42,7 @@ "tool": "webhooks_delete", "kind": "direct", "status": "partial", - "effect": "write", + "effect": "destructive", "implementationState": "asBuilt", "operations": [ { "operationId": "webhooks_deleteInstance", "method": "delete", "path": "/v1/webhooks/{id}", "role": "primary" } @@ -287,11 +287,11 @@ "fields": [ { "toolField": "webhook_id", "sourceKind": "parameter", "operationId": "webhooks_updateInstance", "location": "path", "appleName": "id" }, { "toolField": "webhook_id", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/id" }, - { "toolField": "name", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/name" }, - { "toolField": "url", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/url", "localRole": "when supplied, requires an absolute HTTPS URL with a non-empty host and rejects URL user/password or fragments; ports, paths, and queries are preserved" }, - { "toolField": "secret", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/secret", "localRole": "when supplied, local hardening requires at least 32 characters and rejects low-diversity or repeated-pattern values" }, - { "toolField": "event_types", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/eventTypes", "localRole": "validated against the Apple 4.4.1 webhook event enum" }, - { "toolField": "enabled", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/enabled" } + { "toolField": "name", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/name", "localRole": "omission leaves the value unchanged; explicit null clears the nullable Apple attribute" }, + { "toolField": "url", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/url", "localRole": "omission leaves the value unchanged; explicit null clears the nullable Apple attribute; a supplied value requires an absolute HTTPS URL with a non-empty host and rejects URL user/password or fragments; ports, paths, and queries are preserved" }, + { "toolField": "secret", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/secret", "localRole": "omission leaves the value unchanged; explicit null clears the nullable Apple attribute; a supplied value must contain at least 32 characters and cannot use low-diversity or repeated patterns" }, + { "toolField": "event_types", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/eventTypes", "localRole": "omission leaves the value unchanged; explicit null clears the nullable Apple attribute; supplied values are validated against the Apple 4.4.1 webhook event enum" }, + { "toolField": "enabled", "sourceKind": "requestBody", "operationId": "webhooks_updateInstance", "jsonPointer": "/data/attributes/enabled", "localRole": "omission leaves the value unchanged; explicit null clears the nullable Apple attribute" } ], "response": { "mode": "projection", @@ -302,7 +302,7 @@ { "outputField": "webhook", "operationId": "webhooks_updateInstance", "jsonPointer": "/data" } ] }, - "note": "Exact Apple 4.4.1 update operation. The handler enforces at least one changed attribute and returns a selected projection without an MCP output schema." + "note": "Exact Apple 4.4.1 update operation. All five Apple update attributes are nullable; the handler preserves the distinction between omission, explicit null, and a supplied value, enforces at least one changed attribute, and returns a selected projection without an MCP output schema." }, { "tool": "webhooks_verify_signature", diff --git a/Sources/asc-mcp/Services/HTTPClient.swift b/Sources/asc-mcp/Services/HTTPClient.swift index bf70cd9..bf50ed2 100644 --- a/Sources/asc-mcp/Services/HTTPClient.swift +++ b/Sources/asc-mcp/Services/HTTPClient.swift @@ -69,7 +69,12 @@ public actor HTTPClient { return try await get(page.path, parameters: page.parameters) } - /// Performs GET request to App Store Connect API + /// Performs a GET request that must return HTTP 200. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - parameters: Query parameters to append to the request. + /// - Returns: Raw response body. + /// - Throws: `ASCError` when endpoint validation, authentication, transport, status validation, or retry handling fails. public func get(_ endpoint: String, parameters: [String: String] = [:]) async throws -> Data { let response = try await request(.GET, endpoint: endpoint, parameters: parameters) guard response.statusCode == 200 else { @@ -81,9 +86,24 @@ public actor HTTPClient { return response.data } - /// Performs POST request to App Store Connect API - public func post(_ endpoint: String, body: Data? = nil) async throws -> Data { - try await postReceipt(endpoint, body: body).data + /// Performs a POST request and validates its exact success status. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Optional raw request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - Returns: Raw response body after exact-status and HTTP 204 body validation. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified mutation state when applicable. + public func post( + _ endpoint: String, + body: Data? = nil, + expectedStatusCode: Int = 201 + ) async throws -> Data { + let receipt = try await postReceipt(endpoint, body: body) + return try Self.validatedMutationData( + receipt, + method: .POST, + expectedStatusCode: expectedStatusCode + ) } func postReceipt(_ endpoint: String, body: Data? = nil) async throws -> ASCMutationReceipt { @@ -91,9 +111,24 @@ public actor HTTPClient { return ASCMutationReceipt(data: response.data, statusCode: response.statusCode) } - /// Performs PATCH request to App Store Connect API - public func patch(_ endpoint: String, body: Data) async throws -> Data { - try await patchReceipt(endpoint, body: body).data + /// Performs a PATCH request and validates its exact success status. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Raw request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - Returns: Raw response body after exact-status and HTTP 204 body validation. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified mutation state when applicable. + public func patch( + _ endpoint: String, + body: Data, + expectedStatusCode: Int = 200 + ) async throws -> Data { + let receipt = try await patchReceipt(endpoint, body: body) + return try Self.validatedMutationData( + receipt, + method: .PATCH, + expectedStatusCode: expectedStatusCode + ) } func patchReceipt(_ endpoint: String, body: Data) async throws -> ASCMutationReceipt { @@ -101,15 +136,34 @@ public actor HTTPClient { return ASCMutationReceipt(data: response.data, statusCode: response.statusCode) } - /// Performs PUT request to App Store Connect API - public func put(_ endpoint: String, body: Data) async throws -> Data { - return try await request(.PUT, endpoint: endpoint, body: body).data + /// Performs a PUT request and validates its exact success status. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Raw request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - Returns: Raw response body after exact-status and HTTP 204 body validation. + /// - Throws: `ASCError` when request, status, or response-body validation fails. + public func put( + _ endpoint: String, + body: Data, + expectedStatusCode: Int = 200 + ) async throws -> Data { + let response = try await request(.PUT, endpoint: endpoint, body: body) + let receipt = ASCMutationReceipt(data: response.data, statusCode: response.statusCode) + return try Self.validatedMutationData( + receipt, + method: .PUT, + expectedStatusCode: expectedStatusCode + ) } - /// Performs DELETE request to App Store Connect API + /// Performs a DELETE request that must return an empty HTTP 204 response. + /// - Parameter endpoint: Root-relative App Store Connect API path. + /// - Returns: The empty response body. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified deletion state when applicable. public func delete(_ endpoint: String) async throws -> Data { let receipt = try await deleteReceipt(endpoint) - guard receipt.statusCode == 204 else { + guard receipt.statusCode == 204, receipt.data.isEmpty else { throw ASCError.deleteCommittedUnverified(statusCode: receipt.statusCode) } return receipt.data @@ -120,10 +174,15 @@ public actor HTTPClient { return ASCDeleteReceipt(data: response.data, statusCode: response.statusCode) } - /// Performs DELETE request with body (for relationship endpoints) + /// Performs a relationship DELETE request that must return an empty HTTP 204 response. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Raw relationship request body. + /// - Returns: The empty response body. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified deletion state when applicable. public func delete(_ endpoint: String, body: Data) async throws -> Data { let receipt = try await deleteReceipt(endpoint, body: body) - guard receipt.statusCode == 204 else { + guard receipt.statusCode == 204, receipt.data.isEmpty else { throw ASCError.deleteCommittedUnverified(statusCode: receipt.statusCode) } return receipt.data @@ -205,13 +264,15 @@ public actor HTTPClient { do { (data, httpResponse) = try await transport.data(for: request) } catch let error as ASCError { - if method == .DELETE, - let outcomeUnknown = Self.deleteOutcomeUnknownError(from: error) { + if let outcomeUnknown = Self.mutationOutcomeUnknownError( + method: method, + from: error + ) { throw outcomeUnknown } throw error } catch { - if error is CancellationError, method != .DELETE { + if error is CancellationError, !method.isNonIdempotentMutation { throw CancellationError() } if attempt < maxAttempts - 1 && retriesAmbiguousFailures { @@ -223,8 +284,10 @@ public actor HTTPClient { logger.error("Network request failed: \(error.localizedDescription)") let networkError = ASCError.network("HTTP request failed: \(error.localizedDescription)") - if method == .DELETE, - let outcomeUnknown = Self.deleteOutcomeUnknownError(from: networkError) { + if let outcomeUnknown = Self.mutationOutcomeUnknownError( + method: method, + from: networkError + ) { throw outcomeUnknown } throw networkError @@ -272,8 +335,10 @@ public actor HTTPClient { } else { responseError = .api(errorMessage, httpResponse.statusCode) } - if method == .DELETE, - let outcomeUnknown = Self.deleteOutcomeUnknownError(from: responseError) { + if let outcomeUnknown = Self.mutationOutcomeUnknownError( + method: method, + from: responseError + ) { throw outcomeUnknown } throw responseError @@ -282,25 +347,65 @@ public actor HTTPClient { throw ASCError.network("Maximum retry attempts exceeded before a request was sent") } - private static func deleteOutcomeUnknownError(from error: ASCError) -> ASCError? { - if case .deleteOutcomeUnknown = error { - return nil - } - + private static func mutationOutcomeUnknownError( + method: HTTPMethod, + from error: ASCError + ) -> ASCError? { + guard method.isNonIdempotentMutation else { return nil } switch error { + case .mutationOutcomeUnknown, .deleteOutcomeUnknown: + return nil case .network(let message): - return .deleteOutcomeUnknown(.network(message)) + return outcomeUnknownError(method: method, cause: .network(message)) case .api(let message, let statusCode) - where statusCode == 408 || (500...599).contains(statusCode): - return .deleteOutcomeUnknown(.api(message, statusCode)) + where (300...399).contains(statusCode) + || statusCode == 408 + || statusCode >= 500: + return outcomeUnknownError(method: method, cause: .api(message, statusCode)) case .apiResponse(_, let statusCode) - where statusCode == 408 || (500...599).contains(statusCode): - return .deleteOutcomeUnknown(error) + where (300...399).contains(statusCode) + || statusCode == 408 + || statusCode >= 500: + return outcomeUnknownError(method: method, cause: error) default: return nil } } + private static func outcomeUnknownError( + method: HTTPMethod, + cause: ASCError + ) -> ASCError { + if method == .DELETE { + return .deleteOutcomeUnknown(cause) + } + return .mutationOutcomeUnknown(method: method.rawValue, cause: cause) + } + + private static func validatedMutationData( + _ receipt: ASCMutationReceipt, + method: HTTPMethod, + expectedStatusCode: Int + ) throws -> Data { + guard receipt.statusCode == expectedStatusCode else { + throw ASCError.mutationCommittedUnverified( + method: method.rawValue, + expectedStatusCode: expectedStatusCode, + actualStatusCode: receipt.statusCode, + cause: nil + ) + } + guard expectedStatusCode != 204 || receipt.data.isEmpty else { + throw ASCError.mutationCommittedUnverified( + method: method.rawValue, + expectedStatusCode: expectedStatusCode, + actualStatusCode: receipt.statusCode, + cause: .parsing("HTTP 204 response must not contain a response body") + ) + } + return receipt.data + } + /// Calculates retry delay with exponential backoff private func calculateRetryDelay(attempt: Int, response: HTTPURLResponse?) -> Double { // Check Retry-After header @@ -388,6 +493,10 @@ private enum HTTPMethod: String { case PUT = "PUT" case PATCH = "PATCH" case DELETE = "DELETE" + + var isNonIdempotentMutation: Bool { + self == .POST || self == .PATCH || self == .DELETE + } } private extension HTTPURLResponse { @@ -420,7 +529,13 @@ extension HTTPClient { } } - /// Decodes JSON response into specified type + /// Fetches an HTTP 200 JSON resource and decodes it as the requested type. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - parameters: Query parameters to append to the request. + /// - type: Expected response type. + /// - Returns: Decoded response value. + /// - Throws: `ASCError` when request, status, or decoding fails. public func get(_ endpoint: String, parameters: [String: String] = [:], as type: T.Type) async throws -> T { let data = try await get(endpoint, parameters: parameters) @@ -431,8 +546,20 @@ extension HTTPClient { } } - /// POST request with JSON body encoding - public func post(_ endpoint: String, body: T, as responseType: R.Type) async throws -> R { + /// Encodes a JSON body, performs an exact-status POST, and decodes the response. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Encodable request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - responseType: Expected response type. + /// - Returns: Decoded response value. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified mutation state when applicable. + public func post( + _ endpoint: String, + body: T, + expectedStatusCode: Int = 201, + as responseType: R.Type + ) async throws -> R { let bodyData: Data do { bodyData = try JSONEncoder().encode(body) @@ -440,17 +567,38 @@ extension HTTPClient { throw ASCError.parsing("Failed to encode request body: \(error.localizedDescription)") } - let responseData = try await post(endpoint, body: bodyData) + let responseData = try await post( + endpoint, + body: bodyData, + expectedStatusCode: expectedStatusCode + ) do { return try JSONDecoder().decode(responseType, from: responseData) } catch { - throw ASCError.parsing("Failed to decode \(responseType): \(error.localizedDescription)") + throw ASCError.mutationCommittedUnverified( + method: HTTPMethod.POST.rawValue, + expectedStatusCode: expectedStatusCode, + actualStatusCode: expectedStatusCode, + cause: .parsing("Failed to decode \(responseType): \(error.localizedDescription)") + ) } } - /// PATCH request for updating resources - public func patch(_ endpoint: String, body: T, as responseType: R.Type) async throws -> R { + /// Encodes a JSON body, performs an exact-status PATCH, and decodes the response. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Encodable request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - responseType: Expected response type. + /// - Returns: Decoded response value. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified mutation state when applicable. + public func patch( + _ endpoint: String, + body: T, + expectedStatusCode: Int = 200, + as responseType: R.Type + ) async throws -> R { let bodyData: Data do { bodyData = try JSONEncoder().encode(body) @@ -458,17 +606,38 @@ extension HTTPClient { throw ASCError.parsing("Failed to encode request body: \(error.localizedDescription)") } - let responseData = try await request(.PATCH, endpoint: endpoint, body: bodyData).data + let responseData = try await patch( + endpoint, + body: bodyData, + expectedStatusCode: expectedStatusCode + ) do { return try JSONDecoder().decode(responseType, from: responseData) } catch { - throw ASCError.parsing("Failed to decode \(responseType): \(error.localizedDescription)") + throw ASCError.mutationCommittedUnverified( + method: HTTPMethod.PATCH.rawValue, + expectedStatusCode: expectedStatusCode, + actualStatusCode: expectedStatusCode, + cause: .parsing("Failed to decode \(responseType): \(error.localizedDescription)") + ) } } - /// PUT request with JSON encoding - public func put(_ endpoint: String, body: T, as responseType: R.Type) async throws -> R { + /// Encodes a JSON body, performs an exact-status PUT, and decodes the response. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Encodable request body. + /// - expectedStatusCode: Exact successful status required by the Apple operation contract. + /// - responseType: Expected response type. + /// - Returns: Decoded response value. + /// - Throws: `ASCError` when encoding, request, status, or decoding fails. + public func put( + _ endpoint: String, + body: T, + expectedStatusCode: Int = 200, + as responseType: R.Type + ) async throws -> R { let bodyData: Data do { bodyData = try JSONEncoder().encode(body) @@ -476,16 +645,30 @@ extension HTTPClient { throw ASCError.parsing("Failed to encode request body: \(error.localizedDescription)") } - let responseData = try await put(endpoint, body: bodyData) + let responseData = try await put( + endpoint, + body: bodyData, + expectedStatusCode: expectedStatusCode + ) do { return try JSONDecoder().decode(responseType, from: responseData) } catch { - throw ASCError.parsing("Failed to decode \(responseType): \(error.localizedDescription)") + throw ASCError.mutationCommittedUnverified( + method: HTTPMethod.PUT.rawValue, + expectedStatusCode: expectedStatusCode, + actualStatusCode: expectedStatusCode, + cause: .parsing("Failed to decode \(responseType): \(error.localizedDescription)") + ) } } - /// DELETE request with JSON body encoding + /// Encodes a relationship body and performs a DELETE requiring an empty HTTP 204 response. + /// - Parameters: + /// - endpoint: Root-relative App Store Connect API path. + /// - body: Encodable relationship request body. + /// - Returns: The empty response body. + /// - Throws: `ASCError`, including an indeterminate or committed-but-unverified deletion state when applicable. public func delete(_ endpoint: String, body: T) async throws -> Data { let bodyData: Data do { diff --git a/Sources/asc-mcp/Tooling/ASCOperationToolAnalyzer.swift b/Sources/asc-mcp/Tooling/ASCOperationToolAnalyzer.swift index d8828af..3f5ed1e 100644 --- a/Sources/asc-mcp/Tooling/ASCOperationToolAnalyzer.swift +++ b/Sources/asc-mcp/Tooling/ASCOperationToolAnalyzer.swift @@ -758,6 +758,16 @@ struct ASCOperationToolAnalyzer: Sendable { let readMethods: Set = ["get", "head", "options", "trace"] let manifestReadOnly = mapping.effect == .read || mapping.effect == .local + if mapping.kind == .direct, + methods == Set(["delete"]), + mapping.effect != .destructive { + return [error( + .toolAppleEffectMismatch, + "A direct Apple DELETE operation must be published as destructive.", + tool: mapping.tool + )] + } + if methods.isSubset(of: readMethods) && !manifestReadOnly { return [warning( .toolAppleEffectMismatch, diff --git a/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+Handlers.swift b/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+Handlers.swift index 05a2f1f..17bcc67 100644 --- a/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+Handlers.swift @@ -30,7 +30,7 @@ extension AccessibilityWorker { do { let response: ASCAccessibilityDeclarationsResponse let path = "/v1/apps/\(try ASCPathSegment.encode(appID))/accessibilityDeclarations" - var query = defaultListQuery(arguments: arguments) + var query = try defaultListQuery(arguments: arguments) if let deviceFamilies = parseStringList(arguments["device_family"]) { query["filter[deviceFamily]"] = deviceFamilies.joined(separator: ",") } @@ -114,12 +114,20 @@ extension AccessibilityWorker { supports: supportAttributes(from: arguments) ) let response = try await httpClient.post("/v1/accessibilityDeclarations", body: request, as: ASCAccessibilityDeclarationResponse.self) + try validateAcceptedAccessibilityMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "accessibilityDeclarations", + method: "POST", + statusCode: 201, + context: "Apple accessibility declaration create response" + ) return MCPResult.jsonObject([ "success": true, "accessibility_declaration": formatDeclaration(response.data) ]) } catch { - return MCPResult.error("Failed to create accessibility declaration: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to create accessibility declaration") } } @@ -144,12 +152,21 @@ extension AccessibilityWorker { let request = ASCAccessibilityDeclarationUpdateRequest(declarationID: declarationID, attributes: attributes) let response = try await httpClient.patch("/v1/accessibilityDeclarations/\(try ASCPathSegment.encode(declarationID))", body: request, as: ASCAccessibilityDeclarationResponse.self) + try validateAcceptedAccessibilityMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "accessibilityDeclarations", + expectedID: declarationID, + method: "PATCH", + statusCode: 200, + context: "Apple accessibility declaration update response" + ) return MCPResult.jsonObject([ "success": true, "accessibility_declaration": formatDeclaration(response.data) ]) } catch { - return MCPResult.error("Failed to update accessibility declaration: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to update accessibility declaration") } } @@ -186,7 +203,7 @@ extension AccessibilityWorker { do { let endpoint = "/v1/apps/\(try ASCPathSegment.encode(appID))/relationships/accessibilityDeclarations" - let query = defaultListQuery(arguments: arguments) + let query = try defaultListQuery(arguments: arguments) let response: ASCAccessibilityDeclarationLinkagesResponse if let nextURL = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -215,9 +232,45 @@ extension AccessibilityWorker { } } - private func defaultListQuery(arguments: [String: Value]) -> [String: String] { - let limit = arguments["limit"]?.intValue ?? 25 - return ["limit": String(min(max(limit, 1), 200))] + private func defaultListQuery(arguments: [String: Value]) throws -> [String: String] { + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + throw AccessibilityArgumentError("'limit' must be an integer between 1 and 200") + } + limit = parsed + } else { + limit = 25 + } + return ["limit": String(limit)] + } + + private func validateAcceptedAccessibilityMutationResource( + type: String, + id: String, + expectedType: String, + expectedID: String? = nil, + method: String, + statusCode: Int, + context: String + ) throws { + do { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: type, + id: id, + expectedType: expectedType, + expectedID: expectedID, + context: context + ) + } catch { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + throw ASCError.mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } } private func supportAttributes(from arguments: [String: Value]) -> ASCAccessibilityDeclarationSupportAttributes { diff --git a/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+ToolDefinitions.swift index 288e4b0..9cfb4c0 100644 --- a/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AccessibilityWorker/AccessibilityWorker+ToolDefinitions.swift @@ -138,11 +138,14 @@ extension AccessibilityWorker { private func enumListSchema(_ description: String, values: [String]) -> Value { let enumValues = Value.array(values.map(Value.string)) + let alternatives = values.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") + let scalarPattern = "^\\s*(?:\(alternatives))(?:\\s*,\\s*(?:\(alternatives)))*\\s*$" return .object([ "description": .string(description + ": " + values.joined(separator: ", ")), "oneOf": .array([ .object([ - "type": .string("string") + "type": .string("string"), + "pattern": .string(scalarPattern) ]), .object([ "type": .string("array"), diff --git a/Sources/asc-mcp/Workers/AnalyticsWorker/AnalyticsWorker+Handlers.swift b/Sources/asc-mcp/Workers/AnalyticsWorker/AnalyticsWorker+Handlers.swift index 39d1c75..100c83f 100644 --- a/Sources/asc-mcp/Workers/AnalyticsWorker/AnalyticsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AnalyticsWorker/AnalyticsWorker+Handlers.swift @@ -271,7 +271,12 @@ extension AnalyticsWorker { ) } let summaryOnly = arguments["summary_only"]?.boolValue ?? true - let limit = min(max(arguments["limit"]?.intValue ?? 25, 1), 200) + let limit: Int + do { + limit = try Self.collectionLimit(arguments["limit"]) + } catch { + return MCPResult.error(error) + } let appIdFilter: String? if let rawAppId = arguments["app_id"]?.stringValue { guard let normalizedAppId = Self.nonEmptyIdentifier(rawAppId) else { @@ -475,7 +480,12 @@ extension AnalyticsWorker { } let summaryOnly = arguments["summary_only"]?.boolValue ?? true - let limit = min(max(arguments["limit"]?.intValue ?? 25, 1), 200) + let limit: Int + do { + limit = try Self.collectionLimit(arguments["limit"]) + } catch { + return MCPResult.error(error) + } do { let queryParams: [String: String] = [ @@ -653,10 +663,7 @@ extension AnalyticsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create analytics report request: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create analytics report request") } } diff --git a/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+Handlers.swift index ce43a70..9a622cf 100644 --- a/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+Handlers.swift @@ -20,7 +20,7 @@ extension AppEventsWorker { do { let path = "/v1/apps/\(try ASCPathSegment.encode(appId))/appEvents" var queryParams = [ - "limit": String(boundedLimit(arguments["limit"], maximum: 200, defaultValue: 25)) + "limit": String(try boundedLimit(arguments["limit"], field: "limit", maximum: 200, defaultValue: 25)) ] if let states = try stringList( arguments["event_states"], @@ -41,7 +41,12 @@ extension AppEventsWorker { } if arguments["localizations_limit"] != nil { queryParams["limit[localizations]"] = String( - boundedLimit(arguments["localizations_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["localizations_limit"], + field: "localizations_limit", + maximum: 50, + defaultValue: 50 + ) ) } @@ -110,7 +115,12 @@ extension AppEventsWorker { } if arguments["localizations_limit"] != nil { queryParams["limit[localizations]"] = String( - boundedLimit(arguments["localizations_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["localizations_limit"], + field: "localizations_limit", + maximum: 50, + defaultValue: 50 + ) ) } @@ -191,8 +201,7 @@ extension AppEventsWorker { deepLink: try nullableAbsoluteURIValue(arguments["deep_link"], field: "deep_link"), purchaseRequirement: try nullableStringValue( arguments["purchase_requirement"], - field: "purchase_requirement", - allowedValues: allowedPurchaseRequirements + field: "purchase_requirement" ), primaryLocale: try nullableStringValue(arguments["primary_locale"], field: "primary_locale"), priority: try nullableStringValue( @@ -220,6 +229,14 @@ extension AppEventsWorker { body: request, as: ASCAppEventResponse.self ) + try validateAcceptedAppEventMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appEvents", + method: "POST", + statusCode: 201, + context: "Apple app event create response" + ) let result = [ "success": true, @@ -229,10 +246,7 @@ extension AppEventsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create app event: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create app event") } } @@ -267,8 +281,7 @@ extension AppEventsWorker { deepLink: try nullableAbsoluteURIValue(arguments["deep_link"], field: "deep_link"), purchaseRequirement: try nullableStringValue( arguments["purchase_requirement"], - field: "purchase_requirement", - allowedValues: allowedPurchaseRequirements + field: "purchase_requirement" ), primaryLocale: try nullableStringValue(arguments["primary_locale"], field: "primary_locale"), priority: try nullableStringValue( @@ -299,6 +312,15 @@ extension AppEventsWorker { body: request, as: ASCAppEventResponse.self ) + try validateAcceptedAppEventMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appEvents", + expectedID: eventId, + method: "PATCH", + statusCode: 200, + context: "Apple app event update response" + ) let result = [ "success": true, @@ -308,10 +330,7 @@ extension AppEventsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update app event: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update app event") } } @@ -359,7 +378,7 @@ extension AppEventsWorker { do { let path = "/v1/appEvents/\(try ASCPathSegment.encode(eventId))/localizations" var query = [ - "limit": String(boundedLimit(arguments["limit"], maximum: 200, defaultValue: 25)) + "limit": String(try boundedLimit(arguments["limit"], field: "limit", maximum: 200, defaultValue: 25)) ] if let includes = try stringList( arguments["include"], @@ -370,12 +389,22 @@ extension AppEventsWorker { } if arguments["screenshots_limit"] != nil { query["limit[appEventScreenshots]"] = String( - boundedLimit(arguments["screenshots_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["screenshots_limit"], + field: "screenshots_limit", + maximum: 50, + defaultValue: 50 + ) ) } if arguments["video_clips_limit"] != nil { query["limit[appEventVideoClips]"] = String( - boundedLimit(arguments["video_clips_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["video_clips_limit"], + field: "video_clips_limit", + maximum: 50, + defaultValue: 50 + ) ) } @@ -473,6 +502,14 @@ extension AppEventsWorker { body: request, as: ASCAppEventLocalizationSingleResponse.self ) + try validateAcceptedAppEventMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appEventLocalizations", + method: "POST", + statusCode: 201, + context: "Apple app event localization create response" + ) let result: [String: Any] = [ "success": true, @@ -482,10 +519,7 @@ extension AppEventsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create app event localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create app event localization") } } @@ -534,6 +568,15 @@ extension AppEventsWorker { body: request, as: ASCAppEventLocalizationSingleResponse.self ) + try validateAcceptedAppEventMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appEventLocalizations", + expectedID: localizationId, + method: "PATCH", + statusCode: 200, + context: "Apple app event localization update response" + ) let result: [String: Any] = [ "success": true, @@ -543,10 +586,7 @@ extension AppEventsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update app event localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update app event localization") } } @@ -662,12 +702,45 @@ extension AppEventsWorker { ["APPROPRIATE_FOR_ALL_USERS", "ATTRACT_NEW_USERS", "KEEP_ACTIVE_USERS_INFORMED", "BRING_BACK_LAPSED_USERS"] } - private var allowedPurchaseRequirements: Set { - ["NO_COST_ASSOCIATED", "IN_APP_PURCHASE"] + private func boundedLimit( + _ value: Value?, + field: String, + maximum: Int, + defaultValue: Int + ) throws -> Int { + guard let value else { return defaultValue } + guard let limit = value.intValue, (1...maximum).contains(limit) else { + throw AppEventArgumentError("'\(field)' must be an integer between 1 and \(maximum)") + } + return limit } - private func boundedLimit(_ value: Value?, maximum: Int, defaultValue: Int) -> Int { - min(max(value?.intValue ?? defaultValue, 1), maximum) + private func validateAcceptedAppEventMutationResource( + type: String, + id: String, + expectedType: String, + expectedID: String? = nil, + method: String, + statusCode: Int, + context: String + ) throws { + do { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: type, + id: id, + expectedType: expectedType, + expectedID: expectedID, + context: context + ) + } catch { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + throw ASCError.mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } } private func stringList( diff --git a/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+ToolDefinitions.swift index 9f353dd..2c593de 100644 --- a/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AppEventsWorker/AppEventsWorker+ToolDefinitions.swift @@ -90,10 +90,7 @@ extension AppEventsWorker { "format": .string("uri"), "description": .string("Absolute deep-link URI to open the event in the app; null clears it") ]), - "purchase_requirement": nullableEnumSchema( - "Purchase requirement", - values: purchaseRequirements - ), + "purchase_requirement": nullableStringSchema("Purchase requirement string; null clears Apple's value"), "primary_locale": nullableStringSchema("Primary locale code for the event"), "priority": nullableEnumSchema("Event priority", values: ["HIGH", "NORMAL"]), "purpose": nullableEnumSchema("Event purpose", values: appEventPurposes), @@ -125,10 +122,7 @@ extension AppEventsWorker { "format": .string("uri"), "description": .string("Absolute deep-link URI to open the event in the app; null clears it") ]), - "purchase_requirement": nullableEnumSchema( - "Purchase requirement", - values: purchaseRequirements - ), + "purchase_requirement": nullableStringSchema("Purchase requirement string; null clears Apple's value"), "primary_locale": nullableStringSchema("Primary locale code for the event"), "priority": nullableEnumSchema("Event priority", values: ["HIGH", "NORMAL"]), "purpose": nullableEnumSchema("Event purpose", values: appEventPurposes), @@ -295,10 +289,6 @@ extension AppEventsWorker { ["APPROPRIATE_FOR_ALL_USERS", "ATTRACT_NEW_USERS", "KEEP_ACTIVE_USERS_INFORMED", "BRING_BACK_LAPSED_USERS"] } - private var purchaseRequirements: [String] { - ["NO_COST_ASSOCIATED", "IN_APP_PURCHASE"] - } - private func boundedIntegerSchema(_ description: String, maximum: Int) -> Value { .object([ "type": .string("integer"), @@ -340,10 +330,15 @@ extension AppEventsWorker { private func stringOrArrayEnumSchema(_ description: String, values: [String]) -> Value { let enumValues = Value.array(values.map(Value.string)) + let alternatives = values.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") + let scalarPattern = "^\\s*(?:\(alternatives))\\s*(?:,\\s*(?:\(alternatives))\\s*)*$" return .object([ "description": .string(description + ": " + values.joined(separator: ", ")), "oneOf": .array([ - .object(["type": .string("string")]), + .object([ + "type": .string("string"), + "pattern": .string(scalarPattern) + ]), .object([ "type": .string("array"), "items": .object([ diff --git a/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+Handlers.swift index aa3607b..082372d 100644 --- a/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+Handlers.swift @@ -58,7 +58,11 @@ extension AppInfoWorker { do { let path = "/v1/apps/\(try ASCPathSegment.encode(appId))/appInfos" - var query = ["limit": String(boundedLimit(arguments["limit"], maximum: 200, defaultValue: 25))] + var query = [ + "limit": String( + try boundedLimit(arguments["limit"], field: "limit", maximum: 200, defaultValue: 25) + ) + ] if let includes = try stringList( arguments["include"], field: "include", @@ -68,7 +72,12 @@ extension AppInfoWorker { } if arguments["localizations_limit"] != nil { query["limit[appInfoLocalizations]"] = String( - boundedLimit(arguments["localizations_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["localizations_limit"], + field: "localizations_limit", + maximum: 50, + defaultValue: 50 + ) ) } @@ -133,7 +142,12 @@ extension AppInfoWorker { } if arguments["localizations_limit"] != nil { queryParams["limit[appInfoLocalizations]"] = String( - boundedLimit(arguments["localizations_limit"], maximum: 50, defaultValue: 50) + try boundedLimit( + arguments["localizations_limit"], + field: "localizations_limit", + maximum: 50, + defaultValue: 50 + ) ) } @@ -246,6 +260,15 @@ extension AppInfoWorker { body: request, as: ASCAppInfoResponse.self ) + try validateAcceptedAppInfoMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appInfos", + expectedID: infoId, + method: "PATCH", + statusCode: 200, + context: "Apple app info update response" + ) let result = [ "success": true, @@ -255,10 +278,7 @@ extension AppInfoWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update app info: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update app info") } } @@ -276,7 +296,11 @@ extension AppInfoWorker { do { let path = "/v1/appInfos/\(try ASCPathSegment.encode(infoId))/appInfoLocalizations" - var query = ["limit": String(boundedLimit(arguments["limit"], maximum: 200, defaultValue: 25))] + var query = [ + "limit": String( + try boundedLimit(arguments["limit"], field: "limit", maximum: 200, defaultValue: 25) + ) + ] if let locales = try stringList(arguments["locale"], field: "locale") { query["filter[locale]"] = locales.joined(separator: ",") } @@ -379,6 +403,15 @@ extension AppInfoWorker { body: request, as: ASCAppInfoLocalizationResponse.self ) + try validateAcceptedAppInfoMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appInfoLocalizations", + expectedID: localizationId, + method: "PATCH", + statusCode: 200, + context: "Apple app info localization update response" + ) let localization = formatAppInfoLocalization(response.data) @@ -390,7 +423,7 @@ extension AppInfoWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to update app info localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to update app info localization") } } @@ -441,6 +474,14 @@ extension AppInfoWorker { body: request, as: ASCAppInfoLocalizationResponse.self ) + try validateAcceptedAppInfoMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appInfoLocalizations", + method: "POST", + statusCode: 201, + context: "Apple app info localization create response" + ) let localization = formatAppInfoLocalization(response.data) @@ -452,7 +493,7 @@ extension AppInfoWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to create app info localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to create app info localization") } } @@ -572,7 +613,18 @@ extension AppInfoWorker { "/v1/endUserLicenseAgreements", body: bodyData ) - let response = try JSONDecoder().decode(ASCEULAResponse.self, from: responseData) + let response: ASCEULAResponse + do { + response = try JSONDecoder().decode(ASCEULAResponse.self, from: responseData) + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: response.data.type, + id: response.data.id, + expectedType: "endUserLicenseAgreements", + context: "Apple EULA create response" + ) + } catch { + throw committedUnverifiedAppInfoMutation(error, method: "POST", statusCode: 201) + } let result: [String: Any] = [ "success": true, @@ -583,10 +635,7 @@ extension AppInfoWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create EULA: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create EULA") } } @@ -646,7 +695,19 @@ extension AppInfoWorker { "/v1/endUserLicenseAgreements/\(try ASCPathSegment.encode(eulaId))", body: bodyData ) - let response = try JSONDecoder().decode(ASCEULAResponse.self, from: responseData) + let response: ASCEULAResponse + do { + response = try JSONDecoder().decode(ASCEULAResponse.self, from: responseData) + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: response.data.type, + id: response.data.id, + expectedType: "endUserLicenseAgreements", + expectedID: eulaId, + context: "Apple EULA update response" + ) + } catch { + throw committedUnverifiedAppInfoMutation(error, method: "PATCH", statusCode: 200) + } let result: [String: Any] = [ "success": true, @@ -657,10 +718,7 @@ extension AppInfoWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update EULA: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update EULA") } } @@ -818,8 +876,53 @@ extension AppInfoWorker { return values } - private func boundedLimit(_ value: Value?, maximum: Int, defaultValue: Int) -> Int { - min(max(value?.intValue ?? defaultValue, 1), maximum) + private func boundedLimit( + _ value: Value?, + field: String, + maximum: Int, + defaultValue: Int + ) throws -> Int { + guard let value else { return defaultValue } + guard let limit = value.intValue, (1...maximum).contains(limit) else { + throw AppInfoArgumentError("'\(field)' must be an integer between 1 and \(maximum)") + } + return limit + } + + private func validateAcceptedAppInfoMutationResource( + type: String, + id: String, + expectedType: String, + expectedID: String? = nil, + method: String, + statusCode: Int, + context: String + ) throws { + do { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: type, + id: id, + expectedType: expectedType, + expectedID: expectedID, + context: context + ) + } catch { + throw committedUnverifiedAppInfoMutation(error, method: method, statusCode: statusCode) + } + } + + private func committedUnverifiedAppInfoMutation( + _ error: Error, + method: String, + statusCode: Int + ) -> ASCError { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) } private func nullableString(_ value: Value?, field: String) throws -> JSONValue? { diff --git a/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+ToolDefinitions.swift index 6f0a618..a17a856 100644 --- a/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AppInfoWorker/AppInfoWorker+ToolDefinitions.swift @@ -353,11 +353,14 @@ extension AppInfoWorker { private func stringOrArrayEnumSchema(_ description: String, values: [String]) -> Value { let enumValues = Value.array(values.map(Value.string)) + let alternatives = values.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") + let scalarPattern = "^\\s*(?:\(alternatives))(?:\\s*,\\s*(?:\(alternatives)))*\\s*$" return .object([ "description": .string(description), "oneOf": .array([ .object([ - "type": .string("string") + "type": .string("string"), + "pattern": .string(scalarPattern) ]), .object([ "type": .string("array"), diff --git a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift index 8df4249..361a3cd 100644 --- a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift @@ -67,10 +67,19 @@ extension AppLifecycleWorker { } do { - let releaseType = arguments["release_type"]?.stringValue ?? "MANUAL" - let earliestDate = arguments["earliest_release_date"]?.stringValue - let copyright = arguments["copyright"]?.stringValue - let reviewType = arguments["review_type"]?.stringValue + let releaseType = try nullableString( + "release_type", + from: arguments, + allowedValues: ["MANUAL", "AFTER_APPROVAL", "SCHEDULED"] + ) + let earliestDate = try nullableString("earliest_release_date", from: arguments) + try validateNullableDateTime(earliestDate, name: "earliest_release_date") + let copyright = try nullableString("copyright", from: arguments) + let reviewType = try nullableString( + "review_type", + from: arguments, + allowedValues: ["APP_STORE", "NOTARIZATION"] + ) let usesIdfa = try nullableBool("uses_idfa", from: arguments) let request = CreateAppStoreVersionRequest( @@ -131,10 +140,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create version: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create version") } } @@ -475,6 +481,7 @@ extension AppLifecycleWorker { allowedValues: ["MANUAL", "AFTER_APPROVAL", "SCHEDULED"] ) let earliestDate = try nullableString("earliest_release_date", from: arguments) + try validateNullableDateTime(earliestDate, name: "earliest_release_date") let copyright = try nullableString("copyright", from: arguments) let versionString = try nullableString("version_string", from: arguments) let reviewType = try nullableString( @@ -554,10 +561,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update version: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update version") } } @@ -580,7 +584,8 @@ extension AppLifecycleWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.patch( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))/relationships/build", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result: [String: Any] = [ @@ -591,10 +596,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to attach build: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to attach build") } } @@ -781,7 +783,7 @@ extension AppLifecycleWorker { error: error ) } - return MCPResult.error("Failed to submit for review: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to submit for review") } } @@ -790,18 +792,40 @@ extension AppLifecycleWorker { failedStep: String, error: any Error ) -> CallTool.Result { - MCPResult.jsonObject( - [ - "success": false, - "partial_success": true, - "submission_id": submissionId, - "failed_step": failedStep, - "error": error.localizedDescription, - "recovery_tools": [ - "app_versions_cancel_review" - ], - "message": "Review submission was created, but the submit flow failed before completion. This MCP has no review-submission inspection or resume tool; use app_versions_cancel_review with review_submission_id set to the returned submission_id before retrying." - ], + var payload: [String: Value] = [ + "success": .bool(false), + "partial_success": .bool(true), + "submission_id": .string(submissionId), + "failed_step": .string(failedStep), + "error": .string(Redactor.redact(error.localizedDescription)), + "recovery_tools": .array([.string("app_versions_cancel_review")]), + "message": .string("Review submission was created, but the submit flow failed before completion. This MCP has no review-submission inspection or resume tool; use app_versions_cancel_review with review_submission_id set to the returned submission_id before retrying.") + ] + + if let ascError = error as? ASCError { + payload["details"] = ascError.structuredValue + switch ascError { + case .mutationOutcomeUnknown: + payload["operationCommitState"] = .string("unknown") + payload["outcomeUnknown"] = .bool(true) + payload["retrySafe"] = .bool(false) + payload["inspectionRequired"] = .bool(true) + case .mutationCommittedUnverified: + payload["operationCommitState"] = .string("committed_unverified") + payload["operationCommitted"] = .bool(true) + payload["outcomeUnknown"] = .bool(false) + payload["retrySafe"] = .bool(false) + payload["inspectionRequired"] = .bool(true) + default: + break + } + } else { + payload["details"] = .null + } + + return MCPResult.json( + .object(payload), + text: "Error: Failed to submit for review: \(Redactor.redact(error.localizedDescription))", isError: true ) } @@ -854,10 +878,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to cancel review: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to cancel review") } } @@ -921,10 +942,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create phased release: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create phased release") } } @@ -1030,10 +1048,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update phased release: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update phased release") } } @@ -1205,7 +1220,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to release version: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to release version") } } @@ -1386,10 +1401,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to set review details: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to set review details") } } @@ -1653,10 +1665,7 @@ extension AppLifecycleWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update age rating: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update age rating") } } @@ -2079,7 +2088,9 @@ private extension AppLifecycleWorker { payload["success"] = false payload["operationCommitState"] = "committed_unverified" payload["operationCommitted"] = true + payload["outcomeUnknown"] = false payload["retrySafe"] = false + payload["inspectionRequired"] = true payload["error"] = Redactor.redact(reason) payload["inspection"] = inspection return MCPResult.jsonObject( @@ -2192,6 +2203,8 @@ private extension AppLifecycleWorker { return .committedUnverified case .deleteOutcomeUnknown: return .commitUnknown + case .mutationOutcomeUnknown, .mutationCommittedUnverified: + return .rejected case .network: return .rejected case .api(_, _), .apiResponse(_, _): @@ -2231,6 +2244,24 @@ private extension AppLifecycleWorker { return .string(string) } + func validateNullableDateTime(_ value: NullableAttributeValue?, name: String) throws { + guard let value, case .string(let string) = value else { + return + } + guard isISO8601DateTime(string) else { + throw AppLifecycleArgumentError("\(name) must be null or a valid ISO 8601 date-time string") + } + } + + func isISO8601DateTime(_ value: String) -> Bool { + let formatter = ISO8601DateFormatter() + if formatter.date(from: value) != nil { + return true + } + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) != nil + } + func nullableBool(_ name: String, from arguments: [String: Value]) throws -> NullableAttributeValue? { guard let value = arguments[name] else { return nil diff --git a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+ToolDefinitions.swift index 0aa3620..a91e66f 100644 --- a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+ToolDefinitions.swift @@ -25,22 +25,23 @@ extension AppLifecycleWorker { "description": .string("Version number (e.g., 1.2.0)") ]), "release_type": .object([ - "type": .string("string"), + "type": .array([.string("string"), .string("null")]), "description": .string("Release type after approval"), - "enum": .array([.string("MANUAL"), .string("AFTER_APPROVAL"), .string("SCHEDULED")]) + "enum": .array([.string("MANUAL"), .string("AFTER_APPROVAL"), .string("SCHEDULED"), .null]) ]), "earliest_release_date": .object([ - "type": .string("string"), + "type": .array([.string("string"), .string("null")]), + "format": .string("date-time"), "description": .string("Earliest release date for SCHEDULED release (ISO 8601)") ]), "copyright": .object([ - "type": .string("string"), + "type": .array([.string("string"), .string("null")]), "description": .string("Copyright text") ]), "review_type": .object([ - "type": .string("string"), + "type": .array([.string("string"), .string("null")]), "description": .string("Review flow type"), - "enum": .array([.string("APP_STORE"), .string("NOTARIZATION")]) + "enum": .array([.string("APP_STORE"), .string("NOTARIZATION"), .null]) ]), "uses_idfa": .object([ "type": .array([.string("boolean"), .string("null")]), @@ -251,6 +252,7 @@ extension AppLifecycleWorker { ]), "earliest_release_date": .object([ "type": .array([.string("string"), .string("null")]), + "format": .string("date-time"), "description": .string("Earliest release date for SCHEDULED release (ISO 8601)") ]), "copyright": .object([ diff --git a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift index 62ef6e3..c415251 100644 --- a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift @@ -120,6 +120,47 @@ extension AppsWorker { return versions } + private func fetchAllMetadataLocalizations( + versionId: String, + locale: String? + ) async throws -> [ASCAppStoreVersionLocalization] { + let path = "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))/appStoreVersionLocalizations" + var parameters = [ + "fields[appStoreVersionLocalizations]": "description,locale,keywords,marketingUrl,promotionalText,supportUrl,whatsNew,appStoreVersion", + "limit": "200" + ] + if let locale { + parameters["filter[locale]"] = locale + } + + let scope = PaginationScope.strict(path: path, query: parameters) + var response: ASCAppStoreVersionLocalizationsResponse? = try await httpClient.get( + path, + parameters: parameters, + as: ASCAppStoreVersionLocalizationsResponse.self + ) + var localizations: [ASCAppStoreVersionLocalization] = [] + var seenNextURLs: Set = [] + + while let page = response { + localizations.append(contentsOf: page.data) + if let next = page.links?.next { + guard seenNextURLs.insert(next).inserted else { + throw ASCError.parsing("App metadata localization pagination returned a repeated next URL") + } + response = try await httpClient.getPage( + next, + scope: scope, + as: ASCAppStoreVersionLocalizationsResponse.self + ) + } else { + response = nil + } + } + + return localizations + } + private func nullableMetadataString(_ name: String, from arguments: [String: Value]) throws -> NullableAttributeValue? { guard let value = arguments[name] else { return nil @@ -195,6 +236,40 @@ extension AppsWorker { return strings.joined(separator: ",") } + private func appDetailsIncludeValue(_ value: Value?) throws -> String? { + guard let value else { return nil } + let allowedValues: Set = [ + "appEncryptionDeclarations", "appStoreIcon", "ciProduct", "betaGroups", "appStoreVersions", + "preReleaseVersions", "betaAppLocalizations", "builds", "betaLicenseAgreement", + "betaAppReviewDetail", "appInfos", "appClips", "endUserLicenseAgreement", "inAppPurchases", + "subscriptionGroups", "gameCenterEnabledVersions", "appCustomProductPages", "inAppPurchasesV2", + "promotedPurchases", "appEvents", "reviewSubmissions", "subscriptionGracePeriod", + "gameCenterDetail", "appStoreVersionExperimentsV2", "androidToIosAppMappingDetails" + ] + let values: [String] + if let string = value.stringValue { + values = string.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + } else if let array = value.arrayValue { + let strings = array.compactMap(\.stringValue) + guard strings.count == array.count else { + throw AppsMetadataArgumentError("include must contain only strings") + } + values = strings + } else { + throw AppsMetadataArgumentError("include must be a string or array of strings") + } + guard !values.isEmpty, values.allSatisfy({ !$0.isEmpty }) else { + throw AppsMetadataArgumentError("include must contain at least one non-empty value") + } + guard Set(values).count == values.count else { + throw AppsMetadataArgumentError("include must not contain duplicate values") + } + if let invalid = values.first(where: { !allowedValues.contains($0) }) { + throw AppsMetadataArgumentError("Unsupported include value '\(invalid)'") + } + return values.joined(separator: ",") + } + private func addStringListQueryArguments( _ mappings: [(toolField: String, appleName: String)], from arguments: [String: Value], @@ -230,15 +305,56 @@ extension AppsWorker { } } + private func validatedLimit( + _ value: Value?, + field: String = "limit", + maximum: Int = 200, + defaultValue: Int + ) throws -> Int { + guard let value else { return defaultValue } + guard let limit = value.intValue, (1...maximum).contains(limit) else { + throw AppsMetadataArgumentError("'\(field)' must be an integer between 1 and \(maximum)") + } + return limit + } + + private func validateAcceptedAppsMutationResource( + type: String, + id: String, + expectedType: String, + expectedID: String? = nil, + method: String, + statusCode: Int, + context: String + ) throws { + do { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: type, + id: id, + expectedType: expectedType, + expectedID: expectedID, + context: context + ) + } catch { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + throw ASCError.mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } + } + /// Lists all apps from App Store Connect with optional filtering /// - Returns: JSON array of apps with their IDs, names, bundle IDs, and metadata /// - Throws: CallTool.Result with error if API call fails public func listApps(_ params: CallTool.Parameters) async throws -> CallTool.Result { let arguments = params.arguments ?? [:] - let effectiveLimit = min(max(arguments["limit"]?.intValue ?? 25, 1), 200) do { + let effectiveLimit = try validatedLimit(arguments["limit"], defaultValue: 25) let response: ASCAppsResponse // Check for pagination next_url @@ -286,9 +402,10 @@ extension AppsWorker { "primaryLocale": app.locale, "type": app.type, "attributes": [ - "availableInNewTerritories": (app.attributes?.availableInNewTerritories).jsonSafe, + "accessibilityUrl": (app.attributes?.accessibilityUrl).jsonSafe, "contentRightsDeclaration": (app.attributes?.contentRightsDeclaration).jsonSafe, - "isOrEverWasMadeForKids": (app.attributes?.isOrEverWasMadeForKids).jsonSafe + "isOrEverWasMadeForKids": (app.attributes?.isOrEverWasMadeForKids).jsonSafe, + "streamlinedPurchasingEnabled": (app.attributes?.streamlinedPurchasingEnabled).jsonSafe ] ] as [String: Any] } @@ -297,7 +414,7 @@ extension AppsWorker { "success": true, "apps": apps, "count": response.data.count, - "totalCount": response.totalCount, + "totalCount": response.totalCount.jsonSafe, "hasNextPage": response.hasNextPage, "links": [ "self": response.links.`self`, @@ -337,9 +454,7 @@ extension AppsWorker { // Parameters for including additional information var queryParams: [String: String] = [:] - if let arguments = params.arguments, - let includeValue = arguments["include"], - let include = includeValue.stringValue { + if let include = try appDetailsIncludeValue(arguments["include"]) { queryParams["include"] = include } @@ -360,9 +475,10 @@ extension AppsWorker { "sku": app.appSKU, "primaryLocale": app.locale, "attributes": [ - "availableInNewTerritories": (app.attributes?.availableInNewTerritories).jsonSafe, + "accessibilityUrl": (app.attributes?.accessibilityUrl).jsonSafe, "contentRightsDeclaration": (app.attributes?.contentRightsDeclaration).jsonSafe, "isOrEverWasMadeForKids": (app.attributes?.isOrEverWasMadeForKids).jsonSafe, + "streamlinedPurchasingEnabled": (app.attributes?.streamlinedPurchasingEnabled).jsonSafe, "subscriptionStatusUrl": (app.attributes?.subscriptionStatusUrl).jsonSafe, "subscriptionStatusUrlVersion": (app.attributes?.subscriptionStatusUrlVersion).jsonSafe, "subscriptionStatusUrlForSandbox": (app.attributes?.subscriptionStatusUrlForSandbox).jsonSafe, @@ -541,6 +657,10 @@ extension AppsWorker { let platformFilter = arguments["platform"]?.stringValue let includeMedia = arguments["include_media"]?.boolValue ?? false + guard !includeMedia || locale != nil else { + return MCPResult.error("'include_media' requires an explicit 'locale'") + } + do { // Step 1: Resolve version let resolvedVersion: (id: String, versionString: String, appVersionState: String?, appStoreState: String?, platform: String?) @@ -626,31 +746,33 @@ extension AppsWorker { ) } - // Step 2: Fetch localizations - var localizationParams: [String: String] = [ - "fields[appStoreVersionLocalizations]": "description,locale,keywords,marketingUrl,promotionalText,supportUrl,whatsNew,appStoreVersion", - "limit": "200" - ] - if let locale = locale { - localizationParams["filter[locale]"] = locale - } - - let localizationsResponse: ASCAppStoreVersionLocalizationsResponse = try await httpClient.get( - "/v1/appStoreVersions/\(try ASCPathSegment.encode(resolvedVersion.id))/appStoreVersionLocalizations", - parameters: localizationParams, - as: ASCAppStoreVersionLocalizationsResponse.self + let localizations = try await fetchAllMetadataLocalizations( + versionId: resolvedVersion.id, + locale: locale ) // Check if locale filter returned empty results - if let locale = locale, localizationsResponse.data.isEmpty { + if let locale = locale, localizations.isEmpty { return MCPResult.error( "Localization '\(locale)' not found for version \(resolvedVersion.versionString)" ) } + if let locale, localizations.count != 1 { + return MCPResult.error( + "Apple returned \(localizations.count) localizations for exact locale '\(locale)'" + ) + } - guard localizationsResponse.data.allSatisfy({ localizationBelongsToVersion($0, versionId: resolvedVersion.id) }) else { + guard localizations.allSatisfy({ + $0.type == "appStoreVersionLocalizations" && + localizationBelongsToVersion($0, versionId: resolvedVersion.id) + }) else { return MCPResult.error("Apple returned a localization outside version '\(resolvedVersion.id)' context") } + if let locale, + localizations.contains(where: { $0.attributes?.locale != locale }) { + return MCPResult.error("Apple returned a localization that does not match locale '\(locale)'") + } let versionInfo: [String: Any] = [ "id": resolvedVersion.id, @@ -680,7 +802,7 @@ extension AppsWorker { if locale != nil { // Single locale mode - let loc = localizationsResponse.data[0] + let loc = localizations[0] result["localization"] = formatLocalization(loc) // Step 3: Fetch media if requested @@ -691,7 +813,7 @@ extension AppsWorker { } } else { // All locales mode - let formatted = localizationsResponse.data + let formatted = localizations .sorted { $0.locale < $1.locale } .map { formatLocalization($0) } result["totalLocalizations"] = formatted.count @@ -877,6 +999,15 @@ extension AppsWorker { guard let localization = localizationsResponse.data.first else { return MCPResult.error("Localization '\(locale)' not found for version \(version.version)") } + guard localizationsResponse.data.count == 1 else { + return MCPResult.error("Apple returned \(localizationsResponse.data.count) localizations for exact locale '\(locale)'") + } + guard localization.type == "appStoreVersionLocalizations" else { + return MCPResult.error("Apple returned unexpected resource type '\(localization.type)' for localization '\(locale)'") + } + guard localization.attributes?.locale == locale else { + return MCPResult.error("Apple returned localization '\(localization.id)' for a different locale") + } guard localizationBelongsToVersion(localization, versionId: versionId) else { return MCPResult.error("Localization '\(localization.id)' does not belong to version '\(versionId)'") } @@ -909,11 +1040,20 @@ extension AppsWorker { attributes: attributes ) - let _: ASCAppStoreVersionLocalizationUpdateResponse = try await httpClient.patch( + let response: ASCAppStoreVersionLocalizationUpdateResponse = try await httpClient.patch( "/v1/appStoreVersionLocalizations/\(try ASCPathSegment.encode(localization.id))", body: updateRequest, as: ASCAppStoreVersionLocalizationUpdateResponse.self ) + try validateAcceptedAppsMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appStoreVersionLocalizations", + expectedID: localization.id, + method: "PATCH", + statusCode: 200, + context: "Apple metadata update response" + ) // 5. Format result var result = "**Metadata updated successfully**\n\n" @@ -953,7 +1093,7 @@ extension AppsWorker { ) } catch { - return MCPResult.error("Failed to update metadata: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to update metadata") } } @@ -995,6 +1135,14 @@ extension AppsWorker { body: request, as: ASCAppStoreVersionLocalizationResponse.self ) + try validateAcceptedAppsMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appStoreVersionLocalizations", + method: "POST", + statusCode: 201, + context: "Apple localization create response" + ) let loc = response.data var locData: [String: Any] = [ @@ -1016,10 +1164,7 @@ extension AppsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create localization") } } @@ -1116,7 +1261,7 @@ extension AppsWorker { do { let localizationFields = "locale,description,whatsNew,keywords,promotionalText,supportUrl,marketingUrl,appStoreVersion" - let effectiveLimit = min(max(arguments["limit"]?.intValue ?? 200, 1), 200) + let effectiveLimit = try validatedLimit(arguments["limit"], defaultValue: 200) var localizationParameters = [ "fields[appStoreVersionLocalizations]": localizationFields, "limit": String(effectiveLimit) @@ -1191,11 +1336,21 @@ extension AppsWorker { // Sort by locale for convenience localizations.sort { ($0["locale"] as? String ?? "") < ($1["locale"] as? String ?? "") } + let totalLocalizations: Any + if let total = localizationsResponse.meta?.paging?.total { + totalLocalizations = total + } else if localizationsResponse.links?.next == nil { + totalLocalizations = localizations.count + } else { + totalLocalizations = NSNull() + } + var result: [String: Any] = [ "success": true, "appId": appId, "versionId": versionId, - "totalLocalizations": localizations.count, + "count": localizations.count, + "totalLocalizations": totalLocalizations, "localizations": localizations ] diff --git a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift index c419134..1baed2d 100644 --- a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift @@ -101,10 +101,7 @@ extension AppsWorker { "type": .string("string"), "description": .string("App Store Connect app ID") ]), - "include": .object([ - "type": .string("string"), - "description": .string("Additional related data to include") - ]) + "include": appDetailsIncludeProperty() ]), "required": .array([.string("app_id")]) ]) @@ -184,9 +181,9 @@ extension AppsWorker { Get app metadata (description, whatsNew, keywords, etc.) for a version and localization. Behavior: - - Without locale: returns ALL locales in one request + - Without locale: follows every Apple page and returns ALL locales - Without version_id: auto-selects version by appVersionState, then platform (priority: PREPARE_FOR_SUBMISSION > REJECTED > METADATA_REJECTED > READY_FOR_DISTRIBUTION; legacy READY_FOR_SALE remains supported) - - include_media: false by default, media loaded only on request + - include_media: false by default; requires locale because media belongs to one localization """, inputSchema: .object([ "type": .string("object"), @@ -214,7 +211,7 @@ extension AppsWorker { ]), "include_media": .object([ "type": .string("boolean"), - "description": .string("Include screenshots and videos in response (default: false)") + "description": .string("Include screenshots and videos for the explicit locale (default: false; requires locale)") ]) ]), "required": .array([.string("app_id")]) @@ -376,4 +373,36 @@ extension AppsWorker { } return .object(schema) } + + private func appDetailsIncludeProperty() -> Value { + let allowedValues = [ + "appEncryptionDeclarations", "appStoreIcon", "ciProduct", "betaGroups", "appStoreVersions", + "preReleaseVersions", "betaAppLocalizations", "builds", "betaLicenseAgreement", + "betaAppReviewDetail", "appInfos", "appClips", "endUserLicenseAgreement", "inAppPurchases", + "subscriptionGroups", "gameCenterEnabledVersions", "appCustomProductPages", "inAppPurchasesV2", + "promotedPurchases", "appEvents", "reviewSubmissions", "subscriptionGracePeriod", + "gameCenterDetail", "appStoreVersionExperimentsV2", "androidToIosAppMappingDetails" + ] + let tokenPattern = allowedValues + .map(NSRegularExpression.escapedPattern(for:)) + .joined(separator: "|") + return .object([ + "description": .string("Related resources to include. Accepts an array or a comma-separated compatibility form."), + "oneOf": .array([ + .object([ + "type": .string("string"), + "pattern": .string("^(?:\(tokenPattern))(?:,(?:\(tokenPattern)))*$") + ]), + .object([ + "type": .string("array"), + "items": .object([ + "type": .string("string"), + "enum": .array(allowedValues.map(Value.string)) + ]), + "minItems": .int(1), + "uniqueItems": .bool(true) + ]) + ]) + ]) + } } diff --git a/Sources/asc-mcp/Workers/BetaAppWorker/BetaAppWorker+Handlers.swift b/Sources/asc-mcp/Workers/BetaAppWorker/BetaAppWorker+Handlers.swift index 876e55a..31af376 100644 --- a/Sources/asc-mcp/Workers/BetaAppWorker/BetaAppWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BetaAppWorker/BetaAppWorker+Handlers.swift @@ -136,10 +136,7 @@ extension BetaAppWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create beta app localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create beta app localization") } } @@ -241,10 +238,7 @@ extension BetaAppWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update beta app localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update beta app localization") } } @@ -325,12 +319,12 @@ extension BetaAppWorker { } catch let error as ASCError { let ambiguousOutcome: Bool switch error { - case .deleteCommittedUnverified: + case .deleteCommittedUnverified, .mutationCommittedUnverified: return MCPResult.error( error, prefix: "Failed to submit build for beta review" ) - case .deleteOutcomeUnknown: + case .deleteOutcomeUnknown, .mutationOutcomeUnknown: ambiguousOutcome = true case .network: ambiguousOutcome = true @@ -340,18 +334,18 @@ extension BetaAppWorker { ambiguousOutcome = false } if ambiguousOutcome { - return unknownSubmissionCommitFailure(error.localizedDescription, requestedBuildID: buildId) + return unknownSubmissionCommitFailure(error, requestedBuildID: buildId) } return MCPResult.error("Failed to submit build for beta review: \(error.localizedDescription)") } catch { - return unknownSubmissionCommitFailure(error.localizedDescription, requestedBuildID: buildId) + return unknownSubmissionCommitFailure(error, requestedBuildID: buildId) } let response: ASCBetaAppReviewSubmissionResponse do { response = try JSONDecoder().decode(ASCBetaAppReviewSubmissionResponse.self, from: responseData) } catch { - return committedSubmissionDecodeFailure(error.localizedDescription, requestedBuildID: buildId) + return committedSubmissionDecodeFailure(error, requestedBuildID: buildId) } guard response.data.type == "betaAppReviewSubmissions", @@ -677,10 +671,7 @@ extension BetaAppWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update beta app review details: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update beta app review details") } } @@ -846,12 +837,22 @@ extension BetaAppWorker { requestedBuildID: String ) -> CallTool.Result { let safeMessage = Redactor.redact(message) + let committedError = ASCError.mutationCommittedUnverified( + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201, + cause: .parsing(safeMessage) + ) return MCPResult.jsonObject( [ "success": false, "error": safeMessage, + "details": committedError.structuredValue, "operationCommitted": true, + "operationCommitState": "committed_unverified", + "outcomeUnknown": false, "retrySafe": false, + "inspectionRequired": true, "submissionId": submission.id, "submissionIdKnown": true, "requestedBuildId": requestedBuildID, @@ -867,12 +868,22 @@ extension BetaAppWorker { requestedBuildID: String ) -> CallTool.Result { let safeMessage = Redactor.redact(message) + let committedError = ASCError.mutationCommittedUnverified( + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201, + cause: .parsing(safeMessage) + ) return MCPResult.jsonObject( [ "success": false, "error": safeMessage, + "details": committedError.structuredValue, "operationCommitted": true, + "operationCommitState": "committed_unverified", + "outcomeUnknown": false, "retrySafe": false, + "inspectionRequired": true, "submissionIdKnown": false, "requestedBuildId": requestedBuildID, "inspection": submissionInspection(for: requestedBuildID) @@ -883,16 +894,26 @@ extension BetaAppWorker { } private func committedSubmissionDecodeFailure( - _ message: String, + _ error: Error, requestedBuildID: String ) -> CallTool.Result { - let safeMessage = Redactor.redact(message) + let safeMessage = Redactor.redact(error.localizedDescription) + let committedError = ASCError.mutationCommittedUnverified( + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201, + cause: .parsing("Failed to decode beta app review submission response: \(safeMessage)") + ) return MCPResult.jsonObject( [ "success": false, "error": safeMessage, + "details": committedError.structuredValue, "operationCommitted": true, + "operationCommitState": "committed_unverified", + "outcomeUnknown": false, "retrySafe": false, + "inspectionRequired": true, "submissionIdKnown": false, "requestedBuildId": requestedBuildID, "inspection": submissionInspection(for: requestedBuildID) @@ -903,20 +924,24 @@ extension BetaAppWorker { } private func unknownSubmissionCommitFailure( - _ message: String, + _ error: Error, requestedBuildID: String ) -> CallTool.Result { - let safeMessage = Redactor.redact(message) + let safeMessage = Redactor.redact(error.localizedDescription) + var payload: [String: Any] = [ + "success": false, + "error": safeMessage, + "operationCommitState": "unknown", + "retrySafe": false, + "submissionIdKnown": false, + "requestedBuildId": requestedBuildID, + "inspection": submissionInspection(for: requestedBuildID) + ] + if let ascError = error as? ASCError { + payload["details"] = ascError.structuredValue + } return MCPResult.jsonObject( - [ - "success": false, - "error": safeMessage, - "operationCommitState": "unknown", - "retrySafe": false, - "submissionIdKnown": false, - "requestedBuildId": requestedBuildID, - "inspection": submissionInspection(for: requestedBuildID) - ], + payload, text: "Error: The beta review submission request outcome is unknown. Inspect existing submissions before retrying.", isError: true ) @@ -1026,7 +1051,10 @@ extension BetaAppWorker { guard let limit = value.intValue else { throw BetaAppArgumentError("limit must be an integer") } - return min(max(limit, 1), 200) + guard (1...200).contains(limit) else { + throw BetaAppArgumentError("limit must be an integer from 1 through 200") + } + return limit } private static let localizationFields = "feedbackEmail,marketingUrl,privacyPolicyUrl,tvOsPrivacyPolicy,description,locale" diff --git a/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+Handlers.swift b/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+Handlers.swift index f8c6b5c..777fac5 100644 --- a/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+Handlers.swift @@ -16,19 +16,23 @@ extension BetaGroupsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let response: ASCBetaGroupsResponse var queryParams: [String: String] = [ - "filter[app]": appId + "filter[app]": appId, + "limit": String(limit) ] - if let limitValue = arguments["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - if let isInternalValue = arguments["is_internal"], let isInternal = isInternalValue.boolValue { queryParams["filter[isInternalGroup]"] = isInternal ? "true" : "false" @@ -177,10 +181,7 @@ extension BetaGroupsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create beta group: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create beta group") } } @@ -209,19 +210,26 @@ extension BetaGroupsWorker { return MCPResult.error("At least one updatable beta group field is required") } + let updateAttributes: UpdateBetaGroupRequest.UpdateBetaGroupAttributes + do { + updateAttributes = UpdateBetaGroupRequest.UpdateBetaGroupAttributes( + name: try nullableValue(arguments["name"], name: "name") { $0.stringValue }, + publicLinkEnabled: try nullableValue(arguments["public_link_enabled"], name: "public_link_enabled") { $0.boolValue }, + publicLinkLimitEnabled: try nullableValue(arguments["public_link_limit_enabled"], name: "public_link_limit_enabled") { $0.boolValue }, + publicLinkLimit: try nullableValue(arguments["public_link_limit"], name: "public_link_limit") { $0.intValue }, + feedbackEnabled: try nullableValue(arguments["feedback_enabled"], name: "feedback_enabled") { $0.boolValue }, + iosBuildsAvailableForAppleSiliconMac: try nullableValue(arguments["ios_builds_available_for_apple_silicon_mac"], name: "ios_builds_available_for_apple_silicon_mac") { $0.boolValue }, + iosBuildsAvailableForAppleVision: try nullableValue(arguments["ios_builds_available_for_apple_vision"], name: "ios_builds_available_for_apple_vision") { $0.boolValue } + ) + } catch { + return MCPResult.error(error.localizedDescription) + } + do { let request = UpdateBetaGroupRequest( data: UpdateBetaGroupRequest.UpdateBetaGroupData( id: groupId, - attributes: UpdateBetaGroupRequest.UpdateBetaGroupAttributes( - name: arguments["name"]?.stringValue, - publicLinkEnabled: arguments["public_link_enabled"]?.boolValue, - publicLinkLimitEnabled: arguments["public_link_limit_enabled"]?.boolValue, - publicLinkLimit: arguments["public_link_limit"]?.intValue, - feedbackEnabled: arguments["feedback_enabled"]?.boolValue, - iosBuildsAvailableForAppleSiliconMac: arguments["ios_builds_available_for_apple_silicon_mac"]?.boolValue, - iosBuildsAvailableForAppleVision: arguments["ios_builds_available_for_apple_vision"]?.boolValue - ) + attributes: updateAttributes ) ) @@ -241,10 +249,7 @@ extension BetaGroupsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update beta group: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update beta group") } } @@ -290,9 +295,11 @@ extension BetaGroupsWorker { } let testerIds = testerIdsArray.compactMap { $0.stringValue } - guard !testerIds.isEmpty else { + guard !testerIds.isEmpty, + testerIds.count == testerIdsArray.count, + testerIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'tester_ids' must contain at least one tester ID")], + content: [MCPContent.text("Error: 'tester_ids' must contain only non-empty tester ID strings")], isError: true ) } @@ -305,7 +312,8 @@ extension BetaGroupsWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/betaGroups/\(try ASCPathSegment.encode(groupId))/relationships/betaTesters", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result = [ @@ -316,10 +324,7 @@ extension BetaGroupsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to add testers: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add testers") } } @@ -338,9 +343,11 @@ extension BetaGroupsWorker { } let testerIds = testerIdsArray.compactMap { $0.stringValue } - guard !testerIds.isEmpty else { + guard !testerIds.isEmpty, + testerIds.count == testerIdsArray.count, + testerIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'tester_ids' must contain at least one tester ID")], + content: [MCPContent.text("Error: 'tester_ids' must contain only non-empty tester ID strings")], isError: true ) } @@ -380,10 +387,19 @@ extension BetaGroupsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let endpoint = "/v1/betaGroups/\(try ASCPathSegment.encode(groupId))/betaTesters" - let limit = arguments["limit"]?.intValue ?? 25 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCBetaTestersResponse // Check for pagination URL @@ -441,9 +457,11 @@ extension BetaGroupsWorker { } let buildIds = buildIdsArray.compactMap { $0.stringValue } - guard !buildIds.isEmpty else { + guard !buildIds.isEmpty, + buildIds.count == buildIdsArray.count, + buildIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'build_ids' must contain at least one build ID")], + content: [MCPContent.text("Error: 'build_ids' must contain only non-empty build ID strings")], isError: true ) } @@ -456,7 +474,8 @@ extension BetaGroupsWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/betaGroups/\(try ASCPathSegment.encode(groupId))/relationships/builds", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result = [ @@ -467,10 +486,7 @@ extension BetaGroupsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to add builds: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add builds") } } @@ -489,9 +505,11 @@ extension BetaGroupsWorker { } let buildIds = buildIdsArray.compactMap { $0.stringValue } - guard !buildIds.isEmpty else { + guard !buildIds.isEmpty, + buildIds.count == buildIdsArray.count, + buildIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'build_ids' must contain at least one build ID")], + content: [MCPContent.text("Error: 'build_ids' must contain only non-empty build ID strings")], isError: true ) } @@ -525,12 +543,12 @@ extension BetaGroupsWorker { return [ "id": tester.id, "type": tester.type, - "email": tester.attributes.email.jsonSafe, - "firstName": tester.attributes.firstName.jsonSafe, - "lastName": tester.attributes.lastName.jsonSafe, - "inviteType": tester.attributes.inviteType.jsonSafe, - "state": tester.attributes.state.jsonSafe, - "appDevices": tester.attributes.appDevices.map { devices in + "email": (tester.attributes?.email).jsonSafe, + "firstName": (tester.attributes?.firstName).jsonSafe, + "lastName": (tester.attributes?.lastName).jsonSafe, + "inviteType": (tester.attributes?.inviteType).jsonSafe, + "state": (tester.attributes?.state).jsonSafe, + "appDevices": (tester.attributes?.appDevices).map { devices in devices.map { device in [ "model": device.model.jsonSafe, @@ -547,18 +565,18 @@ extension BetaGroupsWorker { return [ "id": group.id, "type": group.type, - "name": group.attributes.name.jsonSafe, - "createdDate": group.attributes.createdDate.jsonSafe, - "isInternalGroup": group.attributes.isInternalGroup.jsonSafe, - "hasAccessToAllBuilds": group.attributes.hasAccessToAllBuilds.jsonSafe, - "publicLinkEnabled": group.attributes.publicLinkEnabled.jsonSafe, - "publicLinkLimit": group.attributes.publicLinkLimit.jsonSafe, - "publicLinkLimitEnabled": group.attributes.publicLinkLimitEnabled.jsonSafe, - "publicLink": group.attributes.publicLink.jsonSafe, - "publicLinkId": group.attributes.publicLinkId.jsonSafe, - "feedbackEnabled": group.attributes.feedbackEnabled.jsonSafe, - "iosBuildsAvailableForAppleSiliconMac": group.attributes.iosBuildsAvailableForAppleSiliconMac.jsonSafe, - "iosBuildsAvailableForAppleVision": group.attributes.iosBuildsAvailableForAppleVision.jsonSafe, + "name": (group.attributes?.name).jsonSafe, + "createdDate": (group.attributes?.createdDate).jsonSafe, + "isInternalGroup": (group.attributes?.isInternalGroup).jsonSafe, + "hasAccessToAllBuilds": (group.attributes?.hasAccessToAllBuilds).jsonSafe, + "publicLinkEnabled": (group.attributes?.publicLinkEnabled).jsonSafe, + "publicLinkLimit": (group.attributes?.publicLinkLimit).jsonSafe, + "publicLinkLimitEnabled": (group.attributes?.publicLinkLimitEnabled).jsonSafe, + "publicLink": (group.attributes?.publicLink).jsonSafe, + "publicLinkId": (group.attributes?.publicLinkId).jsonSafe, + "feedbackEnabled": (group.attributes?.feedbackEnabled).jsonSafe, + "iosBuildsAvailableForAppleSiliconMac": (group.attributes?.iosBuildsAvailableForAppleSiliconMac).jsonSafe, + "iosBuildsAvailableForAppleVision": (group.attributes?.iosBuildsAvailableForAppleVision).jsonSafe, "relationships": formatBetaGroupRelationships(group.relationships) ] } @@ -603,4 +621,31 @@ extension BetaGroupsWorker { } return strings.joined(separator: ",") } + + private func nullableValue( + _ value: Value?, + name: String, + extract: (Value) -> T? + ) throws -> ASCNullable? { + guard let value else { + return nil + } + if value.isNull { + return .null + } + guard let extracted = extract(value) else { + throw BetaGroupsArgumentError("'\(name)' has an invalid value type") + } + return .value(extracted) + } +} + +private struct BetaGroupsArgumentError: LocalizedError { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { message } } diff --git a/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+ToolDefinitions.swift index 14ec829..835fa08 100644 --- a/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/BetaGroupsWorker/BetaGroupsWorker+ToolDefinitions.swift @@ -122,32 +122,32 @@ extension BetaGroupsWorker { "description": .string("Beta group ID") ]), "name": .object([ - "type": .string("string"), - "description": .string("New group name") + "type": .array([.string("string"), .string("null")]), + "description": .string("New group name, or null to clear it") ]), "public_link_enabled": .object([ - "type": .string("boolean"), - "description": .string("Enable public invite link") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable public invite link, or null to clear the setting") ]), "public_link_limit": .object([ - "type": .string("integer"), - "description": .string("Max testers via public link") + "type": .array([.string("integer"), .string("null")]), + "description": .string("Max testers via public link, or null to clear the limit") ]), "public_link_limit_enabled": .object([ - "type": .string("boolean"), - "description": .string("Enable or disable the public-link tester limit") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable or disable the public-link tester limit, or null to clear the setting") ]), "feedback_enabled": .object([ - "type": .string("boolean"), - "description": .string("Enable feedback") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable feedback, or null to clear the setting") ]), "ios_builds_available_for_apple_silicon_mac": .object([ - "type": .string("boolean"), - "description": .string("Make eligible iOS builds available on Apple silicon Macs") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Make eligible iOS builds available on Apple silicon Macs, or null to clear the setting") ]), "ios_builds_available_for_apple_vision": .object([ - "type": .string("boolean"), - "description": .string("Make eligible iOS builds available on Apple Vision") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Make eligible iOS builds available on Apple Vision, or null to clear the setting") ]) ]), "required": .array([.string("group_id")]) @@ -187,8 +187,10 @@ extension BetaGroupsWorker { "type": .string("array"), "description": .string("Array of beta tester IDs"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("group_id"), .string("tester_ids")]) @@ -209,7 +211,10 @@ extension BetaGroupsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(25) ]), "next_url": .object([ "type": .string("string"), @@ -236,8 +241,10 @@ extension BetaGroupsWorker { "type": .string("array"), "description": .string("Array of build IDs to add"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("group_id"), .string("build_ids")]) @@ -260,8 +267,10 @@ extension BetaGroupsWorker { "type": .string("array"), "description": .string("Array of build IDs to remove"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("group_id"), .string("build_ids")]) @@ -284,8 +293,10 @@ extension BetaGroupsWorker { "type": .string("array"), "description": .string("Array of beta tester IDs"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("group_id"), .string("tester_ids")]) diff --git a/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+Handlers.swift b/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+Handlers.swift index 56fa532..66e543e 100644 --- a/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+Handlers.swift @@ -9,6 +9,16 @@ extension BetaLicenseAgreementsWorker { func listBetaLicenseAgreements(_ params: CallTool.Parameters) async throws -> CallTool.Result { let arguments = params.arguments + let limit: Int + if let value = arguments?["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let response: ASCBetaLicenseAgreementsResponse var queryParams: [String: String] = [:] @@ -16,11 +26,7 @@ extension BetaLicenseAgreementsWorker { if let appIDs = try commaSeparatedAppIDs(arguments?["app_id"]) { queryParams["filter[app]"] = appIDs } - if let limit = arguments?["limit"]?.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } + queryParams["limit"] = String(limit) if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( @@ -147,10 +153,7 @@ extension BetaLicenseAgreementsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update beta license agreement: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update beta license agreement") } } diff --git a/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+ToolDefinitions.swift index 8eab44c..7c1717d 100644 --- a/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/BetaLicenseAgreementsWorker/BetaLicenseAgreementsWorker+ToolDefinitions.swift @@ -27,6 +27,7 @@ extension BetaLicenseAgreementsWorker { "type": .string("integer"), "minimum": .int(1), "maximum": .int(200), + "default": .int(25), "description": .string("Max results (default: 25, max: 200)") ]), "next_url": .object([ diff --git a/Sources/asc-mcp/Workers/BetaTestersWorker/BetaTestersWorker+Handlers.swift b/Sources/asc-mcp/Workers/BetaTestersWorker/BetaTestersWorker+Handlers.swift index 46b65ae..bd81087 100644 --- a/Sources/asc-mcp/Workers/BetaTestersWorker/BetaTestersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BetaTestersWorker/BetaTestersWorker+Handlers.swift @@ -9,9 +9,19 @@ extension BetaTestersWorker { func listBetaTesters(_ params: CallTool.Parameters) async throws -> CallTool.Result { let arguments = params.arguments ?? [:] + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let response: ASCBetaTestersResponse - var queryParams: [String: String] = [:] + var queryParams: [String: String] = ["limit": String(limit)] if let appIdValue = arguments["app_id"], let appId = appIdValue.stringValue { @@ -28,13 +38,6 @@ extension BetaTestersWorker { queryParams["sort"] = sort } - if let limitValue = arguments["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - // Check for pagination URL if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -90,17 +93,26 @@ extension BetaTestersWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { var queryParams: [String: String] = [ - "filter[email]": email + "filter[email]": email, + "limit": String(limit) ] if let appIdValue = arguments["app_id"], let appId = appIdValue.stringValue { queryParams["filter[apps]"] = appId } - queryParams["limit"] = String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) - let response: ASCBetaTestersResponse if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -188,7 +200,7 @@ extension BetaTestersWorker { case .betaGroup(let group): includedGroups.append([ "id": group.id, - "name": group.attributes.name.jsonSafe + "name": (group.attributes?.name).jsonSafe ]) case .build(let build): includedBuilds.append([ @@ -239,6 +251,10 @@ extension BetaTestersWorker { ) } + guard isValidEmail(email) else { + return MCPResult.error("'email' must be a valid email address") + } + let groupIds: [String]? if let values = arguments["group_ids"]?.arrayValue { let ids = values.compactMap(\.stringValue).filter { !$0.isEmpty } @@ -303,10 +319,7 @@ extension BetaTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create beta tester: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create beta tester") } } @@ -356,10 +369,19 @@ extension BetaTestersWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let endpoint = "/v1/betaTesters/\(try ASCPathSegment.encode(testerId))/apps" - let limit = arguments["limit"]?.intValue ?? 25 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCAppsResponse // Check for pagination URL @@ -448,10 +470,7 @@ extension BetaTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to send invitation: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to send invitation") } } @@ -470,9 +489,11 @@ extension BetaTestersWorker { } let groupIds = groupIdsArray.compactMap { $0.stringValue } - guard !groupIds.isEmpty else { + guard !groupIds.isEmpty, + groupIds.count == groupIdsArray.count, + groupIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("'group_ids' must contain at least one group ID")], + content: [MCPContent.text("'group_ids' must contain only non-empty group ID strings")], isError: true ) } @@ -485,7 +506,8 @@ extension BetaTestersWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/betaTesters/\(try ASCPathSegment.encode(betaTesterId))/relationships/betaGroups", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result: [String: Any] = [ @@ -496,10 +518,7 @@ extension BetaTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to add tester to groups: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add tester to groups") } } @@ -518,9 +537,11 @@ extension BetaTestersWorker { } let groupIds = groupIdsArray.compactMap { $0.stringValue } - guard !groupIds.isEmpty else { + guard !groupIds.isEmpty, + groupIds.count == groupIdsArray.count, + groupIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("'group_ids' must contain at least one group ID")], + content: [MCPContent.text("'group_ids' must contain only non-empty group ID strings")], isError: true ) } @@ -563,9 +584,11 @@ extension BetaTestersWorker { } let buildIds = buildIdsArray.compactMap { $0.stringValue } - guard !buildIds.isEmpty else { + guard !buildIds.isEmpty, + buildIds.count == buildIdsArray.count, + buildIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("'build_ids' must contain at least one build ID")], + content: [MCPContent.text("'build_ids' must contain only non-empty build ID strings")], isError: true ) } @@ -578,7 +601,8 @@ extension BetaTestersWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/betaTesters/\(try ASCPathSegment.encode(betaTesterId))/relationships/builds", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result: [String: Any] = [ @@ -589,10 +613,7 @@ extension BetaTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to add builds to tester: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add builds to tester") } } @@ -611,9 +632,11 @@ extension BetaTestersWorker { } let buildIds = buildIdsArray.compactMap { $0.stringValue } - guard !buildIds.isEmpty else { + guard !buildIds.isEmpty, + buildIds.count == buildIdsArray.count, + buildIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("'build_ids' must contain at least one build ID")], + content: [MCPContent.text("'build_ids' must contain only non-empty build ID strings")], isError: true ) } @@ -742,12 +765,12 @@ extension BetaTestersWorker { var result: [String: Any] = [ "id": tester.id, "type": tester.type, - "email": tester.attributes.email.jsonSafe, - "firstName": tester.attributes.firstName.jsonSafe, - "lastName": tester.attributes.lastName.jsonSafe, - "inviteType": tester.attributes.inviteType.jsonSafe, - "state": tester.attributes.state.jsonSafe, - "appDevices": tester.attributes.appDevices.map { devices in + "email": (tester.attributes?.email).jsonSafe, + "firstName": (tester.attributes?.firstName).jsonSafe, + "lastName": (tester.attributes?.lastName).jsonSafe, + "inviteType": (tester.attributes?.inviteType).jsonSafe, + "state": (tester.attributes?.state).jsonSafe, + "appDevices": (tester.attributes?.appDevices).map { devices in devices.map { device in [ "model": device.model.jsonSafe, @@ -799,4 +822,13 @@ extension BetaTestersWorker { let values = rawValues.compactMap(\.stringValue).filter { !$0.isEmpty } return values.count == rawValues.count ? values : nil } + + private func isValidEmail(_ value: String) -> Bool { + let range = NSRange(value.startIndex.. [ASCMetadataValidator.FieldError] { var errors = ASCMetadataValidator.validateLocale(locale) - if let whatsNew = arguments["whats_new"]?.stringValue { - errors += ASCMetadataValidator.validateTextFields( - ["whats_new": whatsNew], - limits: ["whats_new": 4_000] - ) + if let value = arguments["whats_new"], !value.isNull { + if let whatsNew = value.stringValue { + errors += ASCMetadataValidator.validateTextFields( + ["whats_new": whatsNew], + limits: ["whats_new": 4_000] + ) + } else { + errors.append(.init(field: "whats_new", message: "Value must be a string or null")) + } } return errors @@ -80,11 +84,20 @@ extension BuildBetaDetailsWorker { ) } + guard let autoNotifyValue = arguments["auto_notify"] else { + return MCPResult.error("At least one updatable field is required: auto_notify") + } + + let autoNotify: ASCNullable + if autoNotifyValue.isNull { + autoNotify = .null + } else if let bool = autoNotifyValue.boolValue { + autoNotify = .value(bool) + } else { + return MCPResult.error("'auto_notify' must be a boolean or null") + } + do { - guard let autoNotifyValue = arguments["auto_notify"], - let autoNotify = autoNotifyValue.boolValue else { - return MCPResult.error("At least one updatable field is required: auto_notify") - } let updateRequest = UpdateBuildBetaDetailRequest( data: UpdateBuildBetaDetailRequest.UpdateBuildBetaDetailData( @@ -111,10 +124,7 @@ extension BuildBetaDetailsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update beta detail: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update beta detail") } } @@ -146,6 +156,19 @@ extension BuildBetaDetailsWorker { if !validationErrors.isEmpty { return ASCMetadataValidator.errorResult(validationErrors) } + + let whatsNew: ASCNullable? + if let value = arguments["whats_new"] { + if value.isNull { + whatsNew = .null + } else if let string = value.stringValue { + whatsNew = .value(string) + } else { + return MCPResult.error("'whats_new' must be a string or null") + } + } else { + whatsNew = nil + } do { let existingResponse: ASCBetaBuildLocalizationsResponse = try await httpClient.get( @@ -165,7 +188,7 @@ extension BuildBetaDetailsWorker { data: CreateBetaBuildLocalizationRequest.CreateBetaBuildLocalizationData( attributes: CreateBetaBuildLocalizationRequest.CreateBetaBuildLocalizationAttributes( locale: locale, - whatsNew: arguments["whats_new"]?.stringValue + whatsNew: whatsNew ), relationships: CreateBetaBuildLocalizationRequest.CreateBetaBuildLocalizationRelationships( build: CreateBetaBuildLocalizationRequest.BuildRelationship( @@ -200,7 +223,7 @@ extension BuildBetaDetailsWorker { let localizationId = localizationData.id let updateAttributes = UpdateBetaBuildLocalizationRequest.BetaBuildLocalizationUpdateAttributes( - whatsNew: arguments["whats_new"]?.stringValue + whatsNew: whatsNew ) if updateAttributes.whatsNew == nil { @@ -232,7 +255,7 @@ extension BuildBetaDetailsWorker { } } catch { - return MCPResult.error("Failed to set beta localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to set beta localization") } } @@ -249,10 +272,19 @@ extension BuildBetaDetailsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 50 + } + do { let endpoint = "/v1/builds/\(try ASCPathSegment.encode(buildId))/betaBuildLocalizations" - let limit = arguments["limit"]?.intValue ?? 50 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCBetaBuildLocalizationsResponse // Check for pagination URL @@ -309,11 +341,21 @@ extension BuildBetaDetailsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 50 + } + do { let response: ASCBetaGroupsResponse var queryParams: [String: String] = [ "filter[builds]": buildId, - "limit": String(min(max(arguments["limit"]?.intValue ?? 50, 1), 200)) + "limit": String(limit) ] applyBetaGroupStringList(arguments["group_ids"], as: "filter[id]", to: &queryParams) applyBetaGroupStringList(arguments["name"], as: "filter[name]", to: &queryParams) @@ -387,10 +429,19 @@ extension BuildBetaDetailsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 50 + } + do { let endpoint = "/v1/builds/\(try ASCPathSegment.encode(buildId))/individualTesters" - let limit = arguments["limit"]?.intValue ?? 50 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCBetaTestersResponse // Check for pagination URL @@ -449,9 +500,11 @@ extension BuildBetaDetailsWorker { } let groupIds = groupIdsArray.compactMap { $0.stringValue } - guard !groupIds.isEmpty else { + guard !groupIds.isEmpty, + groupIds.count == groupIdsArray.count, + groupIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'group_ids' must contain at least one group ID")], + content: [MCPContent.text("Error: 'group_ids' must contain only non-empty group ID strings")], isError: true ) } @@ -464,7 +517,8 @@ extension BuildBetaDetailsWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/builds/\(try ASCPathSegment.encode(buildId))/relationships/betaGroups", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result = [ @@ -475,10 +529,7 @@ extension BuildBetaDetailsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to add build to beta groups: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add build to beta groups") } } @@ -498,9 +549,11 @@ extension BuildBetaDetailsWorker { } let testerIds = testerIdsArray.compactMap { $0.stringValue } - guard !testerIds.isEmpty else { + guard !testerIds.isEmpty, + testerIds.count == testerIdsArray.count, + testerIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'beta_tester_ids' must contain at least one tester ID")], + content: [MCPContent.text("Error: 'beta_tester_ids' must contain only non-empty tester ID strings")], isError: true ) } @@ -513,7 +566,8 @@ extension BuildBetaDetailsWorker { let bodyData = try JSONEncoder().encode(request) _ = try await httpClient.post( "/v1/builds/\(try ASCPathSegment.encode(buildId))/relationships/individualTesters", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result = [ @@ -524,10 +578,7 @@ extension BuildBetaDetailsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to add individual testers: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add individual testers") } } @@ -547,9 +598,11 @@ extension BuildBetaDetailsWorker { } let testerIds = testerIdsArray.compactMap { $0.stringValue } - guard !testerIds.isEmpty else { + guard !testerIds.isEmpty, + testerIds.count == testerIdsArray.count, + testerIds.allSatisfy({ !$0.isEmpty }) else { return CallTool.Result( - content: [MCPContent.text("Error: 'beta_tester_ids' must contain at least one tester ID")], + content: [MCPContent.text("Error: 'beta_tester_ids' must contain only non-empty tester ID strings")], isError: true ) } @@ -590,10 +643,19 @@ extension BuildBetaDetailsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 50 + } + do { let endpoint = "/v1/builds/\(try ASCPathSegment.encode(buildId))/individualTesters" - let limit = arguments["limit"]?.intValue ?? 50 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCBetaTestersResponse // Check for pagination URL @@ -678,10 +740,7 @@ extension BuildBetaDetailsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to send beta notification: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to send beta notification") } } diff --git a/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+Parsers.swift b/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+Parsers.swift index 1dadfee..73c025e 100644 --- a/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+Parsers.swift +++ b/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+Parsers.swift @@ -12,9 +12,9 @@ extension BuildBetaDetailsWorker { "type": detail.type ] - result["autoNotifyEnabled"] = detail.attributes.autoNotifyEnabled.jsonSafe - result["internalBuildState"] = detail.attributes.internalBuildState.jsonSafe - result["externalBuildState"] = detail.attributes.externalBuildState.jsonSafe + result["autoNotifyEnabled"] = (detail.attributes?.autoNotifyEnabled).jsonSafe + result["internalBuildState"] = (detail.attributes?.internalBuildState).jsonSafe + result["externalBuildState"] = (detail.attributes?.externalBuildState).jsonSafe // Add relationships if present if let relationships = detail.relationships { @@ -42,8 +42,8 @@ extension BuildBetaDetailsWorker { "type": localization.type ] - result["locale"] = localization.attributes.locale.jsonSafe - result["whatsNew"] = localization.attributes.whatsNew.jsonSafe + result["locale"] = (localization.attributes?.locale).jsonSafe + result["whatsNew"] = (localization.attributes?.whatsNew).jsonSafe if let build = localization.relationships?.build?.data { result["build"] = [ @@ -62,18 +62,18 @@ extension BuildBetaDetailsWorker { "type": group.type ] - result["name"] = group.attributes.name.jsonSafe - result["createdDate"] = group.attributes.createdDate.jsonSafe - result["isInternalGroup"] = group.attributes.isInternalGroup.jsonSafe - result["hasAccessToAllBuilds"] = group.attributes.hasAccessToAllBuilds.jsonSafe - result["publicLinkEnabled"] = group.attributes.publicLinkEnabled.jsonSafe - result["publicLinkLimit"] = group.attributes.publicLinkLimit.jsonSafe - result["publicLinkLimitEnabled"] = group.attributes.publicLinkLimitEnabled.jsonSafe - result["publicLink"] = group.attributes.publicLink.jsonSafe - result["publicLinkId"] = group.attributes.publicLinkId.jsonSafe - result["feedbackEnabled"] = group.attributes.feedbackEnabled.jsonSafe - result["iosBuildsAvailableForAppleSiliconMac"] = group.attributes.iosBuildsAvailableForAppleSiliconMac.jsonSafe - result["iosBuildsAvailableForAppleVision"] = group.attributes.iosBuildsAvailableForAppleVision.jsonSafe + result["name"] = (group.attributes?.name).jsonSafe + result["createdDate"] = (group.attributes?.createdDate).jsonSafe + result["isInternalGroup"] = (group.attributes?.isInternalGroup).jsonSafe + result["hasAccessToAllBuilds"] = (group.attributes?.hasAccessToAllBuilds).jsonSafe + result["publicLinkEnabled"] = (group.attributes?.publicLinkEnabled).jsonSafe + result["publicLinkLimit"] = (group.attributes?.publicLinkLimit).jsonSafe + result["publicLinkLimitEnabled"] = (group.attributes?.publicLinkLimitEnabled).jsonSafe + result["publicLink"] = (group.attributes?.publicLink).jsonSafe + result["publicLinkId"] = (group.attributes?.publicLinkId).jsonSafe + result["feedbackEnabled"] = (group.attributes?.feedbackEnabled).jsonSafe + result["iosBuildsAvailableForAppleSiliconMac"] = (group.attributes?.iosBuildsAvailableForAppleSiliconMac).jsonSafe + result["iosBuildsAvailableForAppleVision"] = (group.attributes?.iosBuildsAvailableForAppleVision).jsonSafe if let relationships = group.relationships { var relationIds: [String: Any] = [:] @@ -105,12 +105,12 @@ extension BuildBetaDetailsWorker { "type": tester.type ] - result["email"] = tester.attributes.email.jsonSafe - result["firstName"] = tester.attributes.firstName.jsonSafe - result["lastName"] = tester.attributes.lastName.jsonSafe - result["inviteType"] = tester.attributes.inviteType.jsonSafe - result["state"] = tester.attributes.state.jsonSafe - result["appDevices"] = tester.attributes.appDevices.map { devices in + result["email"] = (tester.attributes?.email).jsonSafe + result["firstName"] = (tester.attributes?.firstName).jsonSafe + result["lastName"] = (tester.attributes?.lastName).jsonSafe + result["inviteType"] = (tester.attributes?.inviteType).jsonSafe + result["state"] = (tester.attributes?.state).jsonSafe + result["appDevices"] = (tester.attributes?.appDevices).map { devices in devices.map { device in [ "model": device.model.jsonSafe, diff --git a/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+ToolDefinitions.swift index 524729c..0e8f4dc 100644 --- a/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/BuildBetaDetailsWorker/BuildBetaDetailsWorker+ToolDefinitions.swift @@ -33,8 +33,8 @@ extension BuildBetaDetailsWorker { "description": .string("Beta detail ID") ]), "auto_notify": .object([ - "type": .string("boolean"), - "description": .string("Automatically notify testers") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Automatically notify testers, or null to clear the setting") ]), "internal_build_state": .object([ "type": .string("string"), @@ -70,8 +70,8 @@ extension BuildBetaDetailsWorker { "description": .string("Locale code (e.g. en-US, ru-RU, de-DE, ja, zh-Hans)") ]), "whats_new": .object([ - "type": .string("string"), - "description": .string("What's New text for TestFlight (max 4000 characters)") + "type": .array([.string("string"), .string("null")]), + "description": .string("What's New text for TestFlight (max 4000 characters), or null to clear it") ]), "feedback_email": .object([ "type": .string("string"), @@ -112,7 +112,10 @@ extension BuildBetaDetailsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Maximum number of localizations to return (1-200)") + "description": .string("Maximum number of localizations to return (1-200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), @@ -185,7 +188,10 @@ extension BuildBetaDetailsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Maximum number of testers to return (1-200)") + "description": .string("Maximum number of testers to return (1-200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), @@ -212,8 +218,10 @@ extension BuildBetaDetailsWorker { "type": .string("array"), "description": .string("Array of beta group IDs to add the build to"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("build_id"), .string("group_ids")]) @@ -253,8 +261,10 @@ extension BuildBetaDetailsWorker { "type": .string("array"), "description": .string("Array of beta tester IDs to add to the build"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("build_id"), .string("beta_tester_ids")]) @@ -277,8 +287,10 @@ extension BuildBetaDetailsWorker { "type": .string("array"), "description": .string("Array of beta tester IDs to remove from the build"), "items": .object([ - "type": .string("string") - ]) + "type": .string("string"), + "minLength": .int(1) + ]), + "minItems": .int(1) ]) ]), "required": .array([.string("build_id"), .string("beta_tester_ids")]) @@ -299,7 +311,10 @@ extension BuildBetaDetailsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Maximum number of testers to return (1-200)") + "description": .string("Maximum number of testers to return (1-200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/BuildProcessingWorker/BuildProcessingWorker+Handlers.swift b/Sources/asc-mcp/Workers/BuildProcessingWorker/BuildProcessingWorker+Handlers.swift index 7b958f2..27167b8 100644 --- a/Sources/asc-mcp/Workers/BuildProcessingWorker/BuildProcessingWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BuildProcessingWorker/BuildProcessingWorker+Handlers.swift @@ -83,6 +83,7 @@ extension BuildProcessingWorker { ) } + let response: ASCBuildResponse do { let updateRequest = UpdateBuildProcessingRequest( data: UpdateBuildProcessingRequest.UpdateBuildProcessingData( @@ -94,31 +95,41 @@ extension BuildProcessingWorker { ) ) - let response: ASCBuildResponse = try await httpClient.patch( + response = try await httpClient.patch( "/v1/builds/\(try ASCPathSegment.encode(buildId))", body: updateRequest, as: ASCBuildResponse.self ) + } catch { + return MCPResult.error(error, prefix: "Failed to update build encryption compliance") + } + let build: [String: Any] + do { let encodedBuild = try JSONEncoder().encode(response.data) - let build = try JSONSerialization.jsonObject(with: encodedBuild) as? [String: Any] ?? [:] - - let result: [String: Any] = [ - "success": true, - "build": build, - "buildId": response.data.id, - "usesNonExemptEncryption": response.data.attributes.usesNonExemptEncryption.jsonSafe, - "message": "Build encryption compliance updated successfully" - ] - - return MCPResult.jsonObject(result) - + build = try JSONSerialization.jsonObject(with: encodedBuild) as? [String: Any] ?? [:] } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update build encryption compliance: \(error.localizedDescription)")], - isError: true + let committedError = ASCError.mutationCommittedUnverified( + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200, + cause: .parsing("Failed to encode the accepted build response: \(error.localizedDescription)") + ) + return MCPResult.error( + committedError, + prefix: "Failed to verify updated build encryption compliance" ) } + + let result: [String: Any] = [ + "success": true, + "build": build, + "buildId": response.data.id, + "usesNonExemptEncryption": response.data.attributes.usesNonExemptEncryption.jsonSafe, + "message": "Build encryption compliance updated successfully" + ] + + return MCPResult.jsonObject(result) } /// Checks current processing status of a build (non-blocking, single check) @@ -225,9 +236,9 @@ extension BuildProcessingWorker { switch item { case .buildBetaDetail(let detail): betaDetails = [ - "autoNotifyEnabled": detail.attributes.autoNotifyEnabled ?? false, - "internalBuildState": detail.attributes.internalBuildState ?? "", - "externalBuildState": detail.attributes.externalBuildState ?? "" + "autoNotifyEnabled": detail.attributes?.autoNotifyEnabled ?? false, + "internalBuildState": detail.attributes?.internalBuildState ?? "", + "externalBuildState": detail.attributes?.externalBuildState ?? "" ] default: break diff --git a/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Handlers.swift b/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Handlers.swift index 78a129a..86e523a 100644 --- a/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Handlers.swift @@ -17,11 +17,22 @@ extension BuildsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let response: ASCBuildsResponse var queryParams: [String: String] = [ "filter[app]": appId, - "include": "app,buildBetaDetail,preReleaseVersion,buildUpload" + "include": "app,buildBetaDetail,preReleaseVersion,buildUpload", + "limit": String(limit) ] if let version = commaSeparatedStringList(arguments["version"]) { @@ -48,12 +59,6 @@ extension BuildsWorker { if let declarationExists = arguments["uses_non_exempt_encryption_set"]?.boolValue { queryParams["exists[usesNonExemptEncryption]"] = declarationExists ? "true" : "false" } - if let limitValue = arguments["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } if let sortValue = arguments["sort"], let sort = sortValue.stringValue { queryParams["sort"] = sort diff --git a/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Parsers.swift b/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Parsers.swift index 509f00d..24faa78 100644 --- a/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Parsers.swift +++ b/Sources/asc-mcp/Workers/BuildsWorker/BuildsWorker+Parsers.swift @@ -115,9 +115,9 @@ extension BuildsWorker { return [ "id": detail.id, "type": detail.type, - "autoNotifyEnabled": detail.attributes.autoNotifyEnabled.jsonSafe, - "internalBuildState": detail.attributes.internalBuildState.jsonSafe, - "externalBuildState": detail.attributes.externalBuildState.jsonSafe + "autoNotifyEnabled": (detail.attributes?.autoNotifyEnabled).jsonSafe, + "internalBuildState": (detail.attributes?.internalBuildState).jsonSafe, + "externalBuildState": (detail.attributes?.externalBuildState).jsonSafe ] case .buildUpload(let upload): return formatIncludedBuildUpload(upload) @@ -140,17 +140,17 @@ extension BuildsWorker { return [ "id": group.id, "type": group.type, - "name": group.attributes.name.jsonSafe, - "isInternalGroup": group.attributes.isInternalGroup.jsonSafe + "name": (group.attributes?.name).jsonSafe, + "isInternalGroup": (group.attributes?.isInternalGroup).jsonSafe ] case .betaTester(let tester): return [ "id": tester.id, "type": tester.type, - "email": tester.attributes.email.jsonSafe, - "firstName": tester.attributes.firstName.jsonSafe, - "lastName": tester.attributes.lastName.jsonSafe, - "appDevices": tester.attributes.appDevices.map { devices in + "email": (tester.attributes?.email).jsonSafe, + "firstName": (tester.attributes?.firstName).jsonSafe, + "lastName": (tester.attributes?.lastName).jsonSafe, + "appDevices": (tester.attributes?.appDevices).map { devices in devices.map { device in [ "model": device.model.jsonSafe, diff --git a/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+Handlers.swift b/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+Handlers.swift index 177b32e..cbadbaf 100644 --- a/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+Handlers.swift @@ -214,7 +214,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "create_page", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), identifiers: createPageRecoveryIdentifiers( appID: appID, name: name, @@ -307,7 +312,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "update_page", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), identifiers: updatePageRecoveryIdentifiers(pageID: pageID, name: name, visible: visible), inspection: getInspection( tool: "custom_pages_get", @@ -532,7 +542,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "create_version", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), identifiers: createVersionRecoveryIdentifiers(pageID: pageID, deepLink: deepLink), inspection: listInspection( tool: "custom_pages_list_versions", @@ -617,7 +632,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "update_version", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), identifiers: updateVersionRecoveryIdentifiers(versionID: versionID, deepLink: deepLink), inspection: getInspection( tool: "custom_pages_get_version", @@ -799,7 +819,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "create_localization", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), identifiers: createLocalizationRecoveryIdentifiers( versionID: versionID, locale: locale, @@ -895,7 +920,12 @@ extension CustomProductPagesWorker { return customPageMutationFailure( operation: "update_localization", phase: .acceptedResponse, - error: error, + error: customPageAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), identifiers: updateLocalizationRecoveryIdentifiers( localizationID: localizationID, promotionalText: promotionalText @@ -1107,7 +1137,12 @@ private extension CustomProductPagesWorker { return customPageMutationFailure( operation: operation, phase: .acceptedResponse, - error: ASCError.api("Search keyword relationship POST expected HTTP 204", receipt.statusCode), + error: customPageAcceptedMutationError( + ASCError.api("Search keyword relationship POST expected HTTP 204", receipt.statusCode), + method: "POST", + expectedStatusCode: 204, + actualStatusCode: receipt.statusCode + ), identifiers: ["localizationId": localizationID, "keywordIds": keywordIDs], inspection: inspection ) @@ -1880,6 +1915,7 @@ private extension CustomProductPagesWorker { payload["mutationAttempted"] = true payload["retrySafe"] = disposition == .rejected payload["error"] = Redactor.redact(error.localizedDescription) + payload["cause"] = customPageMutationCause(error, phase: phase) payload["inspection"] = inspection payload["recovery"] = [ "action": disposition == .rejected ? "correct_request_before_retry" : "inspect_before_retry", @@ -1911,6 +1947,48 @@ private extension CustomProductPagesWorker { return MCPResult.jsonObject(payload, text: text, isError: true) } + func customPageAcceptedMutationError( + _ error: Error, + method: String, + expectedStatusCode: Int, + actualStatusCode: Int + ) -> ASCError { + let cause: ASCError + if let ascError = error as? ASCError { + if case .mutationCommittedUnverified = ascError { + return ascError + } + cause = ascError + } else { + cause = .parsing(Redactor.redact(error.localizedDescription)) + } + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: expectedStatusCode, + actualStatusCode: actualStatusCode, + cause: cause + ) + } + + func customPageMutationCause( + _ error: Error, + phase: ASCNonIdempotentWriteFailurePhase + ) -> Value { + if let ascError = error as? ASCError { + return ascError.structuredValue + } + if error is CancellationError { + return .object([ + "type": .string("cancellation"), + "message": .string("The request was cancelled before its write outcome was confirmed") + ]) + } + return .object([ + "type": .string(phase == .request ? "request" : "response_validation"), + "message": .string(Redactor.redact(error.localizedDescription)) + ]) + } + func getInspection( tool: String, arguments: [String: Any], diff --git a/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+ToolDefinitions.swift index acb0e16..e00895f 100644 --- a/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/CustomProductPagesWorker/CustomProductPagesWorker+ToolDefinitions.swift @@ -58,7 +58,9 @@ extension CustomProductPagesWorker { "name": nullableStringSchema("Replacement name, or null to clear Apple's nullable field"), "visible": nullableBooleanSchema("Replacement visibility, or null to clear Apple's nullable field") ], - required: ["page_id"] + required: ["page_id"], + minimumProperties: 2, + anyRequired: ["name", "visible"] ) ) } @@ -266,7 +268,9 @@ extension CustomProductPagesWorker { private func objectSchema( properties: [String: Value], - required: [String] = [] + required: [String] = [], + minimumProperties: Int? = nil, + anyRequired: [String] = [] ) -> Value { var schema: [String: Value] = [ "type": .string("object"), @@ -276,6 +280,14 @@ extension CustomProductPagesWorker { if !required.isEmpty { schema["required"] = .array(required.map(Value.string)) } + if let minimumProperties { + schema["minProperties"] = .int(minimumProperties) + } + if !anyRequired.isEmpty { + schema["anyOf"] = .array(anyRequired.map { field in + .object(["required": .array([.string(field)])]) + }) + } return .object(schema) } diff --git a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+Handlers.swift b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+Handlers.swift index fdc991b..60135cc 100644 --- a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+Handlers.swift @@ -13,7 +13,7 @@ extension ExportComplianceWorker { func listDeclarations(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let appID = try exportComplianceString(arguments, "app_id") + let appID = try exportComplianceIdentifier(arguments, "app_id") let path = "/v1/apps/\(try ASCPathSegment.encode(appID))/appEncryptionDeclarations" let requestedLimit = try exportComplianceLimit(arguments["limit"]) let nextURL = try paginationURL(from: arguments["next_url"]) @@ -32,21 +32,13 @@ extension ExportComplianceWorker { "fields[appEncryptionDeclarations]": exportComplianceDeclarationFields, "limit": String(limit) ] + let paginationScope = PaginationScope.strict(path: path, query: query) let response: ASCExportComplianceDeclarationsResponse if let nextURL { response = try await httpClient.getPage( nextURL, - scope: PaginationScope( - path: path, - requiredParameters: query, - allowedParameters: [ - "fields[appEncryptionDeclarations]", - "limit", - "cursor" - ], - requiredNonEmptyParameters: ["cursor"] - ), + scope: paginationScope, as: ASCExportComplianceDeclarationsResponse.self ) } else { @@ -56,6 +48,12 @@ extension ExportComplianceWorker { as: ASCExportComplianceDeclarationsResponse.self ) } + try validateExportComplianceDeclarationCollection( + response, + expectedPath: path, + requestedLimit: limit, + paginationScope: paginationScope + ) let declarations = response.data.map(exportComplianceDeclarationDictionary) var result: [String: Any] = [ @@ -66,7 +64,7 @@ extension ExportComplianceWorker { if let total = response.meta?.paging?.total { result["total"] = total } - if let nextURL = response.links?.next { + if let nextURL = response.links.next { result["next_url"] = nextURL } return MCPResult.jsonObject(result) @@ -78,7 +76,7 @@ extension ExportComplianceWorker { func getDeclaration(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let declarationID = try exportComplianceString(arguments, "declaration_id") + let declarationID = try exportComplianceIdentifier(arguments, "declaration_id") let declaration = try await fetchDeclaration(declarationID) return MCPResult.jsonObject([ "success": true, @@ -94,7 +92,7 @@ extension ExportComplianceWorker { let request: ExportComplianceCreateDeclarationRequest do { let arguments = try exportComplianceArguments(params) - appID = try exportComplianceString(arguments, "app_id") + appID = try exportComplianceIdentifier(arguments, "app_id") let appDescription = try exportComplianceString(arguments, "app_description") let containsProprietaryCryptography = try exportComplianceBoolean( arguments, @@ -138,18 +136,52 @@ extension ExportComplianceWorker { do { response = try JSONDecoder().decode(ASCExportComplianceDeclarationResponse.self, from: data) } catch { + let mutationError = exportComplianceAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201 + ) return exportComplianceDeclarationCreationFailure( appID: appID, state: .committedUnverified, - reason: "Apple accepted the declaration create request but returned an unreadable response: \(error.localizedDescription)" + reason: "Apple accepted the declaration create request but returned an unreadable response: \(error.localizedDescription)", + cause: mutationError ) } guard response.data.type == "appEncryptionDeclarations", exportComplianceHasUsableResourceID(response.data.id) else { + let mutationError = exportComplianceAcceptedMutationError( + ASCError.parsing("Apple returned an unexpected declaration resource"), + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201 + ) + return exportComplianceDeclarationCreationFailure( + appID: appID, + state: .committedUnverified, + reason: "Apple returned an unexpected declaration resource after accepting the create request.", + cause: mutationError + ) + } + do { + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarations", + id: response.data.id + ) + } catch { + let mutationError = exportComplianceAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201 + ) return exportComplianceDeclarationCreationFailure( appID: appID, state: .committedUnverified, - reason: "Apple returned an unexpected declaration resource after accepting the create request." + reason: "Apple accepted the declaration create request but returned an invalid resource self-link.", + cause: mutationError ) } return MCPResult.jsonObject([ @@ -164,7 +196,8 @@ extension ExportComplianceWorker { return exportComplianceDeclarationCreationFailure( appID: appID, state: state, - reason: "The declaration create request did not return a confirmed resource: \(error.localizedDescription)" + reason: "The declaration create request did not return a confirmed resource: \(error.localizedDescription)", + cause: error ) } } @@ -174,7 +207,7 @@ extension ExportComplianceWorker { let filePath: String do { let arguments = try exportComplianceArguments(params) - declarationID = try exportComplianceString(arguments, "declaration_id") + declarationID = try exportComplianceIdentifier(arguments, "declaration_id") filePath = try exportComplianceFilePath(arguments, "file_path") } catch { return exportComplianceError("Invalid document reservation input", error) @@ -223,7 +256,7 @@ extension ExportComplianceWorker { func getDocument(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let documentID = try exportComplianceString(arguments, "document_id") + let documentID = try exportComplianceIdentifier(arguments, "document_id") let document = try await fetchDocument(documentID, includeUploadOperations: false) return MCPResult.jsonObject([ "success": true, @@ -240,7 +273,7 @@ extension ExportComplianceWorker { let request: ExportComplianceUpdateDocumentRequest do { let arguments = try exportComplianceArguments(params) - documentID = try exportComplianceString(arguments, "document_id") + documentID = try exportComplianceIdentifier(arguments, "document_id") endpoint = "/v1/appEncryptionDeclarationDocuments/\(try ASCPathSegment.encode(documentID))" let checksum = try exportComplianceNullableString(arguments["source_file_checksum"], field: "source_file_checksum") let uploaded = try exportComplianceNullableBoolean(arguments["uploaded"], field: "uploaded") @@ -272,18 +305,52 @@ extension ExportComplianceWorker { do { response = try JSONDecoder().decode(ASCExportComplianceDocumentResponse.self, from: data) } catch { + let mutationError = exportComplianceAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200 + ) return exportComplianceDocumentUpdateFailure( documentID: documentID, state: .committedUnverified, - reason: "Apple accepted the document update but returned an unreadable response: \(error.localizedDescription)" + reason: "Apple accepted the document update but returned an unreadable response: \(error.localizedDescription)", + cause: mutationError ) } guard response.data.id == documentID, response.data.type == "appEncryptionDeclarationDocuments" else { + let mutationError = exportComplianceAcceptedMutationError( + ASCError.parsing("Apple returned an unexpected encryption document resource"), + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200 + ) return exportComplianceDocumentUpdateFailure( documentID: documentID, state: .committedUnverified, - reason: "Apple returned an unexpected document resource after accepting the update request." + reason: "Apple returned an unexpected document resource after accepting the update request.", + cause: mutationError + ) + } + do { + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarationDocuments", + id: response.data.id + ) + } catch { + let mutationError = exportComplianceAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200 + ) + return exportComplianceDocumentUpdateFailure( + documentID: documentID, + state: .committedUnverified, + reason: "Apple accepted the document update but returned an invalid resource self-link.", + cause: mutationError ) } return MCPResult.jsonObject([ @@ -297,7 +364,8 @@ extension ExportComplianceWorker { return exportComplianceDocumentUpdateFailure( documentID: documentID, state: exportComplianceMutationState(for: error), - reason: "The document update did not return a confirmed resource: \(error.localizedDescription)" + reason: "The document update did not return a confirmed resource: \(error.localizedDescription)", + cause: error ) } } @@ -308,7 +376,7 @@ extension ExportComplianceWorker { let expectedChecksum: String do { let arguments = try exportComplianceArguments(params) - documentID = try exportComplianceString(arguments, "document_id") + documentID = try exportComplianceIdentifier(arguments, "document_id") filePath = try exportComplianceFilePath(arguments, "file_path") expectedChecksum = try exportComplianceMD5(arguments, "source_file_checksum") } catch { @@ -446,7 +514,7 @@ extension ExportComplianceWorker { func inspectDocument(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let declarationID = try exportComplianceString(arguments, "declaration_id") + let declarationID = try exportComplianceIdentifier(arguments, "declaration_id") let declaration = try await fetchDeclaration(declarationID) guard let document = try await fetchDocumentForDeclaration(declarationID) else { return MCPResult.jsonObject([ @@ -476,7 +544,7 @@ extension ExportComplianceWorker { func getBuildDeclaration(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let buildID = try exportComplianceString(arguments, "build_id") + let buildID = try exportComplianceIdentifier(arguments, "build_id") _ = try await fetchBuild(buildID) guard let declaration = try await fetchBuildDeclaration(buildID) else { return MCPResult.jsonObject([ @@ -501,8 +569,8 @@ extension ExportComplianceWorker { let declarationID: String do { let arguments = try exportComplianceArguments(params) - buildID = try exportComplianceString(arguments, "build_id") - declarationID = try exportComplianceString(arguments, "declaration_id") + buildID = try exportComplianceIdentifier(arguments, "build_id") + declarationID = try exportComplianceIdentifier(arguments, "declaration_id") } catch { return exportComplianceError("Invalid build declaration attachment input", error) } @@ -550,9 +618,10 @@ extension ExportComplianceWorker { do { _ = try await httpClient.patch( "/v1/builds/\(try ASCPathSegment.encode(buildID))", - body: try JSONEncoder().encode(request) + body: try JSONEncoder().encode(request), + expectedStatusCode: 200 ) - } catch { + } catch let mutationError { do { if let attached = try await fetchBuildDeclaration(buildID), attached.id == declarationID { @@ -568,23 +637,32 @@ extension ExportComplianceWorker { return exportComplianceUnverifiedAttachment( buildID: buildID, declarationID: declarationID, - reason: "The build update had no confirmed response, and relationship reconciliation failed: \(error.localizedDescription)" + reason: "The build update had no confirmed response, and relationship reconciliation failed: \(error.localizedDescription)", + cause: mutationError ) } return exportComplianceUnverifiedAttachment( buildID: buildID, declarationID: declarationID, - reason: "The build update had no confirmed response, and the intended declaration could not be verified." + reason: "The build update had no confirmed response, and the intended declaration could not be verified.", + cause: mutationError ) } do { guard let attached = try await fetchBuildDeclaration(buildID), attached.id == declarationID else { + let mutationError = exportComplianceAcceptedMutationError( + ASCError.parsing("The build relationship did not match the requested declaration"), + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200 + ) return exportComplianceUnverifiedAttachment( buildID: buildID, declarationID: declarationID, - reason: "The build relationship could not be verified after the update." + reason: "The build relationship could not be verified after the update.", + cause: mutationError ) } return MCPResult.jsonObject([ @@ -594,10 +672,17 @@ extension ExportComplianceWorker { "declaration": exportComplianceDeclarationDictionary(attached) ]) } catch { + let mutationError = exportComplianceAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200 + ) return exportComplianceUnverifiedAttachment( buildID: buildID, declarationID: declarationID, - reason: "The update succeeded, but relationship verification failed: \(error.localizedDescription)" + reason: "The update succeeded, but relationship verification failed: \(error.localizedDescription)", + cause: mutationError ) } } @@ -605,7 +690,7 @@ extension ExportComplianceWorker { func checkReleaseReadiness(_ params: CallTool.Parameters) async throws -> CallTool.Result { do { let arguments = try exportComplianceArguments(params) - let buildID = try exportComplianceString(arguments, "build_id") + let buildID = try exportComplianceIdentifier(arguments, "build_id") let build = try await fetchBuild(buildID) let appID = build.relationships?.app?.data?.id let processingState = build.attributes.processingState @@ -936,13 +1021,18 @@ extension ExportComplianceWorker { ) }, decodeResource: { data in - let document = try JSONDecoder() + let response = try JSONDecoder() .decode(ASCExportComplianceDocumentResponse.self, from: data) - .data + let document = response.data guard document.type == "appEncryptionDeclarationDocuments", exportComplianceHasUsableResourceID(document.id) else { throw ExportComplianceInputError("Apple returned an unexpected document resource") } + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarationDocuments", + id: document.id + ) return document }, makeCommitBody: { id, checksum in @@ -1003,6 +1093,11 @@ extension ExportComplianceWorker { response.data.type == "appEncryptionDeclarations" else { throw ExportComplianceInputError("Apple returned an unexpected declaration resource") } + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarations", + id: response.data.id + ) return response.data } @@ -1023,6 +1118,11 @@ extension ExportComplianceWorker { response.data.type == "appEncryptionDeclarationDocuments" else { throw ExportComplianceInputError("Apple returned an unexpected document resource") } + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarationDocuments", + id: response.data.id + ) return response.data } @@ -1039,6 +1139,11 @@ extension ExportComplianceWorker { exportComplianceHasUsableResourceID(response.data.id) else { throw ExportComplianceInputError("Apple returned an unexpected document resource") } + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarationDocuments", + id: response.data.id + ) return response.data } catch let error as ASCError where exportComplianceHTTPStatus(error) == 404 { return nil @@ -1058,6 +1163,11 @@ extension ExportComplianceWorker { exportComplianceHasUsableResourceID(response.data.id) else { throw ExportComplianceInputError("Apple returned an unexpected declaration resource") } + try validateExportComplianceResourceSelf( + response.links.`self`, + collection: "appEncryptionDeclarations", + id: response.data.id + ) return response.data } catch let error as ASCError where exportComplianceHTTPStatus(error) == 404 { return nil @@ -1065,6 +1175,87 @@ extension ExportComplianceWorker { } } +private extension ExportComplianceWorker { + func validateExportComplianceResourceSelf( + _ value: String, + collection: String, + id: String + ) throws { + do { + _ = try httpClient.validatedScopedLink( + value, + scope: PaginationScope( + path: "/v1/\(try ASCPathSegment.encode(collection))/\(try ASCPathSegment.encode(id))", + allowedParameters: [] + ) + ) + } catch { + throw ASCError.parsing("Apple returned an out-of-scope required resource links.self") + } + } + + func validateExportComplianceDeclarationCollection( + _ response: ASCExportComplianceDeclarationsResponse, + expectedPath: String, + requestedLimit: Int, + paginationScope: PaginationScope + ) throws { + do { + _ = try httpClient.validatedScopedLink( + response.links.`self`, + scope: PaginationScope(path: expectedPath) + ) + } catch { + throw ASCError.parsing("Apple returned an out-of-scope required links.self for export-compliance declarations") + } + + let nextRequest: PaginationRequest? + if let next = response.links.next { + do { + nextRequest = try httpClient.validatedScopedLink(next, scope: paginationScope) + } catch { + throw ASCError.parsing("Apple returned an out-of-scope links.next for export-compliance declarations") + } + } else { + nextRequest = nil + } + + guard response.data.count <= requestedLimit else { + throw ASCError.parsing("Apple returned more export-compliance declarations than the requested limit") + } + if let meta = response.meta { + guard let paging = meta.paging, + paging.limit == requestedLimit else { + throw ASCError.parsing("Apple returned export-compliance paging metadata outside the requested limit") + } + if let total = paging.total, total < response.data.count { + throw ASCError.parsing("Apple returned an export-compliance paging total below the page count") + } + if let cursor = paging.nextCursor { + guard !cursor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + nextRequest?.parameters["cursor"] == cursor else { + throw ASCError.parsing("Apple returned inconsistent export-compliance cursor metadata") + } + } + } + + var identities = Set() + for declaration in response.data { + guard declaration.type == "appEncryptionDeclarations", + exportComplianceHasUsableResourceID(declaration.id), + identities.insert(declaration.id).inserted else { + throw ASCError.parsing("Apple returned an invalid or duplicate export-compliance declaration identity") + } + if let document = declaration.relationships?.appEncryptionDeclarationDocument?.data { + guard document.type == "appEncryptionDeclarationDocuments", + exportComplianceHasUsableResourceID(document.id) else { + throw ASCError.parsing("Apple returned an invalid export-compliance document relationship") + } + } + } + } +} + // MARK: - Formatting private func exportComplianceDeclarationDictionary( @@ -1312,25 +1503,34 @@ private func exportComplianceUploadResult( private func exportComplianceUnverifiedAttachment( buildID: String, declarationID: String, - reason: String + reason: String, + cause: Error ) -> CallTool.Result { let safeReason = Redactor.redact(reason) + let state = exportComplianceMutationState(for: cause) + var details: [String: Value] = [ + "attachmentState": .string("unverified"), + "operationCommitState": .string(state.operationCommitState), + "outcomeUnknown": .bool(state == .commitUnknown), + "retrySafe": .bool(state.retrySafe), + "build_id": .string(buildID), + "declaration_id": .string(declarationID), + "cause": exportComplianceStructuredCause(cause), + "inspection": .object([ + "tool": .string("export_compliance_get_build_declaration"), + "arguments": .object(["build_id": .string(buildID)]) + ]) + ] + if state == .committedUnverified { + details["operationCommitted"] = .bool(true) + } return MCPResult.error( safeReason, - details: .object([ - "attachmentState": .string("unverified"), - "retrySafe": .bool(false), - "build_id": .string(buildID), - "declaration_id": .string(declarationID), - "inspection": .object([ - "tool": .string("export_compliance_get_build_declaration"), - "arguments": .object(["build_id": .string(buildID)]) - ]) - ]) + details: .object(details) ) } -private enum ExportComplianceMutationState: String { +private enum ExportComplianceMutationState: String, Equatable { case committedUnverified = "committed_unverified" case commitUnknown = "commit_unknown" case rejected @@ -1361,6 +1561,15 @@ private enum ExportComplianceMutationState: String { return false } } + + var operationCommitState: String { + switch self { + case .commitUnknown: + return "unknown" + case .committedUnverified, .rejected: + return rawValue + } + } } private func exportComplianceMutationState(for error: Error) -> ExportComplianceMutationState { @@ -1372,6 +1581,10 @@ private func exportComplianceMutationState(for error: Error) -> ExportCompliance return .committedUnverified case .deleteOutcomeUnknown: return .commitUnknown + case .mutationCommittedUnverified: + return .committedUnverified + case .mutationOutcomeUnknown: + return .commitUnknown case .network(_): return .commitUnknown case .api(_, let statusCode), .apiResponse(_, let statusCode): @@ -1386,15 +1599,22 @@ private func exportComplianceMutationState(for error: Error) -> ExportCompliance private func exportComplianceDeclarationCreationFailure( appID: String, state: ExportComplianceMutationState, - reason: String + reason: String, + cause: Error ) -> CallTool.Result { let safeReason = Redactor.redact(reason) var details: [String: Value] = [ "creationState": .string(state.rawValue), + "operationCommitState": .string(state.operationCommitState), + "outcomeUnknown": .bool(state == .commitUnknown), "commitConfirmed": .bool(state.commitConfirmed), "declarationIdKnown": .bool(false), - "retrySafe": .bool(state.retrySafe) + "retrySafe": .bool(state.retrySafe), + "cause": exportComplianceStructuredCause(cause) ] + if state == .committedUnverified { + details["operationCommitted"] = .bool(true) + } if state.needsInspection { details["inspection"] = .object([ "tool": .string("export_compliance_list_declarations"), @@ -1410,14 +1630,21 @@ private func exportComplianceDeclarationCreationFailure( private func exportComplianceDocumentUpdateFailure( documentID: String, state: ExportComplianceMutationState, - reason: String + reason: String, + cause: Error ) -> CallTool.Result { let safeReason = Redactor.redact(reason) var details: [String: Value] = [ "updateState": .string(state.rawValue), + "operationCommitState": .string(state.operationCommitState), + "outcomeUnknown": .bool(state == .commitUnknown), "commitConfirmed": .bool(state.commitConfirmed), - "retrySafe": .bool(state.retrySafe) + "retrySafe": .bool(state.retrySafe), + "cause": exportComplianceStructuredCause(cause) ] + if state == .committedUnverified { + details["operationCommitted"] = .bool(true) + } if state.needsInspection { details["inspection"] = .object([ "tool": .string("export_compliance_get_document"), @@ -1430,6 +1657,45 @@ private func exportComplianceDocumentUpdateFailure( ) } +private func exportComplianceAcceptedMutationError( + _ error: Error, + method: String, + expectedStatusCode: Int, + actualStatusCode: Int +) -> ASCError { + let cause: ASCError + if let ascError = error as? ASCError { + if case .mutationCommittedUnverified = ascError { + return ascError + } + cause = ascError + } else { + cause = .parsing(Redactor.redact(error.localizedDescription)) + } + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: expectedStatusCode, + actualStatusCode: actualStatusCode, + cause: cause + ) +} + +private func exportComplianceStructuredCause(_ error: Error) -> Value { + if let ascError = error as? ASCError { + return ascError.structuredValue + } + if error is CancellationError { + return .object([ + "type": .string("cancellation"), + "message": .string("The request was cancelled before its write outcome was confirmed") + ]) + } + return .object([ + "type": .string("request"), + "message": .string(Redactor.redact(error.localizedDescription)) + ]) +} + // MARK: - Validation private func exportComplianceArguments(_ params: CallTool.Parameters) throws -> [String: Value] { @@ -1463,6 +1729,20 @@ private func exportComplianceBoolean( return value } +private func exportComplianceIdentifier( + _ arguments: [String: Value], + _ field: String +) throws -> String { + let identifier = try exportComplianceString(arguments, field) + let encoded = try ASCPathSegment.encode(identifier, field: field) + guard encoded == identifier else { + throw ExportComplianceInputError( + "Parameter '\(field)' must be a canonical App Store Connect resource ID" + ) + } + return identifier +} + private func exportComplianceFilePath( _ arguments: [String: Value], _ field: String @@ -1523,7 +1803,7 @@ private func exportComplianceHasUsableResourceID(_ id: String) -> Bool { id == id.trimmingCharacters(in: .whitespacesAndNewlines) else { return false } - return (try? ASCPathSegment.encode(id)) != nil + return (try? ASCPathSegment.encode(id)) == id } private func exportComplianceNullableString( @@ -1560,6 +1840,10 @@ private func exportComplianceHTTPStatus(_ error: ASCError) -> Int? { return statusCode case .deleteOutcomeUnknown(let cause): return exportComplianceHTTPStatus(cause) + case .mutationOutcomeUnknown(_, let cause): + return exportComplianceHTTPStatus(cause) + case .mutationCommittedUnverified(_, _, let actualStatusCode, _): + return actualStatusCode default: return nil } diff --git a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+ToolDefinitions.swift index 7e0ff80..6925bc6 100644 --- a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker+ToolDefinitions.swift @@ -113,7 +113,9 @@ extension ExportComplianceWorker { "description": .string("Upload completion flag, or JSON null to send an explicit nullable value") ]) ], - required: ["document_id"] + required: ["document_id"], + minimumProperties: 2, + anyRequired: ["source_file_checksum", "uploaded"] ) ) } @@ -185,19 +187,34 @@ extension ExportComplianceWorker { } } -private func exportComplianceSchema(properties: [String: Value], required: [String]) -> Value { - .object([ +private func exportComplianceSchema( + properties: [String: Value], + required: [String], + minimumProperties: Int? = nil, + anyRequired: [String] = [] +) -> Value { + var schema: [String: Value] = [ "type": .string("object"), "properties": .object(properties), "required": .array(required.map(Value.string)), "additionalProperties": .bool(false) - ]) + ] + if let minimumProperties { + schema["minProperties"] = .int(minimumProperties) + } + if !anyRequired.isEmpty { + schema["anyOf"] = .array(anyRequired.map { field in + .object(["required": .array([.string(field)])]) + }) + } + return .object(schema) } private func exportComplianceID(_ description: String) -> Value { .object([ "type": .string("string"), "minLength": .int(1), + "pattern": .string(#"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"#), "description": .string(description) ]) } diff --git a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker.swift b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker.swift index ec258e4..c255474 100644 --- a/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker.swift +++ b/Sources/asc-mcp/Workers/ExportComplianceWorker/ExportComplianceWorker.swift @@ -3,6 +3,26 @@ import MCP /// Manages export-compliance declarations, documents, and build linkage. public final class ExportComplianceWorker: Sendable { + private static let allowedArguments: [String: Set] = [ + "export_compliance_list_declarations": ["app_id", "limit", "next_url"], + "export_compliance_get_declaration": ["declaration_id"], + "export_compliance_create_declaration": [ + "app_id", + "app_description", + "contains_proprietary_cryptography", + "contains_third_party_cryptography", + "available_on_french_store" + ], + "export_compliance_create_document": ["declaration_id", "file_path"], + "export_compliance_get_document": ["document_id"], + "export_compliance_update_document": ["document_id", "source_file_checksum", "uploaded"], + "export_compliance_upload_document": ["document_id", "file_path", "source_file_checksum"], + "export_compliance_inspect_document": ["declaration_id"], + "export_compliance_get_build_declaration": ["build_id"], + "export_compliance_attach_build_declaration": ["build_id", "declaration_id"], + "export_compliance_check_release_readiness": ["build_id"] + ] + let httpClient: HTTPClient let uploadService: UploadService let deliveryPollAttempts: Int @@ -54,6 +74,10 @@ public final class ExportComplianceWorker: Sendable { /// - Returns: Structured tool output or a structured error. /// - Throws: `MCPError.methodNotFound` for an unknown tool name. public func handleTool(_ params: CallTool.Parameters) async throws -> CallTool.Result { + if let allowed = Self.allowedArguments[params.name], + let unsupported = params.arguments?.keys.sorted().first(where: { !allowed.contains($0) }) { + return MCPResult.error("Unsupported parameter '\(unsupported)' for tool '\(params.name)'") + } switch params.name { case "export_compliance_list_declarations": return try await listDeclarations(params) diff --git a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+Handlers.swift b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+Handlers.swift index 101dc6f..89182a6 100644 --- a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+Handlers.swift @@ -75,7 +75,7 @@ extension InAppPurchasesWorker { } var queryParams = selection - queryParams["limit"] = String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + queryParams["limit"] = String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) let endpoint = "/v1/apps/\(try ASCPathSegment.encode(appId))/inAppPurchasesV2" if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -170,14 +170,16 @@ extension InAppPurchasesWorker { } do { + let reviewNote = try nullableIAPString("review_note", from: arguments) + let familySharable = try nullableIAPBool("family_sharable", from: arguments) let request = CreateInAppPurchaseV2Request( data: CreateInAppPurchaseV2Request.CreateIAPData( attributes: CreateInAppPurchaseV2Request.CreateIAPAttributes( name: name, productId: productId, inAppPurchaseType: iapType, - reviewNote: arguments["review_note"]?.stringValue, - familySharable: arguments["family_sharable"]?.boolValue + reviewNote: reviewNote, + familySharable: familySharable ), relationships: CreateInAppPurchaseV2Request.CreateIAPRelationships( app: CreateInAppPurchaseV2Request.AppRelationship( @@ -203,10 +205,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create IAP: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create IAP") } } @@ -222,14 +221,22 @@ extension InAppPurchasesWorker { ) } + let mutableFields: Set = ["name", "review_note", "family_sharable"] + guard arguments.keys.contains(where: mutableFields.contains) else { + return MCPResult.error("At least one mutable in-app purchase field is required") + } + do { + let name = try nullableIAPString("name", from: arguments) + let reviewNote = try nullableIAPString("review_note", from: arguments) + let familySharable = try nullableIAPBool("family_sharable", from: arguments) let request = UpdateInAppPurchaseV2Request( data: UpdateInAppPurchaseV2Request.UpdateIAPData( id: iapId, attributes: UpdateInAppPurchaseV2Request.UpdateIAPAttributes( - name: arguments["name"]?.stringValue, - reviewNote: arguments["review_note"]?.stringValue, - familySharable: arguments["family_sharable"]?.boolValue + name: name, + reviewNote: reviewNote, + familySharable: familySharable ) ) ) @@ -250,11 +257,40 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update IAP: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update IAP") + } + } + + private func nullableIAPString( + _ name: String, + from arguments: [String: Value] + ) throws -> ASCNullable? { + guard let value = arguments[name] else { + return nil + } + if case .null = value { + return .null + } + guard let string = value.stringValue else { + throw IAPWriteInputError("\(name) must be a string or null") + } + return .value(string) + } + + private func nullableIAPBool( + _ name: String, + from arguments: [String: Value] + ) throws -> ASCNullable? { + guard let value = arguments[name] else { + return nil + } + if case .null = value { + return .null } + guard let bool = value.boolValue else { + throw IAPWriteInputError("\(name) must be a Boolean or null") + } + return .value(bool) } /// Deletes an in-app purchase @@ -300,7 +336,7 @@ extension InAppPurchasesWorker { let response: ASCInAppPurchaseLocalizationsResponse let endpoint = "/v2/inAppPurchases/\(try ASCPathSegment.encode(iapId))/inAppPurchaseLocalizations" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -378,7 +414,7 @@ extension InAppPurchasesWorker { } var queryParams = selection - queryParams["limit"] = String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + queryParams["limit"] = String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) let endpoint = "/v1/apps/\(try ASCPathSegment.encode(appId))/subscriptionGroups" if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -523,7 +559,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to create IAP localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to create IAP localization") } } @@ -573,7 +609,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to update IAP localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to update IAP localization") } } @@ -655,10 +691,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to submit IAP for review: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to submit IAP for review") } } @@ -680,7 +713,7 @@ extension InAppPurchasesWorker { let response: ASCIAPPricePointsResponse let endpoint = "/v2/inAppPurchases/\(try ASCPathSegment.encode(iapId))/pricePoints" var query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 50, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 50, maximum: 8000)) ] if let territory = arguments["territory"]?.stringValue { query["filter[territory]"] = territory @@ -891,10 +924,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to set IAP price schedule: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to set IAP price schedule") } } @@ -963,10 +993,7 @@ extension InAppPurchasesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to set IAP availability: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to set IAP availability") } } @@ -1331,7 +1358,7 @@ extension InAppPurchasesWorker { let response: ASCIAPImagesResponse let endpoint = "/v2/inAppPurchases/\(try ASCPathSegment.encode(iapId))/images" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -1555,3 +1582,13 @@ extension InAppPurchasesWorker { return result } } + +private struct IAPWriteInputError: LocalizedError, Sendable { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { message } +} diff --git a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+ToolDefinitions.swift index fb1b8b0..1d9d111 100644 --- a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+ToolDefinitions.swift @@ -105,12 +105,12 @@ extension InAppPurchasesWorker { ]) ]), "review_note": .object([ - "type": .string("string"), - "description": .string("Notes for App Review") + "type": .array([.string("string"), .string("null")]), + "description": .string("Notes for App Review; pass null to preserve Apple's explicit nullable value") ]), "family_sharable": .object([ - "type": .string("boolean"), - "description": .string("Enable Family Sharing") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable Family Sharing; pass null to preserve Apple's explicit nullable value") ]) ]), "required": .array([.string("app_id"), .string("name"), .string("product_id"), .string("iap_type")]) @@ -124,22 +124,24 @@ extension InAppPurchasesWorker { description: "Update an existing in-app purchase", inputSchema: .object([ "type": .string("object"), + "additionalProperties": .bool(false), + "minProperties": .int(2), "properties": .object([ "iap_id": .object([ "type": .string("string"), "description": .string("In-app purchase ID") ]), "name": .object([ - "type": .string("string"), - "description": .string("New reference name") + "type": .array([.string("string"), .string("null")]), + "description": .string("New reference name, or null to clear Apple's nullable value") ]), "review_note": .object([ - "type": .string("string"), - "description": .string("Notes for App Review") + "type": .array([.string("string"), .string("null")]), + "description": .string("Notes for App Review, or null to clear") ]), "family_sharable": .object([ - "type": .string("boolean"), - "description": .string("Enable Family Sharing") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable Family Sharing, or null to clear Apple's nullable value") ]) ]), "required": .array([.string("iap_id")]) @@ -204,7 +206,9 @@ extension InAppPurchasesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "filter_reference_name": iapStringListSchema("Filter by one or more exact group reference names"), "filter_subscription_state": iapEnumListSchema( @@ -399,7 +403,9 @@ extension InAppPurchasesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 50, max: 8000)") + "description": .string("Max results (default: 50, max: 8000)"), + "minimum": .int(1), + "maximum": .int(8000) ]), "next_url": .object([ "type": .string("string"), @@ -673,7 +679,9 @@ extension InAppPurchasesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3CommerceHandlers.swift b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3CommerceHandlers.swift index e6cc361..dd1bd4b 100644 --- a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3CommerceHandlers.swift +++ b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3CommerceHandlers.swift @@ -10,7 +10,7 @@ extension InAppPurchasesWorker { do { let response: PassthroughAPIResponse - var query = iapPricePointQuery(limit: clampedIAPLimit(arguments["limit"]?.intValue, defaultValue: 50, max: 8000)) + var query = iapPricePointQuery(limit: try validatedCommerceLimit(arguments["limit"], defaultValue: 50, maximum: 8000)) if let territoryId = arguments["territory_id"]?.stringValue ?? arguments["territory"]?.stringValue { query["filter[territory]"] = territoryId } @@ -54,7 +54,7 @@ extension InAppPurchasesWorker { do { let response: PassthroughAPIResponse - var query = iapPricePointQuery(limit: clampedIAPLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 8000)) + var query = iapPricePointQuery(limit: try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 8000)) if let iapId = arguments["iap_id"]?.stringValue { query["filter[inAppPurchaseV2]"] = iapId } @@ -182,23 +182,18 @@ extension InAppPurchasesWorker { return MCPResult.error("IAP price schedule response did not include an id") } - let manual: PassthroughAPIResponse = try await httpClient.get( - "/v1/inAppPurchasePriceSchedules/\(try ASCPathSegment.encode(scheduleId))/manualPrices", - parameters: iapPriceQuery(territoryId: territoryId), - as: PassthroughAPIResponse.self + let query = iapPriceQuery(territoryId: territoryId) + let manualResult = try await fetchAllIAPPricingPages( + endpoint: "/v1/inAppPurchasePriceSchedules/\(try ASCPathSegment.encode(scheduleId))/manualPrices", + query: query ) - let automatic: PassthroughAPIResponse = try await httpClient.get( - "/v1/inAppPurchasePriceSchedules/\(try ASCPathSegment.encode(scheduleId))/automaticPrices", - parameters: iapPriceQuery(territoryId: territoryId), - as: PassthroughAPIResponse.self + let automaticResult = try await fetchAllIAPPricingPages( + endpoint: "/v1/inAppPurchasePriceSchedules/\(try ASCPathSegment.encode(scheduleId))/automaticPrices", + query: query ) - let manualPrices = (manual.data.arrayValue ?? []).map { - formatIAPPrice($0, included: IAPIncludedIndex(manual.included)) - } - let automaticPrices = (automatic.data.arrayValue ?? []).map { - formatIAPPrice($0, included: IAPIncludedIndex(automatic.included)) - } + let manualPrices = manualResult.prices + let automaticPrices = automaticResult.prices let prices = (manualPrices + automaticPrices).sorted { ($0["start_date"] as? String ?? "") < ($1["start_date"] as? String ?? "") } @@ -223,13 +218,69 @@ extension InAppPurchasesWorker { "current_price": current ?? NSNull(), "scheduled_prices": scheduled, "manual_prices": manualPrices, - "automatic_prices": automaticPrices + "automatic_prices": automaticPrices, + "manual_pages_fetched": manualResult.pagesFetched, + "automatic_pages_fetched": automaticResult.pagesFetched, + "complete": true, + "truncated": false ]) } catch { return MCPResult.error("Failed to summarize IAP pricing: \(error.localizedDescription)") } } + private func fetchAllIAPPricingPages( + endpoint: String, + query: [String: String] + ) async throws -> (prices: [[String: Any]], pagesFetched: Int) { + let scope = iapCommercePaginationScope(path: endpoint, query: query) + var response = try await httpClient.get( + endpoint, + parameters: query, + as: PassthroughAPIResponse.self + ) + var prices: [[String: Any]] = [] + var pagesFetched = 0 + var seenNextURLs: Set = [] + + while true { + guard let resources = response.data.arrayValue else { + throw ASCError.parsing("IAP pricing response data must be an array") + } + let included = IAPIncludedIndex(response.included) + prices.append(contentsOf: resources.map { formatIAPPrice($0, included: included) }) + pagesFetched += 1 + + guard let nextURL = try iapPricingNextURL(response.links) else { + break + } + guard seenNextURLs.insert(nextURL).inserted else { + throw ASCError.parsing("IAP pricing pagination returned a repeated next URL") + } + response = try await httpClient.getPage( + nextURL, + scope: scope, + as: PassthroughAPIResponse.self + ) + } + + return (prices, pagesFetched) + } + + private func iapPricingNextURL(_ links: JSONValue?) throws -> String? { + guard let value = links?.objectValue?["next"] else { + return nil + } + if case .null = value { + return nil + } + guard let nextURL = value.stringValue, + !nextURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ASCError.parsing("IAP pricing links.next must be a non-empty string or null") + } + return nextURL + } + func prepareIAPOfferPrices(_ params: CallTool.Parameters) async throws -> CallTool.Result { guard let arguments = params.arguments, let iapId = arguments["iap_id"]?.stringValue, @@ -350,11 +401,25 @@ extension InAppPurchasesWorker { guard let arguments = params.arguments, let iapId = arguments["iap_id"]?.stringValue, let name = arguments["name"]?.stringValue, - let eligibilities = arguments["customer_eligibilities"]?.arrayValue?.compactMap(\.stringValue), - let territoryIds = arguments["territory_ids"]?.arrayValue?.compactMap(\.stringValue), - let pricePointIds = arguments["price_point_ids"]?.arrayValue?.compactMap(\.stringValue) else { + let eligibilityValues = arguments["customer_eligibilities"]?.arrayValue, + let territoryValues = arguments["territory_ids"]?.arrayValue, + let pricePointValues = arguments["price_point_ids"]?.arrayValue else { return MCPResult.error("Required parameters: iap_id, name, customer_eligibilities, territory_ids, price_point_ids") } + let eligibilities = eligibilityValues.compactMap(\.stringValue) + let territoryIds = territoryValues.compactMap(\.stringValue) + let pricePointIds = pricePointValues.compactMap(\.stringValue) + guard eligibilities.count == eligibilityValues.count, + territoryIds.count == territoryValues.count, + pricePointIds.count == pricePointValues.count else { + return MCPResult.error("customer_eligibilities, territory_ids, and price_point_ids must contain only strings") + } + let allowedEligibilities: Set = ["NON_SPENDER", "ACTIVE_SPENDER", "CHURNED_SPENDER"] + guard !eligibilities.isEmpty, + Set(eligibilities).count == eligibilities.count, + eligibilities.allSatisfy(allowedEligibilities.contains) else { + return MCPResult.error("customer_eligibilities must contain unique values from: ACTIVE_SPENDER, CHURNED_SPENDER, NON_SPENDER") + } guard territoryIds.count == pricePointIds.count else { return MCPResult.error("price_point_ids and territory_ids must have the same count (got \(pricePointIds.count) vs \(territoryIds.count))") } @@ -396,11 +461,14 @@ extension InAppPurchasesWorker { guard let offerCodeId = params.arguments?["offer_code_id"]?.stringValue else { return MCPResult.error("Required parameter 'offer_code_id' is missing") } + guard let active = iapNullableActive(params.arguments?["active"]) else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } return try await patchIAPActiveResource( endpoint: "/v1/inAppPurchaseOfferCodes/\(try ASCPathSegment.encode(offerCodeId))", type: "inAppPurchaseOfferCodes", id: offerCodeId, - active: params.arguments?["active"]?.boolValue, + active: active, key: "offer_code" ) } @@ -413,7 +481,7 @@ extension InAppPurchasesWorker { endpoint: "/v1/inAppPurchaseOfferCodes/\(try ASCPathSegment.encode(offerCodeId))", type: "inAppPurchaseOfferCodes", id: offerCodeId, - active: false, + active: .value(false), key: "offer_code" ) } @@ -426,7 +494,7 @@ extension InAppPurchasesWorker { do { let response: PassthroughAPIResponse - var query = iapOfferPriceQuery(limit: clampedIAPLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 200)) + var query = iapOfferPriceQuery(limit: try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } @@ -473,8 +541,15 @@ extension InAppPurchasesWorker { "numberOfCodes": numberOfCodes, "expirationDate": expirationDate ] - if let environment = arguments["environment"]?.stringValue { - attributes["environment"] = environment + if let environmentValue = arguments["environment"] { + if case .null = environmentValue { + attributes["environment"] = NSNull() + } else if let environment = environmentValue.stringValue, + ["PRODUCTION", "SANDBOX"].contains(environment) { + attributes["environment"] = environment + } else { + return MCPResult.error("environment must be PRODUCTION, SANDBOX, or null") + } } let body: [String: Any] = [ "data": [ @@ -509,11 +584,14 @@ extension InAppPurchasesWorker { guard let oneTimeCodeId = params.arguments?["one_time_code_id"]?.stringValue else { return MCPResult.error("Required parameter 'one_time_code_id' is missing") } + guard let active = iapNullableActive(params.arguments?["active"]) else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } return try await patchIAPActiveResource( endpoint: "/v1/inAppPurchaseOfferCodeOneTimeUseCodes/\(try ASCPathSegment.encode(oneTimeCodeId))", type: "inAppPurchaseOfferCodeOneTimeUseCodes", id: oneTimeCodeId, - active: params.arguments?["active"]?.boolValue, + active: active, key: "one_time_code" ) } @@ -526,7 +604,7 @@ extension InAppPurchasesWorker { endpoint: "/v1/inAppPurchaseOfferCodeOneTimeUseCodes/\(try ASCPathSegment.encode(oneTimeCodeId))", type: "inAppPurchaseOfferCodeOneTimeUseCodes", id: oneTimeCodeId, - active: false, + active: .value(false), key: "one_time_code" ) } @@ -588,11 +666,14 @@ extension InAppPurchasesWorker { guard let customCodeId = params.arguments?["custom_code_id"]?.stringValue else { return MCPResult.error("Required parameter 'custom_code_id' is missing") } + guard let active = iapNullableActive(params.arguments?["active"]) else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } return try await patchIAPActiveResource( endpoint: "/v1/inAppPurchaseOfferCodeCustomCodes/\(try ASCPathSegment.encode(customCodeId))", type: "inAppPurchaseOfferCodeCustomCodes", id: customCodeId, - active: params.arguments?["active"]?.boolValue, + active: active, key: "custom_code" ) } @@ -605,7 +686,7 @@ extension InAppPurchasesWorker { endpoint: "/v1/inAppPurchaseOfferCodeCustomCodes/\(try ASCPathSegment.encode(customCodeId))", type: "inAppPurchaseOfferCodeCustomCodes", id: customCodeId, - active: false, + active: .value(false), key: "custom_code" ) } @@ -622,7 +703,7 @@ extension InAppPurchasesWorker { do { let response: PassthroughAPIResponse var query = defaultQuery - query["limit"] = String(clampedIAPLimit(params.arguments?["limit"]?.intValue, defaultValue: 25, max: 200)) + query["limit"] = String(try validatedCommerceLimit(params.arguments?["limit"], defaultValue: 25, maximum: 200)) if let nextUrl = try paginationURL(from: params.arguments?["next_url"]) { response = try await httpClient.getPage( nextUrl, @@ -662,32 +743,115 @@ extension InAppPurchasesWorker { } private func postIAPPassthroughResource(endpoint: String, body: [String: Any], key: String) async throws -> CallTool.Result { + guard let requestData = body["data"] as? [String: Any], + let expectedType = requestData["type"] as? String else { + return MCPResult.error("Failed to create \(key): request resource type is missing") + } + + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: body) + } catch { + return MCPResult.error(error, prefix: "Failed to encode \(key)") + } + + let responseData: Data + do { + responseData = try await httpClient.post(endpoint, body: data) + } catch { + return MCPResult.error(error, prefix: "Failed to create \(key)") + } + do { - let data = try JSONSerialization.data(withJSONObject: body) - let responseData = try await httpClient.post(endpoint, body: data) let response = try JSONDecoder().decode(PassthroughAPIResponse.self, from: responseData) + guard let responseType = response.data.iapResourceType, + let responseID = response.data.iapResourceId else { + throw ASCError.parsing("POST response resource identity does not match the request contract") + } + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: responseType, + id: responseID, + expectedType: expectedType, + context: "IAP commerce POST response" + ) return MCPResult.jsonObject(["success": true, key: formatIAPGenericResource(response.data)]) } catch { - return MCPResult.error("Failed to create \(key): \(error.localizedDescription)") + return MCPResult.error( + iapCommittedUnverifiedMutation(method: "POST", statusCode: 201, error: error), + prefix: "Failed to verify created \(key)" + ) } } - private func patchIAPActiveResource(endpoint: String, type: String, id: String, active: Bool?, key: String) async throws -> CallTool.Result { - var data: [String: Any] = [ + private func patchIAPActiveResource(endpoint: String, type: String, id: String, active: ASCNullable, key: String) async throws -> CallTool.Result { + let encodedActive: Any + switch active { + case .value(let value): encodedActive = value + case .null: encodedActive = NSNull() + } + let data: [String: Any] = [ "type": type, - "id": id + "id": id, + "attributes": ["active": encodedActive] ] - if let active { - data["attributes"] = ["active": active] + + let body: Data + do { + body = try JSONSerialization.data(withJSONObject: ["data": data]) + } catch { + return MCPResult.error(error, prefix: "Failed to encode \(key)") + } + + let responseData: Data + do { + responseData = try await httpClient.patch(endpoint, body: body) + } catch { + return MCPResult.error(error, prefix: "Failed to update \(key)") } + do { - let body = try JSONSerialization.data(withJSONObject: ["data": data]) - let responseData = try await httpClient.patch(endpoint, body: body) let response = try JSONDecoder().decode(PassthroughAPIResponse.self, from: responseData) + guard let responseType = response.data.iapResourceType, + let responseID = response.data.iapResourceId else { + throw ASCError.parsing("PATCH response resource identity does not match the request contract") + } + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: responseType, + id: responseID, + expectedType: type, + expectedID: id, + context: "IAP commerce PATCH response" + ) return MCPResult.jsonObject(["success": true, key: formatIAPGenericResource(response.data)]) } catch { - return MCPResult.error("Failed to update \(key): \(error.localizedDescription)") + return MCPResult.error( + iapCommittedUnverifiedMutation(method: "PATCH", statusCode: 200, error: error), + prefix: "Failed to verify updated \(key)" + ) + } + } + + private func iapCommittedUnverifiedMutation(method: String, statusCode: Int, error: Error) -> ASCError { + let cause = error as? ASCError ?? .parsing(error.localizedDescription) + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } + + private func iapNullableActive(_ value: Value?) -> ASCNullable? { + guard let value else { + return nil } + if case .null = value { + return .null + } + guard let bool = value.boolValue else { + return nil + } + return .value(bool) } private func iapPricePointQuery(limit: Int) -> [String: String] { @@ -732,10 +896,6 @@ extension InAppPurchasesWorker { ] } - private func clampedIAPLimit(_ value: Int?, defaultValue: Int, max: Int) -> Int { - min(Swift.max(value ?? defaultValue, 1), max) - } - private func appendIAPNext(_ links: JSONValue?, to result: inout [String: Any]) { if let next = links?.objectValue?["next"]?.stringValue { result["next_url"] = next diff --git a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3ToolDefinitions.swift b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3ToolDefinitions.swift index a1e08bd..8537667 100644 --- a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+V3ToolDefinitions.swift @@ -13,18 +13,18 @@ extension InAppPurchasesWorker { iapSimpleTool("iap_list_offer_codes", "List IAP offer codes", ["iap_id": "In-app purchase ID", "limit": "Max results", "next_url": "Pagination URL"], ["iap_id"]), iapSimpleTool("iap_get_offer_code", "Get an IAP offer code", ["offer_code_id": "IAP offer code ID"], ["offer_code_id"]), iapSimpleTool("iap_create_offer_code", "Create an IAP offer code", ["iap_id": "In-app purchase ID", "name": "Offer code name", "customer_eligibilities": "Array of NON_SPENDER, ACTIVE_SPENDER, CHURNED_SPENDER", "territory_ids": "Array of territory IDs", "price_point_ids": "Array of IAP price point IDs"], ["iap_id", "name", "customer_eligibilities", "territory_ids", "price_point_ids"]), - iapSimpleTool("iap_update_offer_code", "Update an IAP offer code", ["offer_code_id": "IAP offer code ID", "active": "Whether active"], ["offer_code_id"]), + iapSimpleTool("iap_update_offer_code", "Update or clear an IAP offer code active state", ["offer_code_id": "IAP offer code ID", "active": "Whether active, or null to clear"], ["offer_code_id", "active"]), iapSimpleTool("iap_deactivate_offer_code", "Deactivate an IAP offer code", ["offer_code_id": "IAP offer code ID"], ["offer_code_id"]), iapSimpleTool("iap_list_offer_code_prices", "List IAP offer code prices", ["offer_code_id": "IAP offer code ID", "territory_id": "Optional territory filter", "limit": "Max results", "next_url": "Pagination URL"], ["offer_code_id"]), iapSimpleTool("iap_generate_one_time_codes", "Generate one-time IAP offer codes", ["offer_code_id": "IAP offer code ID", "number_of_codes": "Number of codes", "expiration_date": "Expiration date", "environment": "Optional environment"], ["offer_code_id", "number_of_codes", "expiration_date"]), iapSimpleTool("iap_list_one_time_codes", "List one-time IAP offer code batches", ["offer_code_id": "IAP offer code ID", "limit": "Max results", "next_url": "Pagination URL"], ["offer_code_id"]), iapSimpleTool("iap_get_one_time_code", "Get one-time IAP offer code batch", ["one_time_code_id": "One-time code resource ID"], ["one_time_code_id"]), - iapSimpleTool("iap_update_one_time_code", "Update one-time IAP offer code batch", ["one_time_code_id": "One-time code resource ID", "active": "Whether active"], ["one_time_code_id"]), + iapSimpleTool("iap_update_one_time_code", "Update or clear a one-time IAP offer code batch active state", ["one_time_code_id": "One-time code resource ID", "active": "Whether active, or null to clear"], ["one_time_code_id", "active"]), iapSimpleTool("iap_deactivate_one_time_code", "Deactivate one-time IAP offer code batch", ["one_time_code_id": "One-time code resource ID"], ["one_time_code_id"]), iapSimpleTool("iap_get_one_time_code_values", "Get generated one-time IAP offer code values as lossless CSV", ["one_time_code_id": "One-time code resource ID"], ["one_time_code_id"]), iapSimpleTool("iap_create_custom_code", "Create a custom IAP offer code", ["offer_code_id": "IAP offer code ID", "custom_code": "Custom code", "number_of_codes": "Number of codes", "expiration_date": "Optional expiration date"], ["offer_code_id", "custom_code", "number_of_codes"]), iapSimpleTool("iap_get_custom_code", "Get custom IAP offer code", ["custom_code_id": "Custom code ID"], ["custom_code_id"]), - iapSimpleTool("iap_update_custom_code", "Update custom IAP offer code", ["custom_code_id": "Custom code ID", "active": "Whether active"], ["custom_code_id"]), + iapSimpleTool("iap_update_custom_code", "Update or clear a custom IAP offer code active state", ["custom_code_id": "Custom code ID", "active": "Whether active, or null to clear"], ["custom_code_id", "active"]), iapSimpleTool("iap_deactivate_custom_code", "Deactivate custom IAP offer code", ["custom_code_id": "Custom code ID"], ["custom_code_id"]) ] } @@ -57,8 +57,33 @@ extension InAppPurchasesWorker { "type": .string(type), "description": .string(description) ] + if key == "limit" { + property["minimum"] = .int(1) + property["maximum"] = .int(name == "iap_list_price_point_equalizations" ? 8000 : 200) + } if type == "array" { - property["items"] = .object(["type": .string("string")]) + var items: [String: Value] = ["type": .string("string")] + if key == "customer_eligibilities" { + items["enum"] = .array([ + .string("NON_SPENDER"), + .string("ACTIVE_SPENDER"), + .string("CHURNED_SPENDER") + ]) + property["minItems"] = .int(1) + property["uniqueItems"] = .bool(true) + } + property["items"] = .object(items) + } + if key == "active", [ + "iap_update_offer_code", + "iap_update_one_time_code", + "iap_update_custom_code" + ].contains(name) { + property["type"] = .array([.string("boolean"), .string("null")]) + } + if name == "iap_generate_one_time_codes", key == "environment" { + property["type"] = .array([.string("string"), .string("null")]) + property["enum"] = .array([.string("PRODUCTION"), .string("SANDBOX"), .null]) } return (key, Value.object(property)) }) diff --git a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+VersionedHandlers.swift b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+VersionedHandlers.swift index fef1aec..4027655 100644 --- a/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+VersionedHandlers.swift +++ b/Sources/asc-mcp/Workers/InAppPurchasesWorker/InAppPurchasesWorker+VersionedHandlers.swift @@ -1289,11 +1289,11 @@ extension InAppPurchasesWorker { private func iapVersionedMutationOutcomeIsUnknown(_ error: Error) -> Bool { guard let error = error as? ASCError else { return true } switch error { - case .network, .deleteOutcomeUnknown: + case .network, .deleteOutcomeUnknown, .mutationOutcomeUnknown: return true case .api(_, let statusCode), .apiResponse(_, let statusCode): return statusCode == 408 || (500...599).contains(statusCode) - case .deleteCommittedUnverified: + case .deleteCommittedUnverified, .mutationCommittedUnverified: return true case .authentication, .configuration, .parsing: return false diff --git a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift index 6d960ea..3327ae3 100644 --- a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift @@ -19,7 +19,7 @@ extension IntroductoryOffersWorker { let response: ASCIntroductoryOffersResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/introductoryOffers" var query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let territory = arguments["filter_territory"]?.stringValue { query["filter[territory]"] = territory @@ -150,10 +150,7 @@ extension IntroductoryOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create introductory offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create introductory offer") } } @@ -168,12 +165,17 @@ extension IntroductoryOffersWorker { ) } + guard arguments.keys.contains("end_date") else { + return MCPResult.error("Required parameter 'end_date' is missing") + } + do { + let endDate = try nullableIntroductoryOfferDate("end_date", from: arguments) let request = UpdateIntroductoryOfferRequest( data: UpdateIntroductoryOfferRequest.UpdateData( id: introOfferId, attributes: UpdateIntroductoryOfferRequest.Attributes( - endDate: arguments["end_date"]?.stringValue + endDate: endDate ) ) ) @@ -194,10 +196,7 @@ extension IntroductoryOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update introductory offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update introductory offer") } } diff --git a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift index 42ce6d5..436b8c0 100644 --- a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift @@ -17,7 +17,9 @@ extension IntroductoryOffersWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "filter_territory": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/MetricsWorker/MetricsWorker+Handlers.swift b/Sources/asc-mcp/Workers/MetricsWorker/MetricsWorker+Handlers.swift index b413c16..a7deaa0 100644 --- a/Sources/asc-mcp/Workers/MetricsWorker/MetricsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/MetricsWorker/MetricsWorker+Handlers.swift @@ -119,11 +119,7 @@ extension MetricsWorker { queryParams["filter[diagnosticType]"] = diagnosticTypes.queryValue } - if let limit = arguments["limit"]?.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } + queryParams["limit"] = String(try metricsLimit(arguments["limit"], defaultValue: 25)) if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -160,10 +156,7 @@ extension MetricsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to list build diagnostic signatures: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to list build diagnostic signatures") } } @@ -181,8 +174,8 @@ extension MetricsWorker { do { var queryParams: [String: String] = [:] - if let limit = arguments["limit"]?.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) + if arguments["limit"] != nil { + queryParams["limit"] = String(try metricsLimit(arguments["limit"], defaultValue: 25)) } let data = try await httpClient.getRaw( @@ -202,11 +195,16 @@ extension MetricsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to get diagnostic logs: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to get diagnostic logs") + } + } + + private func metricsLimit(_ value: Value?, defaultValue: Int) throws -> Int { + guard let value else { return defaultValue } + guard let limit = value.intValue, (1...200).contains(limit) else { + throw ASCError.parsing("limit must be an integer from 1 through 200") } + return limit } // MARK: - Formatting @@ -348,6 +346,13 @@ extension MetricsWorker { private func formatMetric(_ metric: ASCMetric) -> [String: Any] { var dict: [String: Any] = [:] dict["identifier"] = metric.identifier.jsonSafe + dict["goal_keys"] = (metric.goalKeys ?? []).map { goalKey in + [ + "goal_key": goalKey.goalKey.jsonSafe, + "lower_bound": goalKey.lowerBound.jsonSafe, + "upper_bound": goalKey.upperBound.jsonSafe + ] + } if let unit = metric.unit { dict["unit"] = [ "identifier": unit.identifier.jsonSafe, diff --git a/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+Handlers.swift b/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+Handlers.swift index 1baa8b9..c29b909 100644 --- a/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+Handlers.swift @@ -19,7 +19,7 @@ extension OfferCodesWorker { let response: ASCOfferCodesResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/offerCodes" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -236,10 +236,7 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create offer code: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create offer code") } } @@ -254,12 +251,16 @@ extension OfferCodesWorker { ) } + guard let active = nullableOfferCodeActive(arguments["active"]) else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } + do { let request = UpdateOfferCodeRequest( data: UpdateOfferCodeRequest.UpdateData( id: offerCodeId, attributes: UpdateOfferCodeRequest.Attributes( - active: arguments["active"]?.boolValue + active: active ) ) ) @@ -280,10 +281,7 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update offer code: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update offer code") } } @@ -303,7 +301,7 @@ extension OfferCodesWorker { data: UpdateOfferCodeRequest.UpdateData( id: offerCodeId, attributes: UpdateOfferCodeRequest.Attributes( - active: false + active: .value(false) ) ) ) @@ -325,10 +323,7 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to deactivate offer code: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to deactivate offer code") } } @@ -347,7 +342,7 @@ extension OfferCodesWorker { let response: ASCOfferCodePricesResponse let endpoint = "/v1/subscriptionOfferCodes/\(try ASCPathSegment.encode(offerCodeId))/prices" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -450,10 +445,7 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to generate one-time codes: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to generate one-time codes") } } @@ -472,7 +464,7 @@ extension OfferCodesWorker { let response: ASCOneTimeUseCodesResponse let endpoint = "/v1/subscriptionOfferCodes/\(try ASCPathSegment.encode(offerCodeId))/oneTimeUseCodes" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -562,10 +554,7 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create custom code: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create custom code") } } @@ -619,7 +608,7 @@ extension OfferCodesWorker { data: UpdateOfferCodeCustomCodeRequest.UpdateData( id: customCodeId, attributes: UpdateOfferCodeCustomCodeRequest.Attributes( - active: false + active: .value(false) ) ) ) @@ -641,11 +630,21 @@ extension OfferCodesWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to deactivate custom code: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to deactivate custom code") + } + } + + private func nullableOfferCodeActive(_ value: Value?) -> ASCNullable? { + guard let value else { + return nil + } + if case .null = value { + return .null + } + guard let bool = value.boolValue else { + return nil } + return .value(bool) } // MARK: - Formatting diff --git a/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+ToolDefinitions.swift index 3873122..ab30de1 100644 --- a/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/OfferCodesWorker/OfferCodesWorker+ToolDefinitions.swift @@ -17,7 +17,9 @@ extension OfferCodesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -141,7 +143,9 @@ extension OfferCodesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -198,7 +202,9 @@ extension OfferCodesWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+Handlers.swift b/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+Handlers.swift index 66bd8f8..7e3a163 100644 --- a/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+Handlers.swift @@ -9,9 +9,19 @@ extension PreReleaseVersionsWorker { func listPreReleaseVersions(_ params: CallTool.Parameters) async throws -> CallTool.Result { let arguments = params.arguments + let limit: Int + if let value = arguments?["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let response: ASCPreReleaseVersionsResponse - var queryParams: [String: String] = [:] + var queryParams: [String: String] = ["limit": String(limit)] if let appId = arguments?["app_id"]?.stringValue { queryParams["filter[app]"] = appId @@ -32,12 +42,6 @@ extension PreReleaseVersionsWorker { if let sort = arguments?["sort"]?.stringValue { queryParams["sort"] = sort } - if let limit = arguments?["limit"]?.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( nextUrl, @@ -125,10 +129,19 @@ extension PreReleaseVersionsWorker { ) } + let limit: Int + if let value = arguments["limit"] { + guard let parsed = value.intValue, (1...200).contains(parsed) else { + return MCPResult.error("'limit' must be an integer from 1 through 200") + } + limit = parsed + } else { + limit = 25 + } + do { let endpoint = "/v1/preReleaseVersions/\(try ASCPathSegment.encode(preReleaseVersionId))/builds" - let limit = arguments["limit"]?.intValue ?? 25 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let queryParams = ["limit": String(limit)] let response: ASCBuildsResponse if let nextUrl = try paginationURL(from: arguments["next_url"]) { diff --git a/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+ToolDefinitions.swift index 5336355..423b66f 100644 --- a/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/PreReleaseVersionsWorker/PreReleaseVersionsWorker+ToolDefinitions.swift @@ -77,7 +77,10 @@ extension PreReleaseVersionsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(25) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+Handlers.swift b/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+Handlers.swift index 300d4ed..717084d 100644 --- a/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+Handlers.swift @@ -20,8 +20,8 @@ extension PricingWorker { do { let endpoint = "/v1/territories" - let limit = arguments?["limit"]?.intValue ?? 200 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let limit = try validatedListLimit(arguments?["limit"], defaultValue: 200) + let queryParams = ["limit": String(limit)] let response: ASCTerritoriesResponse if let nextUrl = try paginationURL(from: arguments?["next_url"]) { @@ -112,11 +112,12 @@ extension PricingWorker { do { let response: ASCAppPricePointsV3Response + let limit = try validatedListLimit(arguments["limit"], defaultValue: 50) let endpoint = "/v1/apps/\(try ASCPathSegment.encode(appId))/appPricePoints" var queryParams: [String: String] = [ "include": "territory", - "limit": String(min(max(arguments["limit"]?.intValue ?? 50, 1), 200)) + "limit": String(limit) ] if let territoryId = arguments["territory_id"]?.stringValue { queryParams["filter[territory]"] = territoryId @@ -182,8 +183,18 @@ extension PricingWorker { } do { - let manualPricesLimit = min(max(arguments["manual_prices_limit"]?.intValue ?? 50, 1), 50) - let automaticPricesLimit = min(max(arguments["automatic_prices_limit"]?.intValue ?? 50, 1), 50) + let manualPricesLimit = try validatedListLimit( + arguments["manual_prices_limit"], + defaultValue: 50, + maximum: 50, + field: "manual_prices_limit" + ) + let automaticPricesLimit = try validatedListLimit( + arguments["automatic_prices_limit"], + defaultValue: 50, + maximum: 50, + field: "automatic_prices_limit" + ) let response: ASCAppPriceScheduleResponse = try await httpClient.get( "/v1/apps/\(try ASCPathSegment.encode(appId))/appPriceSchedule", parameters: [ @@ -350,6 +361,14 @@ extension PricingWorker { body: request, as: ASCAppPriceScheduleResponse.self ) + try validateAcceptedPricingMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appPriceSchedules", + method: "POST", + statusCode: 201, + context: "Apple price schedule create response" + ) var schedule: [String: Any] = [ "id": response.data.id, @@ -369,10 +388,7 @@ extension PricingWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to set price schedule: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to set price schedule") } } @@ -392,6 +408,7 @@ extension PricingWorker { do { let response: ASCTerritoryAvailabilitiesResponse + let limit = try validatedListLimit(arguments["limit"], defaultValue: 50) let availability: ASCAppAvailabilityV2Response = try await httpClient.get( "/v1/apps/\(try ASCPathSegment.encode(appId))/appAvailabilityV2", as: ASCAppAvailabilityV2Response.self @@ -399,7 +416,7 @@ extension PricingWorker { let endpoint = "/v2/appAvailabilities/\(try ASCPathSegment.encode(availability.data.id))/territoryAvailabilities" let queryParams = [ "include": "territory", - "limit": String(min(max(arguments["limit"]?.intValue ?? 50, 1), 200)) + "limit": String(limit) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -518,6 +535,14 @@ extension PricingWorker { body: request, as: ASCAppAvailabilityV2Response.self ) + try validateAcceptedPricingMutationResource( + type: response.data.type, + id: response.data.id, + expectedType: "appAvailabilities", + method: "POST", + statusCode: 201, + context: "Apple app availability create response" + ) let result: [String: Any] = [ "success": true, @@ -529,10 +554,7 @@ extension PricingWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to create app availability: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create app availability") } } @@ -585,11 +607,12 @@ extension PricingWorker { do { let response: ASCTerritoryAvailabilitiesResponse + let limit = try validatedListLimit(arguments["limit"], defaultValue: 50) let endpoint = "/v2/appAvailabilities/\(try ASCPathSegment.encode(availabilityId))/territoryAvailabilities" let queryParams = [ "include": "territory", - "limit": String(min(max(arguments["limit"]?.intValue ?? 50, 1), 200)) + "limit": String(limit) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -729,7 +752,12 @@ extension PricingWorker { return [:] } - let limit = min(max(arguments["territory_availabilities_limit"]?.intValue ?? 50, 1), 50) + let limit = try validatedListLimit( + arguments["territory_availabilities_limit"], + defaultValue: 50, + maximum: 50, + field: "territory_availabilities_limit" + ) return [ "include": "territoryAvailabilities", "limit[territoryAvailabilities]": String(limit) @@ -810,6 +838,21 @@ extension PricingWorker { return boolean } + private func validatedListLimit( + _ value: Value?, + defaultValue: Int, + maximum: Int = 200, + field: String = "limit" + ) throws -> Int { + guard let value else { + return defaultValue + } + guard let limit = value.intValue, (1...maximum).contains(limit) else { + throw PricingArgumentError("'\(field)' must be an integer from 1 through \(maximum)") + } + return limit + } + private func priceScheduleInputs(_ arguments: [String: Value]) throws -> [PriceScheduleInput] { let legacyPricePointId = arguments["price_point_id"]?.stringValue let manualPricesValue = arguments["manual_prices"] @@ -951,6 +994,32 @@ extension PricingWorker { return result } + private func validateAcceptedPricingMutationResource( + type: String, + id: String, + expectedType: String, + method: String, + statusCode: Int, + context: String + ) throws { + do { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: type, + id: id, + expectedType: expectedType, + context: context + ) + } catch { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + throw ASCError.mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } + } + } private struct PriceScheduleInput { diff --git a/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+ToolDefinitions.swift index fc3fa71..60fbf95 100644 --- a/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/PricingWorker/PricingWorker+ToolDefinitions.swift @@ -21,7 +21,10 @@ extension PricingWorker { "properties": .object([ "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 200, max: 200)") + "description": .string("Max results (default: 200, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -79,7 +82,10 @@ extension PricingWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 50, max: 200)") + "description": .string("Max results (default: 50, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), @@ -185,7 +191,10 @@ extension PricingWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 50, max: 200)") + "description": .string("Max results (default: 50, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), @@ -297,7 +306,10 @@ extension PricingWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 50, max: 200)") + "description": .string("Max results (default: 50, max: 200)"), + "minimum": .int(1), + "maximum": .int(200), + "default": .int(50) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+Handlers.swift b/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+Handlers.swift index 755311a..5a62ed6 100644 --- a/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+Handlers.swift @@ -290,7 +290,12 @@ extension PromotedPurchasesWorker { } catch { return promotedCreateFailure( - error: error, + error: promotedAcceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), phase: .acceptedResponse, appID: appId, visible: visible, @@ -402,7 +407,12 @@ extension PromotedPurchasesWorker { operation: "promoted_update", targetID: promotedPurchaseId, requestedArguments: promotedRequestedUpdateArguments(arguments), - error: error, + error: promotedAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), phase: .acceptedResponse ) } @@ -574,7 +584,12 @@ extension PromotedPurchasesWorker { appID: appID, requestedOrder: requestedOrder, preflightOrder: preflightOrder, - error: error, + error: promotedAcceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 204, + actualStatusCode: receipt.statusCode + ), phase: .acceptedResponse ) } @@ -653,16 +668,17 @@ extension PromotedPurchasesWorker { details: .object([ "deprecated": .bool(true), "tool": .string(tool), - "migration": .string("Use promoted_get to resolve the linked product, then use the matching IAP or subscription image tools."), + "migration": .string("Use promoted_get to resolve the linked product, select its current version, then use the matching active version-scoped IAP or subscription image tools."), "replacement_tools": .array([ - .string("iap_upload_image"), - .string("iap_get_image"), - .string("iap_list_images"), - .string("iap_delete_image"), - .string("subscriptions_upload_image"), - .string("subscriptions_get_image"), - .string("subscriptions_list_images"), - .string("subscriptions_delete_image") + .string("iap_get_version_image"), + .string("iap_list_version_images"), + .string("iap_upload_version_image"), + .string("iap_get_version_image_resource"), + .string("iap_delete_version_image"), + .string("subscriptions_list_version_images"), + .string("subscriptions_upload_version_image"), + .string("subscriptions_get_version_image"), + .string("subscriptions_delete_version_image") ]) ]) ) @@ -716,8 +732,8 @@ extension PromotedPurchasesWorker { guard let values = arguments[name]?.arrayValue else { throw PromotedPurchaseInputError("\(name) must be a JSON array of resource IDs") } - guard (1...200).contains(values.count) else { - throw PromotedPurchaseInputError("\(name) must contain between 1 and 200 resource IDs") + guard !values.isEmpty else { + throw PromotedPurchaseInputError("\(name) must contain at least one resource ID") } var identities = Set() return try values.enumerated().map { index, value in @@ -1335,6 +1351,29 @@ extension PromotedPurchasesWorker { ]) } + private func promotedAcceptedMutationError( + _ error: Error, + method: String, + expectedStatusCode: Int, + actualStatusCode: Int + ) -> ASCError { + let cause: ASCError + if let ascError = error as? ASCError { + if case .mutationCommittedUnverified = ascError { + return ascError + } + cause = ascError + } else { + cause = .parsing(Redactor.redact(error.localizedDescription)) + } + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: expectedStatusCode, + actualStatusCode: actualStatusCode, + cause: cause + ) + } + private func promotedDeleteFailure(targetID: String, error: Error) -> CallTool.Result { let deletionState: String let operationCommitState: String diff --git a/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+ToolDefinitions.swift index 54bee49..b2b5698 100644 --- a/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/PromotedPurchasesWorker/PromotedPurchasesWorker+ToolDefinitions.swift @@ -121,7 +121,6 @@ extension PromotedPurchasesWorker { "description": .string("Every current promoted purchase ID in the desired order"), "items": promotedCanonicalIDSchema("Promoted purchase ID"), "minItems": .int(1), - "maxItems": .int(200), "uniqueItems": .bool(true) ]) ]), @@ -175,17 +174,16 @@ extension PromotedPurchasesWorker { func uploadPromotedPurchaseImageTool() -> Tool { return Tool( name: "promoted_upload_image", - description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for iap_*_image or subscriptions_*_image tools without calling Apple.", + description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for active version-scoped IAP or subscription image tools without calling Apple.", inputSchema: .object([ "type": .string("object"), "properties": .object([ - "promoted_purchase_id": .object([ - "type": .string("string"), - "description": .string("Promoted purchase ID to upload image for") - ]), + "promoted_purchase_id": promotedCanonicalIDSchema("Promoted purchase ID to upload image for"), "file_path": .object([ "type": .string("string"), - "description": .string("Absolute path to the image file on disk") + "minLength": .int(1), + "pattern": .string(#"^\S(?:[\s\S]*\S)?$"#), + "description": .string("Non-empty file path retained only as migration context") ]) ]), "required": .array([.string("promoted_purchase_id"), .string("file_path")]), @@ -204,14 +202,11 @@ extension PromotedPurchasesWorker { func getPromotedPurchaseImageTool() -> Tool { return Tool( name: "promoted_get_image", - description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for iap_*_image or subscriptions_*_image tools without calling Apple.", + description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for active version-scoped IAP or subscription image tools without calling Apple.", inputSchema: .object([ "type": .string("object"), "properties": .object([ - "image_id": .object([ - "type": .string("string"), - "description": .string("Promoted purchase image ID") - ]) + "image_id": promotedCanonicalIDSchema("Promoted purchase image ID") ]), "required": .array([.string("image_id")]), "additionalProperties": .bool(false) @@ -222,14 +217,11 @@ extension PromotedPurchasesWorker { func deletePromotedPurchaseImageTool() -> Tool { return Tool( name: "promoted_delete_image", - description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for iap_*_image or subscriptions_*_image tools without calling Apple.", + description: "Deprecated: promoted purchase image endpoints are absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for active version-scoped IAP or subscription image tools without calling Apple.", inputSchema: .object([ "type": .string("object"), "properties": .object([ - "image_id": .object([ - "type": .string("string"), - "description": .string("Promoted purchase image ID to delete") - ]) + "image_id": promotedCanonicalIDSchema("Promoted purchase image ID to delete") ]), "required": .array([.string("image_id")]), "additionalProperties": .bool(false) @@ -247,14 +239,11 @@ extension PromotedPurchasesWorker { func getPromotedPurchaseImageForPurchaseTool() -> Tool { return Tool( name: "promoted_get_image_for_purchase", - description: "Deprecated: the promoted purchase image relationship is absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for product-scoped image tools without calling Apple.", + description: "Deprecated: the promoted purchase image relationship is absent from pinned executable OpenAPI 4.4.1. Returns migration guidance for active version-scoped IAP or subscription image tools without calling Apple.", inputSchema: .object([ "type": .string("object"), "properties": .object([ - "promoted_purchase_id": .object([ - "type": .string("string"), - "description": .string("Promoted purchase ID") - ]) + "promoted_purchase_id": promotedCanonicalIDSchema("Promoted purchase ID") ]), "required": .array([.string("promoted_purchase_id")]), "additionalProperties": .bool(false) diff --git a/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+Handlers.swift b/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+Handlers.swift index 5148284..874c3cc 100644 --- a/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+Handlers.swift @@ -19,7 +19,7 @@ extension PromotionalOffersWorker { let response: ASCPromotionalOffersResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/promotionalOffers" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -251,10 +251,7 @@ extension PromotionalOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create promotional offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create promotional offer") } } @@ -367,10 +364,7 @@ extension PromotionalOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update promotional offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update promotional offer") } } @@ -415,7 +409,7 @@ extension PromotionalOffersWorker { let response: ASCPromotionalOfferPricesResponse let endpoint = "/v1/subscriptionPromotionalOffers/\(try ASCPathSegment.encode(promotionalOfferId))/prices" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { diff --git a/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+ToolDefinitions.swift index 62d7f59..1980851 100644 --- a/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/PromotionalOffersWorker/PromotionalOffersWorker+ToolDefinitions.swift @@ -17,7 +17,9 @@ extension PromotionalOffersWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -162,7 +164,9 @@ extension PromotionalOffersWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+Handlers.swift b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+Handlers.swift index 4c42d6b..8d0a1ca 100644 --- a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+Handlers.swift @@ -13,17 +13,12 @@ extension ProvisioningWorker { let response: ASCBundleIdsResponse var queryParams: [String: String] = [:] - if let limitValue = arguments?["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - - if let platformValue = arguments?["filter_platform"], - let platform = platformValue.stringValue { - queryParams["filter[platform]"] = platform - } + queryParams["limit"] = String(try provisioningLimit(arguments?["limit"])) + queryParams["filter[platform]"] = try provisioningEnumList( + arguments?["filter_platform"], + name: "filter_platform", + allowedValues: Set(ProvisioningWorker.bundleIdPlatforms) + ) if let identifierValue = arguments?["filter_identifier"], let identifier = identifierValue.stringValue { @@ -45,10 +40,11 @@ extension ProvisioningWorker { queryParams["filter[id]"] = id } - if let sortValue = arguments?["sort"], - let sort = sortValue.stringValue { - queryParams["sort"] = sort - } + queryParams["sort"] = try provisioningEnumList( + arguments?["sort"], + name: "sort", + allowedValues: Set(ProvisioningWorker.bundleIdSortValues) + ) if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( @@ -77,6 +73,9 @@ extension ProvisioningWorker { if let next = response.links?.next { result["next_url"] = next } + if let total = response.meta?.paging?.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -138,9 +137,21 @@ extension ProvisioningWorker { isError: true ) } + guard ProvisioningWorker.bundleIdPlatforms.contains(platform) else { + return MCPResult.error("'platform' must be one of: \(ProvisioningWorker.bundleIdPlatforms.joined(separator: ", "))") + } - if arguments["seed_id"] != nil, arguments["seed_id"]?.stringValue == nil { - return MCPResult.error("'seed_id' must be a string") + let seedId: ASCNullable? + if let value = arguments["seed_id"] { + if value.isNull { + seedId = .null + } else if let string = value.stringValue { + seedId = .value(string) + } else { + return MCPResult.error("'seed_id' must be a string or null") + } + } else { + seedId = nil } do { @@ -150,7 +161,7 @@ extension ProvisioningWorker { name: name, identifier: identifier, platform: platform, - seedId: arguments["seed_id"]?.stringValue + nullableSeedId: seedId ) ) ) @@ -171,10 +182,7 @@ extension ProvisioningWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create bundle ID: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create bundle ID") } } @@ -214,22 +222,17 @@ extension ProvisioningWorker { let response: ASCDevicesResponse var queryParams: [String: String] = [:] - if let limitValue = arguments?["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - - if let platformValue = arguments?["filter_platform"], - let platform = platformValue.stringValue { - queryParams["filter[platform]"] = platform - } - - if let statusValue = arguments?["filter_status"], - let status = statusValue.stringValue { - queryParams["filter[status]"] = status - } + queryParams["limit"] = String(try provisioningLimit(arguments?["limit"])) + queryParams["filter[platform]"] = try provisioningEnumList( + arguments?["filter_platform"], + name: "filter_platform", + allowedValues: Set(ProvisioningWorker.bundleIdPlatforms) + ) + queryParams["filter[status]"] = try provisioningEnumList( + arguments?["filter_status"], + name: "filter_status", + allowedValues: Set(ProvisioningWorker.deviceStatuses) + ) if let nameValue = arguments?["filter_name"], let name = nameValue.stringValue { @@ -246,10 +249,11 @@ extension ProvisioningWorker { queryParams["filter[id]"] = id } - if let sortValue = arguments?["sort"], - let sort = sortValue.stringValue { - queryParams["sort"] = sort - } + queryParams["sort"] = try provisioningEnumList( + arguments?["sort"], + name: "sort", + allowedValues: Set(ProvisioningWorker.deviceSortValues) + ) if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( @@ -278,6 +282,9 @@ extension ProvisioningWorker { if let next = response.links?.next { result["next_url"] = next } + if let total = response.meta?.paging?.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -304,6 +311,9 @@ extension ProvisioningWorker { isError: true ) } + guard ProvisioningWorker.bundleIdPlatforms.contains(platform) else { + return MCPResult.error("'platform' must be one of: \(ProvisioningWorker.bundleIdPlatforms.joined(separator: ", "))") + } do { let request = RegisterDeviceRequest( @@ -332,10 +342,7 @@ extension ProvisioningWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to register device: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to register device") } } @@ -351,15 +358,33 @@ extension ProvisioningWorker { ) } - if arguments["name"] != nil, arguments["name"]?.stringValue == nil { - return MCPResult.error("'name' must be a string") + let name: ASCNullable? + if let value = arguments["name"] { + if value.isNull { + name = .null + } else if let string = value.stringValue { + name = .value(string) + } else { + return MCPResult.error("'name' must be a string or null") + } + } else { + name = nil } - if arguments["status"] != nil, arguments["status"]?.stringValue == nil { - return MCPResult.error("'status' must be a string") + + let status: ASCNullable? + if let value = arguments["status"] { + if value.isNull { + status = .null + } else if let string = value.stringValue, + ProvisioningWorker.deviceStatuses.contains(string) { + status = .value(string) + } else { + return MCPResult.error("'status' must be null or one of: \(ProvisioningWorker.deviceStatuses.joined(separator: ", "))") + } + } else { + status = nil } - let name = arguments["name"]?.stringValue - let status = arguments["status"]?.stringValue guard name != nil || status != nil else { return MCPResult.error("Provide at least one update field: name or status") } @@ -369,8 +394,8 @@ extension ProvisioningWorker { data: UpdateDeviceRequest.UpdateDeviceData( id: deviceId, attributes: UpdateDeviceRequest.UpdateDeviceAttributes( - name: name, - status: status + nullableName: name, + nullableStatus: status ) ) ) @@ -391,10 +416,7 @@ extension ProvisioningWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update device: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update device") } } @@ -407,17 +429,12 @@ extension ProvisioningWorker { let response: ASCCertificatesResponse var queryParams: [String: String] = [:] - if let limitValue = arguments?["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - - if let typeValue = arguments?["filter_type"], - let type = typeValue.stringValue { - queryParams["filter[certificateType]"] = type - } + queryParams["limit"] = String(try provisioningLimit(arguments?["limit"])) + queryParams["filter[certificateType]"] = try provisioningEnumList( + arguments?["filter_type"], + name: "filter_type", + allowedValues: Set(ProvisioningWorker.certificateTypes) + ) if let displayNameValue = arguments?["filter_display_name"], let displayName = displayNameValue.stringValue { @@ -434,10 +451,11 @@ extension ProvisioningWorker { queryParams["filter[id]"] = id } - if let sortValue = arguments?["sort"], - let sort = sortValue.stringValue { - queryParams["sort"] = sort - } + queryParams["sort"] = try provisioningEnumList( + arguments?["sort"], + name: "sort", + allowedValues: Set(ProvisioningWorker.certificateSortValues) + ) if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( @@ -466,6 +484,9 @@ extension ProvisioningWorker { if let next = response.links?.next { result["next_url"] = next } + if let total = response.meta?.paging?.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -486,22 +507,17 @@ extension ProvisioningWorker { let response: ASCProfilesResponse var queryParams: [String: String] = [:] - if let limitValue = arguments?["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } - - if let typeValue = arguments?["filter_profile_type"], - let type = typeValue.stringValue { - queryParams["filter[profileType]"] = type - } - - if let stateValue = arguments?["filter_profile_state"], - let state = stateValue.stringValue { - queryParams["filter[profileState]"] = state - } + queryParams["limit"] = String(try provisioningLimit(arguments?["limit"])) + queryParams["filter[profileType]"] = try provisioningEnumList( + arguments?["filter_profile_type"], + name: "filter_profile_type", + allowedValues: Set(ProvisioningWorker.profileTypes) + ) + queryParams["filter[profileState]"] = try provisioningEnumList( + arguments?["filter_profile_state"], + name: "filter_profile_state", + allowedValues: Set(ProvisioningWorker.profileStates) + ) if let nameValue = arguments?["filter_name"], let name = nameValue.stringValue { @@ -513,10 +529,11 @@ extension ProvisioningWorker { queryParams["filter[id]"] = id } - if let sortValue = arguments?["sort"], - let sort = sortValue.stringValue { - queryParams["sort"] = sort - } + queryParams["sort"] = try provisioningEnumList( + arguments?["sort"], + name: "sort", + allowedValues: Set(ProvisioningWorker.profileSortValues) + ) if let nextUrl = try paginationURL(from: arguments?["next_url"]) { response = try await httpClient.getPage( @@ -545,6 +562,9 @@ extension ProvisioningWorker { if let next = response.links?.next { result["next_url"] = next } + if let total = response.meta?.paging?.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -701,6 +721,9 @@ extension ProvisioningWorker { isError: true ) } + guard ProvisioningWorker.profileTypes.contains(profileType) else { + return MCPResult.error("'profile_type' must be one of: \(ProvisioningWorker.profileTypes.joined(separator: ", "))") + } let certificateIds = certIdsArray.compactMap(\.stringValue) guard certificateIds.count == certIdsArray.count, @@ -775,10 +798,7 @@ extension ProvisioningWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create profile: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create profile") } } @@ -800,12 +820,7 @@ extension ProvisioningWorker { let response: ASCBundleIdCapabilitiesResponse var queryParams: [String: String] = [:] - if let limitValue = arguments["limit"], - let limit = limitValue.intValue { - queryParams["limit"] = String(min(max(limit, 1), 200)) - } else { - queryParams["limit"] = "25" - } + queryParams["limit"] = String(try provisioningLimit(arguments["limit"])) if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -834,6 +849,9 @@ extension ProvisioningWorker { if let next = response.links?.next { result["next_url"] = next } + if let total = response.meta?.paging?.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -858,25 +876,23 @@ extension ProvisioningWorker { isError: true ) } + guard ProvisioningWorker.capabilityTypes.contains(capabilityType) else { + return MCPResult.error("'capability_type' must be one of: \(ProvisioningWorker.capabilityTypes.joined(separator: ", "))") + } + let settings: ASCNullable<[CapabilitySetting]>? do { - var settings: [CapabilitySetting]? = nil - if let settingsValue = arguments["settings"] { - guard let settingsString = settingsValue.stringValue, - let settingsData = settingsString.data(using: .utf8) else { - return CallTool.Result( - content: [MCPContent.text("Error: settings must be a JSON string containing an array")], - isError: true - ) - } - settings = try JSONDecoder().decode([CapabilitySetting].self, from: settingsData) - } + settings = try capabilitySettings(arguments["settings"]) + } catch { + return MCPResult.error(error, prefix: "Invalid capability settings") + } + do { let request = EnableCapabilityRequest( data: EnableCapabilityRequest.EnableCapabilityData( attributes: EnableCapabilityRequest.EnableCapabilityAttributes( capabilityType: capabilityType, - settings: settings + nullableSettings: settings ), relationships: EnableCapabilityRequest.EnableCapabilityRelationships( bundleId: EnableCapabilityRequest.EnableCapabilityBundleIdData( @@ -904,10 +920,7 @@ extension ProvisioningWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to enable capability: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to enable capability") } } @@ -944,10 +957,10 @@ extension ProvisioningWorker { return [ "id": bundleId.id, "type": bundleId.type, - "name": bundleId.attributes.name.jsonSafe, - "identifier": bundleId.attributes.identifier.jsonSafe, - "platform": bundleId.attributes.platform.jsonSafe, - "seedId": bundleId.attributes.seedId.jsonSafe + "name": (bundleId.attributes?.name).jsonSafe, + "identifier": (bundleId.attributes?.identifier).jsonSafe, + "platform": (bundleId.attributes?.platform).jsonSafe, + "seedId": (bundleId.attributes?.seedId).jsonSafe ] } @@ -955,13 +968,13 @@ extension ProvisioningWorker { return [ "id": device.id, "type": device.type, - "name": device.attributes.name.jsonSafe, - "platform": device.attributes.platform.jsonSafe, - "udid": device.attributes.udid.jsonSafe, - "deviceClass": device.attributes.deviceClass.jsonSafe, - "status": device.attributes.status.jsonSafe, - "model": device.attributes.model.jsonSafe, - "addedDate": device.attributes.addedDate.jsonSafe + "name": (device.attributes?.name).jsonSafe, + "platform": (device.attributes?.platform).jsonSafe, + "udid": (device.attributes?.udid).jsonSafe, + "deviceClass": (device.attributes?.deviceClass).jsonSafe, + "status": (device.attributes?.status).jsonSafe, + "model": (device.attributes?.model).jsonSafe, + "addedDate": (device.attributes?.addedDate).jsonSafe ] } @@ -969,16 +982,16 @@ extension ProvisioningWorker { var result: [String: Any] = [ "id": cert.id, "type": cert.type, - "name": cert.attributes.name.jsonSafe, - "certificateType": cert.attributes.certificateType.jsonSafe, - "displayName": cert.attributes.displayName.jsonSafe, - "serialNumber": cert.attributes.serialNumber.jsonSafe, - "platform": cert.attributes.platform.jsonSafe, - "expirationDate": cert.attributes.expirationDate.jsonSafe, - "activated": cert.attributes.activated.jsonSafe + "name": (cert.attributes?.name).jsonSafe, + "certificateType": (cert.attributes?.certificateType).jsonSafe, + "displayName": (cert.attributes?.displayName).jsonSafe, + "serialNumber": (cert.attributes?.serialNumber).jsonSafe, + "platform": (cert.attributes?.platform).jsonSafe, + "expirationDate": (cert.attributes?.expirationDate).jsonSafe, + "activated": (cert.attributes?.activated).jsonSafe ] if includeContent { - result["certificateContent"] = cert.attributes.certificateContent.jsonSafe + result["certificateContent"] = (cert.attributes?.certificateContent).jsonSafe } return result } @@ -987,16 +1000,16 @@ extension ProvisioningWorker { var result: [String: Any] = [ "id": profile.id, "type": profile.type, - "name": profile.attributes.name.jsonSafe, - "platform": profile.attributes.platform.jsonSafe, - "profileType": profile.attributes.profileType.jsonSafe, - "profileState": profile.attributes.profileState.jsonSafe, - "uuid": profile.attributes.uuid.jsonSafe, - "createdDate": profile.attributes.createdDate.jsonSafe, - "expirationDate": profile.attributes.expirationDate.jsonSafe + "name": (profile.attributes?.name).jsonSafe, + "platform": (profile.attributes?.platform).jsonSafe, + "profileType": (profile.attributes?.profileType).jsonSafe, + "profileState": (profile.attributes?.profileState).jsonSafe, + "uuid": (profile.attributes?.uuid).jsonSafe, + "createdDate": (profile.attributes?.createdDate).jsonSafe, + "expirationDate": (profile.attributes?.expirationDate).jsonSafe ] if includeContent { - result["profileContent"] = profile.attributes.profileContent.jsonSafe + result["profileContent"] = (profile.attributes?.profileContent).jsonSafe } return result } @@ -1005,10 +1018,10 @@ extension ProvisioningWorker { var result: [String: Any] = [ "id": capability.id, "type": capability.type, - "capabilityType": capability.attributes.capabilityType.jsonSafe + "capabilityType": (capability.attributes?.capabilityType).jsonSafe ] - if let settings = capability.attributes.settings, !settings.isEmpty { + if let settings = capability.attributes?.settings { let formattedSettings = settings.map { setting -> [String: Any] in var s: [String: Any] = [ "key": setting.key.jsonSafe, @@ -1029,7 +1042,7 @@ extension ProvisioningWorker { if let minInstances = setting.minInstances { s["minInstances"] = minInstances } - if let options = setting.options, !options.isEmpty { + if let options = setting.options { s["options"] = options.map { option -> [String: Any] in var o: [String: Any] = [ "key": option.key.jsonSafe, @@ -1057,4 +1070,181 @@ extension ProvisioningWorker { return result } + + private func provisioningLimit(_ value: Value?) throws -> Int { + guard let value else { + return 25 + } + guard let limit = value.intValue, (1...200).contains(limit) else { + throw ProvisioningInputValidationError("'limit' must be an integer from 1 through 200") + } + return limit + } + + private func provisioningEnumList( + _ value: Value?, + name: String, + allowedValues: Set + ) throws -> String? { + guard let value else { + return nil + } + + let values: [String] + if let string = value.stringValue { + values = string.split(separator: ",", omittingEmptySubsequences: false).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + } else if let array = value.arrayValue { + let strings = array.compactMap(\.stringValue) + guard strings.count == array.count else { + throw ProvisioningInputValidationError("'\(name)' must be a string or an array of strings") + } + values = strings + } else { + throw ProvisioningInputValidationError("'\(name)' must be a string or an array of strings") + } + + guard !values.isEmpty, values.allSatisfy({ !$0.isEmpty }) else { + throw ProvisioningInputValidationError("'\(name)' must contain at least one value") + } + let unsupported = values.filter { !allowedValues.contains($0) } + guard unsupported.isEmpty else { + throw ProvisioningInputValidationError( + "Unsupported value(s) for '\(name)': \(unsupported.joined(separator: ", "))" + ) + } + return values.joined(separator: ",") + } + + private func capabilitySettings(_ value: Value?) throws -> ASCNullable<[CapabilitySetting]>? { + guard let value else { + return nil + } + if value.isNull { + return .null + } + guard let values = value.arrayValue else { + throw ProvisioningInputValidationError("'settings' must be an array or null") + } + return .value(try values.enumerated().map { index, value in + try capabilitySetting(value, path: "settings[\(index)]") + }) + } + + private func capabilitySetting(_ value: Value, path: String) throws -> CapabilitySetting { + guard let object = value.objectValue else { + throw ProvisioningInputValidationError("'\(path)' must be an object") + } + let allowedKeys: Set = [ + "key", "name", "description", "enabledByDefault", "visible", + "allowedInstances", "minInstances", "options" + ] + if let unknown = object.keys.sorted().first(where: { !allowedKeys.contains($0) }) { + throw ProvisioningInputValidationError("Unknown field '\(path).\(unknown)'") + } + return CapabilitySetting( + key: try capabilityString( + object["key"], + path: "\(path).key", + allowedValues: Set(ProvisioningWorker.capabilitySettingKeys) + ), + name: try capabilityString(object["name"], path: "\(path).name"), + description: try capabilityString(object["description"], path: "\(path).description"), + enabledByDefault: try capabilityBool(object["enabledByDefault"], path: "\(path).enabledByDefault"), + visible: try capabilityBool(object["visible"], path: "\(path).visible"), + allowedInstances: try capabilityString( + object["allowedInstances"], + path: "\(path).allowedInstances", + allowedValues: Set(ProvisioningWorker.capabilityAllowedInstances) + ), + minInstances: try capabilityInt(object["minInstances"], path: "\(path).minInstances"), + options: try capabilityOptions(object["options"], path: "\(path).options") + ) + } + + private func capabilityOptions(_ value: Value?, path: String) throws -> [CapabilityOption]? { + guard let value else { + return nil + } + guard let values = value.arrayValue else { + throw ProvisioningInputValidationError("'\(path)' must be an array") + } + return try values.enumerated().map { index, value in + try capabilityOption(value, path: "\(path)[\(index)]") + } + } + + private func capabilityOption(_ value: Value, path: String) throws -> CapabilityOption { + guard let object = value.objectValue else { + throw ProvisioningInputValidationError("'\(path)' must be an object") + } + let allowedKeys: Set = [ + "key", "name", "description", "enabledByDefault", "enabled", "supportsWildcard" + ] + if let unknown = object.keys.sorted().first(where: { !allowedKeys.contains($0) }) { + throw ProvisioningInputValidationError("Unknown field '\(path).\(unknown)'") + } + return CapabilityOption( + key: try capabilityString( + object["key"], + path: "\(path).key", + allowedValues: Set(ProvisioningWorker.capabilityOptionKeys) + ), + name: try capabilityString(object["name"], path: "\(path).name"), + description: try capabilityString(object["description"], path: "\(path).description"), + enabledByDefault: try capabilityBool(object["enabledByDefault"], path: "\(path).enabledByDefault"), + enabled: try capabilityBool(object["enabled"], path: "\(path).enabled"), + supportsWildcard: try capabilityBool(object["supportsWildcard"], path: "\(path).supportsWildcard") + ) + } + + private func capabilityString( + _ value: Value?, + path: String, + allowedValues: Set? = nil + ) throws -> String? { + guard let value else { + return nil + } + guard let string = value.stringValue else { + throw ProvisioningInputValidationError("'\(path)' must be a string") + } + if let allowedValues, !allowedValues.contains(string) { + throw ProvisioningInputValidationError("Unsupported value for '\(path)': \(string)") + } + return string + } + + private func capabilityBool(_ value: Value?, path: String) throws -> Bool? { + guard let value else { + return nil + } + guard let boolean = value.boolValue else { + throw ProvisioningInputValidationError("'\(path)' must be a boolean") + } + return boolean + } + + private func capabilityInt(_ value: Value?, path: String) throws -> Int? { + guard let value else { + return nil + } + guard let integer = value.intValue else { + throw ProvisioningInputValidationError("'\(path)' must be an integer") + } + return integer + } +} + +private struct ProvisioningInputValidationError: LocalizedError { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + message + } } diff --git a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+ToolDefinitions.swift index beacdbf..60de206 100644 --- a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker+ToolDefinitions.swift @@ -17,10 +17,10 @@ extension ProvisioningWorker { "maximum": .int(200), "description": .string("Max results (default: 25, max: 200)") ]), - "filter_platform": .object([ - "type": .string("string"), - "description": .string("Filter by one or more platforms (comma-separated): IOS, MAC_OS, UNIVERSAL") - ]), + "filter_platform": enumListSchema( + description: "Filter by one or more platforms", + values: ProvisioningWorker.bundleIdPlatforms + ), "filter_identifier": .object([ "type": .string("string"), "description": .string("Filter by one or more bundle identifiers (comma-separated)") @@ -37,10 +37,10 @@ extension ProvisioningWorker { "type": .string("string"), "description": .string("Filter by one or more bundle ID resource IDs (comma-separated)") ]), - "sort": .object([ - "type": .string("string"), - "description": .string("Comma-separated sort fields: name, -name, platform, -platform, identifier, -identifier, seedId, -seedId, id, -id") - ]), + "sort": enumListSchema( + description: "Sort by one or more supported fields", + values: ProvisioningWorker.bundleIdSortValues + ), "next_url": .object([ "type": .string("string"), "description": .string("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") @@ -85,11 +85,12 @@ extension ProvisioningWorker { ]), "platform": .object([ "type": .string("string"), + "enum": .array(ProvisioningWorker.bundleIdPlatforms.map(Value.string)), "description": .string("Platform: IOS, MAC_OS, UNIVERSAL") ]), "seed_id": .object([ - "type": .string("string"), - "description": .string("Optional Apple Developer Program seed ID") + "type": .array([.string("string"), .string("null")]), + "description": .string("Optional Apple Developer Program seed ID; pass null for Apple's explicit nullable value") ]) ]), "required": .array([.string("name"), .string("identifier"), .string("platform")]) @@ -127,14 +128,14 @@ extension ProvisioningWorker { "maximum": .int(200), "description": .string("Max results (default: 25, max: 200)") ]), - "filter_platform": .object([ - "type": .string("string"), - "description": .string("Filter by one or more platforms (comma-separated): IOS, MAC_OS, UNIVERSAL") - ]), - "filter_status": .object([ - "type": .string("string"), - "description": .string("Filter by one or more statuses (comma-separated): ENABLED, DISABLED") - ]), + "filter_platform": enumListSchema( + description: "Filter by one or more platforms", + values: ProvisioningWorker.bundleIdPlatforms + ), + "filter_status": enumListSchema( + description: "Filter by one or more statuses", + values: ProvisioningWorker.deviceStatuses + ), "filter_name": .object([ "type": .string("string"), "description": .string("Filter by one or more device names (comma-separated)") @@ -147,10 +148,10 @@ extension ProvisioningWorker { "type": .string("string"), "description": .string("Filter by one or more device resource IDs (comma-separated)") ]), - "sort": .object([ - "type": .string("string"), - "description": .string("Comma-separated sort fields: name, -name, platform, -platform, udid, -udid, status, -status, id, -id") - ]), + "sort": enumListSchema( + description: "Sort by one or more supported fields", + values: ProvisioningWorker.deviceSortValues + ), "next_url": .object([ "type": .string("string"), "description": .string("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") @@ -178,6 +179,7 @@ extension ProvisioningWorker { ]), "platform": .object([ "type": .string("string"), + "enum": .array(ProvisioningWorker.bundleIdPlatforms.map(Value.string)), "description": .string("Platform: IOS, MAC_OS, UNIVERSAL") ]) ]), @@ -198,12 +200,13 @@ extension ProvisioningWorker { "description": .string("Device resource ID") ]), "name": .object([ - "type": .string("string"), - "description": .string("New device name") + "type": .array([.string("string"), .string("null")]), + "description": .string("New device name, or null for Apple's explicit nullable value") ]), "status": .object([ - "type": .string("string"), - "description": .string("New status: ENABLED, DISABLED") + "type": .array([.string("string"), .string("null")]), + "enum": .array(ProvisioningWorker.deviceStatuses.map(Value.string) + [.null]), + "description": .string("New status: ENABLED, DISABLED, or null") ]) ]), "required": .array([.string("device_id")]) @@ -224,10 +227,10 @@ extension ProvisioningWorker { "maximum": .int(200), "description": .string("Max results (default: 25, max: 200)") ]), - "filter_type": .object([ - "type": .string("string"), - "description": .string("Filter by one or more certificate types (comma-separated, e.g. IOS_DISTRIBUTION,DISTRIBUTION)") - ]), + "filter_type": enumListSchema( + description: "Filter by one or more certificate types", + values: ProvisioningWorker.certificateTypes + ), "filter_display_name": .object([ "type": .string("string"), "description": .string("Filter by one or more certificate display names (comma-separated)") @@ -240,10 +243,10 @@ extension ProvisioningWorker { "type": .string("string"), "description": .string("Filter by one or more certificate resource IDs (comma-separated)") ]), - "sort": .object([ - "type": .string("string"), - "description": .string("Comma-separated sort fields: displayName, -displayName, certificateType, -certificateType, serialNumber, -serialNumber, id, -id") - ]), + "sort": enumListSchema( + description: "Sort by one or more supported fields", + values: ProvisioningWorker.certificateSortValues + ), "next_url": .object([ "type": .string("string"), "description": .string("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") @@ -335,6 +338,7 @@ extension ProvisioningWorker { ]), "profile_type": .object([ "type": .string("string"), + "enum": .array(ProvisioningWorker.profileTypes.map(Value.string)), "description": .string("Profile type: IOS_APP_DEVELOPMENT, IOS_APP_STORE, IOS_APP_ADHOC, IOS_APP_INHOUSE, MAC_APP_DEVELOPMENT, MAC_APP_STORE, MAC_APP_DIRECT, TVOS_APP_DEVELOPMENT, TVOS_APP_STORE, TVOS_APP_ADHOC, TVOS_APP_INHOUSE, MAC_CATALYST_APP_DEVELOPMENT, MAC_CATALYST_APP_STORE, MAC_CATALYST_APP_DIRECT") ]), "bundle_id_resource_id": .object([ @@ -401,11 +405,13 @@ extension ProvisioningWorker { ]), "capability_type": .object([ "type": .string("string"), + "enum": .array(ProvisioningWorker.capabilityTypes.map(Value.string)), "description": .string("Capability type: ICLOUD, IN_APP_PURCHASE, GAME_CENTER, PUSH_NOTIFICATIONS, WALLET, INTER_APP_AUDIO, MAPS, ASSOCIATED_DOMAINS, PERSONAL_VPN, APP_GROUPS, HEALTHKIT, HOMEKIT, WIRELESS_ACCESSORY_CONFIGURATION, APPLE_PAY, DATA_PROTECTION, SIRIKIT, NETWORK_EXTENSIONS, MULTIPATH, HOT_SPOT, NFC_TAG_READING, CLASSKIT, AUTOFILL_CREDENTIAL_PROVIDER, ACCESS_WIFI_INFORMATION, NETWORK_CUSTOM_PROTOCOL, COREMEDIA_HLS_LOW_LATENCY, SYSTEM_EXTENSION_INSTALL, USER_MANAGEMENT, APPLE_ID_AUTH") ]), "settings": .object([ - "type": .string("string"), - "description": .string("Optional JSON string with capability settings array") + "type": .array([.string("array"), .string("null")]), + "items": capabilitySettingSchema(), + "description": .string("Capability settings array, explicit null, or omission") ]) ]), "required": .array([.string("bundle_id_resource_id"), .string("capability_type")]) @@ -443,14 +449,14 @@ extension ProvisioningWorker { "maximum": .int(200), "description": .string("Max results (default: 25, max: 200)") ]), - "filter_profile_type": .object([ - "type": .string("string"), - "description": .string("Filter by one or more profile types (comma-separated, e.g. IOS_APP_STORE,MAC_APP_STORE)") - ]), - "filter_profile_state": .object([ - "type": .string("string"), - "description": .string("Filter by one or more states (comma-separated): ACTIVE, INVALID") - ]), + "filter_profile_type": enumListSchema( + description: "Filter by one or more profile types", + values: ProvisioningWorker.profileTypes + ), + "filter_profile_state": enumListSchema( + description: "Filter by one or more states", + values: ProvisioningWorker.profileStates + ), "filter_name": .object([ "type": .string("string"), "description": .string("Filter by one or more profile names (comma-separated)") @@ -459,10 +465,10 @@ extension ProvisioningWorker { "type": .string("string"), "description": .string("Filter by one or more profile resource IDs (comma-separated)") ]), - "sort": .object([ - "type": .string("string"), - "description": .string("Comma-separated sort fields: name, -name, profileType, -profileType, profileState, -profileState, id, -id") - ]), + "sort": enumListSchema( + description: "Sort by one or more supported fields", + values: ProvisioningWorker.profileSortValues + ), "next_url": .object([ "type": .string("string"), "description": .string("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") @@ -472,4 +478,61 @@ extension ProvisioningWorker { ]) ) } + + private func enumListSchema(description: String, values: [String]) -> Value { + .object([ + "oneOf": .array([ + enumSchema(values), + .object([ + "type": .string("array"), + "items": enumSchema(values), + "minItems": .int(1), + "uniqueItems": .bool(true) + ]) + ]), + "description": .string(description) + ]) + } + + private func capabilitySettingSchema() -> Value { + .object([ + "type": .string("object"), + "additionalProperties": .bool(false), + "properties": .object([ + "key": enumSchema(ProvisioningWorker.capabilitySettingKeys), + "name": .object(["type": .string("string")]), + "description": .object(["type": .string("string")]), + "enabledByDefault": .object(["type": .string("boolean")]), + "visible": .object(["type": .string("boolean")]), + "allowedInstances": enumSchema(ProvisioningWorker.capabilityAllowedInstances), + "minInstances": .object(["type": .string("integer")]), + "options": .object([ + "type": .string("array"), + "items": capabilityOptionSchema() + ]) + ]) + ]) + } + + private func capabilityOptionSchema() -> Value { + .object([ + "type": .string("object"), + "additionalProperties": .bool(false), + "properties": .object([ + "key": enumSchema(ProvisioningWorker.capabilityOptionKeys), + "name": .object(["type": .string("string")]), + "description": .object(["type": .string("string")]), + "enabledByDefault": .object(["type": .string("boolean")]), + "enabled": .object(["type": .string("boolean")]), + "supportsWildcard": .object(["type": .string("boolean")]) + ]) + ]) + } + + private func enumSchema(_ values: [String]) -> Value { + .object([ + "type": .string("string"), + "enum": .array(values.map(Value.string)) + ]) + } } diff --git a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker.swift b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker.swift index 87958b3..432526b 100644 --- a/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker.swift +++ b/Sources/asc-mcp/Workers/ProvisioningWorker/ProvisioningWorker.swift @@ -3,6 +3,105 @@ import MCP /// ProvisioningWorker manages certificates, devices, profiles and bundle IDs public final class ProvisioningWorker: Sendable { + static let bundleIdPlatforms = ["IOS", "MAC_OS", "UNIVERSAL"] + static let bundleIdSortValues = [ + "name", "-name", "platform", "-platform", "identifier", "-identifier", + "seedId", "-seedId", "id", "-id" + ] + static let deviceStatuses = ["ENABLED", "DISABLED"] + static let deviceSortValues = [ + "name", "-name", "platform", "-platform", "udid", "-udid", + "status", "-status", "id", "-id" + ] + static let certificateTypes = [ + "APPLE_PAY", + "APPLE_PAY_MERCHANT_IDENTITY", + "APPLE_PAY_PSP_IDENTITY", + "APPLE_PAY_RSA", + "DEVELOPER_ID_KEXT", + "DEVELOPER_ID_KEXT_G2", + "DEVELOPER_ID_APPLICATION", + "DEVELOPER_ID_APPLICATION_G2", + "DEVELOPMENT", + "DISTRIBUTION", + "IDENTITY_ACCESS", + "IOS_DEVELOPMENT", + "IOS_DISTRIBUTION", + "MAC_APP_DISTRIBUTION", + "MAC_INSTALLER_DISTRIBUTION", + "MAC_APP_DEVELOPMENT", + "PASS_TYPE_ID", + "PASS_TYPE_ID_WITH_NFC" + ] + static let certificateSortValues = [ + "displayName", "-displayName", "certificateType", "-certificateType", + "serialNumber", "-serialNumber", "id", "-id" + ] + static let profileTypes = [ + "IOS_APP_DEVELOPMENT", + "IOS_APP_STORE", + "IOS_APP_ADHOC", + "IOS_APP_INHOUSE", + "MAC_APP_DEVELOPMENT", + "MAC_APP_STORE", + "MAC_APP_DIRECT", + "TVOS_APP_DEVELOPMENT", + "TVOS_APP_STORE", + "TVOS_APP_ADHOC", + "TVOS_APP_INHOUSE", + "MAC_CATALYST_APP_DEVELOPMENT", + "MAC_CATALYST_APP_STORE", + "MAC_CATALYST_APP_DIRECT" + ] + static let profileStates = ["ACTIVE", "INVALID"] + static let profileSortValues = [ + "name", "-name", "profileType", "-profileType", "profileState", "-profileState", "id", "-id" + ] + static let capabilityTypes = [ + "ICLOUD", + "IN_APP_PURCHASE", + "GAME_CENTER", + "PUSH_NOTIFICATIONS", + "WALLET", + "INTER_APP_AUDIO", + "MAPS", + "ASSOCIATED_DOMAINS", + "PERSONAL_VPN", + "APP_GROUPS", + "HEALTHKIT", + "HOMEKIT", + "WIRELESS_ACCESSORY_CONFIGURATION", + "APPLE_PAY", + "DATA_PROTECTION", + "SIRIKIT", + "NETWORK_EXTENSIONS", + "MULTIPATH", + "HOT_SPOT", + "NFC_TAG_READING", + "CLASSKIT", + "AUTOFILL_CREDENTIAL_PROVIDER", + "ACCESS_WIFI_INFORMATION", + "NETWORK_CUSTOM_PROTOCOL", + "COREMEDIA_HLS_LOW_LATENCY", + "SYSTEM_EXTENSION_INSTALL", + "USER_MANAGEMENT", + "APPLE_ID_AUTH" + ] + static let capabilitySettingKeys = [ + "ICLOUD_VERSION", + "DATA_PROTECTION_PERMISSION_LEVEL", + "APPLE_ID_AUTH_APP_CONSENT" + ] + static let capabilityAllowedInstances = ["ENTRY", "SINGLE", "MULTIPLE"] + static let capabilityOptionKeys = [ + "XCODE_5", + "XCODE_6", + "COMPLETE_PROTECTION", + "PROTECTED_UNLESS_OPEN", + "PROTECTED_UNTIL_FIRST_USER_AUTH", + "PRIMARY_APP_CONSENT" + ] + let httpClient: HTTPClient public init(httpClient: HTTPClient) { diff --git a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift index 31c2959..4ae4b94 100644 --- a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift @@ -120,12 +120,13 @@ extension ReviewSubmissionsWorker { if let platforms { query["filter[platform]"] = platforms.joined(separator: ",") } + let paginationScope = strictPaginationScope(path: path, query: query) let response: ASCReviewSubmissionsResponse if let nextURL = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( nextURL, - scope: strictPaginationScope(path: path, query: query), + scope: paginationScope, as: ASCReviewSubmissionsResponse.self ) } else { @@ -144,11 +145,13 @@ extension ReviewSubmissionsWorker { try validatePagingInformation( response.meta, pageCount: response.data.count, + requestedLimit: limit, context: "review submissions list" ) try validateCursorNextConsistency( response.meta, nextURL: response.links.next, + paginationScope: paginationScope, context: "review submissions list" ) try validateIncludedResources( @@ -312,7 +315,12 @@ extension ReviewSubmissionsWorker { } catch { return committedUnverifiedMutationFailure( operation: "create", - reason: error, + reason: acceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), identifiers: identifiers, inspection: inspection ) @@ -334,12 +342,13 @@ extension ReviewSubmissionsWorker { let path = "/v1/reviewSubmissions/\(try ASCPathSegment.encode(submissionID))/items" var query = itemListQuery() query["limit"] = String(limit) + let paginationScope = strictPaginationScope(path: path, query: query) let response: ASCReviewSubmissionItemsResponse if let nextURL = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( nextURL, - scope: strictPaginationScope(path: path, query: query), + scope: paginationScope, as: ASCReviewSubmissionItemsResponse.self ) } else { @@ -358,11 +367,13 @@ extension ReviewSubmissionsWorker { try validatePagingInformation( response.meta, pageCount: response.data.count, + requestedLimit: limit, context: "review submission items list" ) try validateCursorNextConsistency( response.meta, nextURL: response.links.next, + paginationScope: paginationScope, context: "review submission items list" ) try validateIncludedResources( @@ -477,7 +488,12 @@ extension ReviewSubmissionsWorker { } catch { return committedUnverifiedMutationFailure( operation: "add_item", - reason: error, + reason: acceptedMutationError( + error, + method: "POST", + expectedStatusCode: 201, + actualStatusCode: receipt.statusCode + ), identifiers: identifiers, inspection: inspection ) @@ -606,7 +622,12 @@ extension ReviewSubmissionsWorker { } catch { return committedUnverifiedMutationFailure( operation: "update_item", - reason: error, + reason: acceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), identifiers: identifiers, inspection: inspection ) @@ -780,7 +801,12 @@ extension ReviewSubmissionsWorker { } catch { return committedUnverifiedMutationFailure( operation: action, - reason: error, + reason: acceptedMutationError( + error, + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: receipt.statusCode + ), identifiers: identifiers, inspection: inspection ) @@ -851,11 +877,13 @@ extension ReviewSubmissionsWorker { try validatePagingInformation( response.meta, pageCount: response.data.count, + requestedLimit: 200, context: "review item membership preflight" ) try validateCursorNextConsistency( response.meta, nextURL: response.links.next, + paginationScope: scope, context: "review item membership preflight" ) if let total = response.meta?.paging.total { @@ -1064,7 +1092,10 @@ extension ReviewSubmissionsWorker { string == string.trimmingCharacters(in: .whitespacesAndNewlines) else { throw ReviewSubmissionArgumentError("'\(field)' must be a non-empty canonical identifier") } - _ = try ASCPathSegment.encode(string, field: field) + let encoded = try ASCPathSegment.encode(string, field: field) + guard encoded == string else { + throw ReviewSubmissionArgumentError("'\(field)' must be a canonical App Store Connect resource ID") + } return string } @@ -1073,28 +1104,14 @@ extension ReviewSubmissionsWorker { expectedPath: String, context: String ) throws { - guard !value.isEmpty, - value == value.trimmingCharacters(in: .whitespacesAndNewlines), - !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { - throw ASCError.parsing("Apple returned an invalid required links.self in \(context)") - } - guard let components = URLComponents(string: value), - components.fragment == nil, - components.user == nil, - components.password == nil else { - throw ASCError.parsing("Apple returned an invalid required links.self URI in \(context)") - } - if components.scheme != nil || components.host != nil { - guard components.scheme == "https", - let host = components.host, - !host.isEmpty else { - throw ASCError.parsing("Apple returned a non-HTTPS required links.self URI in \(context)") - } - } - guard components.percentEncodedPath == expectedPath else { - throw ASCError.parsing("Apple returned a required links.self path outside \(context)") + do { + _ = try httpClient.validatedScopedLink( + value, + scope: PaginationScope(path: expectedPath) + ) + } catch { + throw ASCError.parsing("Apple returned an out-of-scope required links.self in \(context)") } - _ = try validatedASCAPIEndpoint(components.percentEncodedPath) } private func validateSubmissions( @@ -1292,14 +1309,21 @@ extension ReviewSubmissionsWorker { private func validatePagingInformation( _ meta: ASCReviewSubmissionPagingInformation?, pageCount: Int, + requestedLimit: Int? = nil, context: String ) throws { - guard let paging = meta?.paging else { return } - guard paging.limit > 0 else { - throw ASCError.parsing("Apple returned a non-positive paging limit in \(context)") + if let requestedLimit, pageCount > requestedLimit { + throw ASCError.parsing("Apple returned more resources than the requested limit in \(context)") } - guard paging.limit >= pageCount else { - throw ASCError.parsing("Apple returned paging limit below the page count in \(context)") + guard let paging = meta?.paging else { return } + if let requestedLimit { + guard paging.limit == requestedLimit else { + throw ASCError.parsing("Apple returned paging metadata outside the requested limit in \(context)") + } + } else { + guard paging.limit > 0, pageCount <= paging.limit else { + throw ASCError.parsing("Apple returned invalid paging metadata in \(context)") + } } if let total = paging.total, total < pageCount { throw ASCError.parsing("Apple returned paging total below the page count in \(context)") @@ -1309,14 +1333,25 @@ extension ReviewSubmissionsWorker { private func validateCursorNextConsistency( _ meta: ASCReviewSubmissionPagingInformation?, nextURL: String?, + paginationScope: PaginationScope, context: String ) throws { + let nextRequest: PaginationRequest? + if let nextURL { + do { + nextRequest = try httpClient.validatedScopedLink(nextURL, scope: paginationScope) + } catch { + throw ASCError.parsing("Apple returned an out-of-scope links.next in \(context)") + } + } else { + nextRequest = nil + } guard let cursor = meta?.paging.nextCursor else { return } guard !cursor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ASCError.parsing("Apple returned an empty paging cursor in \(context)") } - guard nextURL != nil else { - throw ASCError.parsing("Apple returned a paging cursor without links.next in \(context)") + guard nextRequest?.parameters["cursor"] == cursor else { + throw ASCError.parsing("Apple returned a paging cursor that does not match links.next in \(context)") } } @@ -1621,6 +1656,29 @@ extension ReviewSubmissionsWorker { ) } + private func acceptedMutationError( + _ error: Error, + method: String, + expectedStatusCode: Int, + actualStatusCode: Int + ) -> ASCError { + let cause: ASCError + if let ascError = error as? ASCError { + if case .mutationCommittedUnverified = ascError { + return ascError + } + cause = ascError + } else { + cause = .parsing(Redactor.redact(error.localizedDescription)) + } + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: expectedStatusCode, + actualStatusCode: actualStatusCode, + cause: cause + ) + } + private func encodeMutationBody(_ value: T) throws -> Data { do { return try JSONEncoder().encode(value) @@ -1688,6 +1746,7 @@ extension ReviewSubmissionsWorker { details["inspectionRequired"] = .bool(true) case .committedUnverified: details["operationCommitted"] = .bool(true) + details["outcomeUnknown"] = .bool(false) details["inspectionRequired"] = .bool(true) } return MCPResult.error( diff --git a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Handlers.swift b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Handlers.swift index e4ecc5e..4e18589 100644 --- a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Handlers.swift @@ -10,6 +10,8 @@ import MCP extension ReviewsWorker { + private static let supportedReviewTerritories = Set(SandboxTesterTerritoryValues.all) + private struct ReviewCollectionOptions { let queryParameters: [String: String] } @@ -18,6 +20,20 @@ extension ReviewsWorker { let data: CustomerReview } + private func committedUnverifiedReviewMutation( + _ error: Error, + method: String, + statusCode: Int + ) -> ASCError { + let cause = error as? ASCError ?? .parsing(Redactor.redact(error.localizedDescription)) + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } + private struct ReviewExistenceResponse: Codable, Sendable { let data: Resource @@ -37,6 +53,7 @@ extension ReviewsWorker { let reviewsScanned: Int let uniqueReviewsScanned: Int let duplicatesSkipped: Int + let unaggregatableReviewsSkipped: Int let pagesFetched: Int } @@ -63,6 +80,7 @@ extension ReviewsWorker { var reviewsScanned = 0 var uniqueReviewsScanned = 0 var duplicatesSkipped = 0 + var unaggregatableReviewsSkipped = 0 var pagesFetched = 0 var seenNextURLs: Set = [] var totalCount = 0 @@ -83,7 +101,10 @@ extension ReviewsWorker { pagesFetched += 1 reviewsScanned += page.data.count - let oldestDate = page.data.compactMap { parseReviewDate($0.attributes.createdDate) }.min() + let oldestDate = page.data.compactMap { review -> Date? in + guard let createdDate = review.attributes?.createdDate else { return nil } + return parseReviewDate(createdDate) + }.min() for review in page.data { guard seenReviewIDs.insert(review.id).inserted else { duplicatesSkipped += 1 @@ -91,20 +112,29 @@ extension ReviewsWorker { } uniqueReviewsScanned += 1 + guard let attributes = review.attributes, + let rating = attributes.rating else { + unaggregatableReviewsSkipped += 1 + continue + } if let startDate { - guard let reviewDate = parseReviewDate(review.attributes.createdDate), - reviewDate >= startDate, + guard let createdDate = attributes.createdDate, + let reviewDate = parseReviewDate(createdDate) else { + unaggregatableReviewsSkipped += 1 + continue + } + guard reviewDate >= startDate, reviewDate <= now else { continue } } totalCount += 1 - totalRating += review.attributes.rating - ratingDistribution[review.attributes.rating, default: 0] += 1 - let reviewTerritory = review.attributes.territory ?? "Unknown" + totalRating += rating + ratingDistribution[rating, default: 0] += 1 + let reviewTerritory = attributes.territory ?? "Unknown" territories[reviewTerritory, default: TerritoryAccumulator()].count += 1 - territories[reviewTerritory, default: TerritoryAccumulator()].ratingTotal += review.attributes.rating + territories[reviewTerritory, default: TerritoryAccumulator()].ratingTotal += rating } if let startDate, @@ -149,6 +179,7 @@ extension ReviewsWorker { reviewsScanned: reviewsScanned, uniqueReviewsScanned: uniqueReviewsScanned, duplicatesSkipped: duplicatesSkipped, + unaggregatableReviewsSkipped: unaggregatableReviewsSkipped, pagesFetched: pagesFetched ) } @@ -265,7 +296,7 @@ extension ReviewsWorker { if let value = arguments["territory"] { guard let rawTerritory = value.stringValue, let territory = normalizedReviewTerritory(rawTerritory) else { - throw ASCError.parsing("territory must be a three-letter ISO 3166-1 alpha-3 code") + throw ASCError.parsing("territory must be an Apple-supported three-letter territory code") } return [territory] } @@ -281,7 +312,7 @@ extension ReviewsWorker { } let territories = rawTerritories.compactMap(normalizedReviewTerritory) guard territories.count == rawTerritories.count else { - throw ASCError.parsing("territories must contain only three-letter ISO 3166-1 alpha-3 codes") + throw ASCError.parsing("territories must contain only Apple-supported three-letter territory codes") } guard Set(territories).count == territories.count else { throw ASCError.parsing("territories must not contain duplicates") @@ -291,7 +322,7 @@ extension ReviewsWorker { private func normalizedReviewTerritory(_ value: String) -> String? { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - guard normalized.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil else { + guard Self.supportedReviewTerritories.contains(normalized) else { return nil } return normalized @@ -487,7 +518,7 @@ extension ReviewsWorker { territory = normalized } else { return CallTool.Result( - content: [MCPContent.text("Error: territory must be 'all' or a three-letter ISO 3166-1 alpha-3 code")], + content: [MCPContent.text("Error: territory must be 'all' or an Apple-supported three-letter territory code")], isError: true ) } @@ -532,8 +563,9 @@ extension ReviewsWorker { "reviews_scanned": collection.reviewsScanned, "unique_reviews_scanned": collection.uniqueReviewsScanned, "duplicates_skipped": collection.duplicatesSkipped, + "unaggregatable_reviews_skipped": collection.unaggregatableReviewsSkipped, "pages_fetched": collection.pagesFetched, - "complete": true, + "complete": collection.unaggregatableReviewsSkipped == 0, "average_rating": Double(String(format: "%.2f", stats.averageRating)) ?? stats.averageRating, "rating_distribution": ratingDist ] @@ -586,13 +618,6 @@ extension ReviewsWorker { ) } - if responseBody.count > 5000 { - return CallTool.Result( - content: [MCPContent.text("Error: Response body exceeds 5000 characters limit")], - isError: true - ) - } - do { let request = CreateReviewResponseRequest( data: CreateReviewResponseRequest.RequestData( @@ -612,7 +637,27 @@ extension ReviewsWorker { let bodyData = try JSONEncoder().encode(request) let responseRaw = try await httpClient.post("/v1/customerReviewResponses", body: bodyData) - let responseData = try JSONDecoder().decode(ReviewResponseData.self, from: responseRaw) + let responseData: ReviewResponseData + do { + responseData = try JSONDecoder().decode(ReviewResponseData.self, from: responseRaw) + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: responseData.data.type, + id: responseData.data.id, + expectedType: "customerReviewResponses", + context: "Apple customer review response create response" + ) + if let review = responseData.data.relationships?.review?.data { + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: review.type, + id: review.id, + expectedType: "customerReviews", + expectedID: reviewId, + context: "Apple customer review response relationship" + ) + } + } catch { + throw committedUnverifiedReviewMutation(error, method: "POST", statusCode: 201) + } let result: [String: Any] = [ "success": true, @@ -622,10 +667,7 @@ extension ReviewsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create response: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create response") } } diff --git a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Parsers.swift b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Parsers.swift index a4df6da..d9884c2 100644 --- a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Parsers.swift +++ b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+Parsers.swift @@ -30,13 +30,15 @@ extension ReviewsWorker { } return reviews.enumerated().map { index, review in - """ - \(index + 1). Rating: \(String(repeating: "⭐", count: review.attributes.rating)) - Title: \(review.attributes.title ?? "No title") - Review: \(review.attributes.body ?? "No content") - By: \(review.attributes.reviewerNickname) - Date: \(formatDate(review.attributes.createdDate)) - Territory: \(review.attributes.territory ?? "Unknown") + let attributes = review.attributes + let rating = attributes?.rating.map { String(repeating: "⭐", count: $0) } ?? "Unknown" + return """ + \(index + 1). Rating: \(rating) + Title: \(attributes?.title ?? "No title") + Review: \(attributes?.body ?? "No content") + By: \(attributes?.reviewerNickname ?? "Unknown") + Date: \(attributes?.createdDate.map { formatDate($0) } ?? "Unknown") + Territory: \(attributes?.territory ?? "Unknown") ID: \(review.id) """ }.joined(separator: "\n\n") @@ -106,35 +108,38 @@ extension ReviewsWorker { let filteredReviews: [CustomerReview] if let startDate { filteredReviews = reviews.filter { review in - guard let reviewDate = parseReviewDate(review.attributes.createdDate) else { return false } + guard let attributes = review.attributes, + attributes.rating != nil, + let createdDate = attributes.createdDate, + let reviewDate = parseReviewDate(createdDate) else { return false } return reviewDate >= startDate && reviewDate <= now } } else { - filteredReviews = reviews + filteredReviews = reviews.filter { $0.attributes?.rating != nil } } let totalCount = filteredReviews.count // Calculate average rating - let totalRating = filteredReviews.reduce(0) { $0 + $1.attributes.rating } + let totalRating = filteredReviews.reduce(0) { $0 + ($1.attributes?.rating ?? 0) } let averageRating = totalCount > 0 ? Double(totalRating) / Double(totalCount) : 0.0 // Calculate rating distribution var ratingDistribution: [Int: Int] = [:] for rating in 1...5 { - ratingDistribution[rating] = filteredReviews.filter { $0.attributes.rating == rating }.count + ratingDistribution[rating] = filteredReviews.filter { $0.attributes?.rating == rating }.count } // Calculate territory statistics var territoryMap: [String: [CustomerReview]] = [:] for review in filteredReviews { - let territory = review.attributes.territory ?? "Unknown" + let territory = review.attributes?.territory ?? "Unknown" territoryMap[territory, default: []].append(review) } let topTerritories = territoryMap .map { territory, reviews in - let avgRating = Double(reviews.reduce(0) { $0 + $1.attributes.rating }) / Double(reviews.count) + let avgRating = Double(reviews.reduce(0) { $0 + ($1.attributes?.rating ?? 0) }) / Double(reviews.count) return TerritoryStats( territory: territory, count: reviews.count, diff --git a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+ToolDefinitions.swift index 9c11fb0..67a9f21 100644 --- a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker+ToolDefinitions.swift @@ -50,7 +50,8 @@ extension ReviewsWorker { "type": .string("string"), "description": .string("One Apple ISO 3166-1 alpha-3 territory code (USA, RUS, DEU, JPN)"), "minLength": .int(3), - "maxLength": .int(3) + "maxLength": .int(3), + "pattern": .string(reviewTerritoryPattern()) ]), "territories": .object([ "type": .string("array"), @@ -58,7 +59,8 @@ extension ReviewsWorker { "items": .object([ "type": .string("string"), "minLength": .int(3), - "maxLength": .int(3) + "maxLength": .int(3), + "pattern": .string(reviewTerritoryPattern()) ]), "minItems": .int(1), "uniqueItems": .bool(true) @@ -139,7 +141,8 @@ extension ReviewsWorker { "type": .string("string"), "description": .string("Filter by one Apple ISO 3166-1 alpha-3 territory code (e.g., USA, RUS, DEU)"), "minLength": .int(3), - "maxLength": .int(3) + "maxLength": .int(3), + "pattern": .string(reviewTerritoryPattern()) ]), "territories": .object([ "type": .string("array"), @@ -147,7 +150,8 @@ extension ReviewsWorker { "items": .object([ "type": .string("string"), "minLength": .int(3), - "maxLength": .int(3) + "maxLength": .int(3), + "pattern": .string(reviewTerritoryPattern()) ]), "minItems": .int(1), "uniqueItems": .bool(true) @@ -196,7 +200,8 @@ extension ReviewsWorker { ]), "territory": .object([ "type": .string("string"), - "description": .string("Filter by Apple's ISO 3166-1 alpha-3 territory code (e.g., USA, RUS, DEU) or 'all' for all territories") + "description": .string("Filter by an Apple-supported three-letter territory code (e.g., USA, RUS, DEU) or 'all' for all territories"), + "pattern": .string(reviewTerritoryPattern(includingAll: true)) ]), "has_published_response": .object([ "type": .string("boolean"), @@ -222,7 +227,7 @@ extension ReviewsWorker { ]), "response_body": .object([ "type": .string("string"), - "description": .string("Developer response text (max 5000 characters)") + "description": .string("Developer response text") ]) ]), "required": .array([.string("review_id"), .string("response_body")]) @@ -324,4 +329,22 @@ extension ReviewsWorker { ]) ]) } + + private func reviewTerritoryPattern(includingAll: Bool = false) -> String { + let values = includingAll + ? ["all"] + SandboxTesterTerritoryValues.all + : SandboxTesterTerritoryValues.all + let alternatives = values.map { value in + value.map { character in + let scalar = String(character) + let uppercase = scalar.uppercased() + let lowercase = scalar.lowercased() + if uppercase == lowercase { + return NSRegularExpression.escapedPattern(for: scalar) + } + return "[\(uppercase)\(lowercase)]" + }.joined() + }.joined(separator: "|") + return "^(?:\(alternatives))$" + } } diff --git a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker.swift b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker.swift index 5d92d60..ff984b7 100644 --- a/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker.swift +++ b/Sources/asc-mcp/Workers/ReviewsWorker/ReviewsWorker.swift @@ -61,17 +61,17 @@ extension ReviewsWorker { struct CustomerReview: Codable, Sendable { let id: String let type: String - let attributes: ReviewAttributes + let attributes: ReviewAttributes? let relationships: ReviewRelationships? let links: ResourceLinks? } struct ReviewAttributes: Codable, Sendable { - let rating: Int + let rating: Int? let title: String? let body: String? - let reviewerNickname: String - let createdDate: String + let reviewerNickname: String? + let createdDate: String? let territory: String? } @@ -112,7 +112,7 @@ extension ReviewsWorker { } struct Paging: Codable, Sendable { - let total: Int + let total: Int? let limit: Int } @@ -204,19 +204,23 @@ extension ReviewsWorker { _ review: CustomerReview, includedResponses: [String: CustomerReviewResponse] = [:] ) -> [String: Any] { - var dict: [String: Any] = [ - "id": review.id, - "rating": review.attributes.rating, - "reviewer": review.attributes.reviewerNickname, - "created_date": review.attributes.createdDate - ] - if let title = review.attributes.title { + var dict: [String: Any] = ["id": review.id] + if let rating = review.attributes?.rating { + dict["rating"] = rating + } + if let reviewer = review.attributes?.reviewerNickname { + dict["reviewer"] = reviewer + } + if let createdDate = review.attributes?.createdDate { + dict["created_date"] = createdDate + } + if let title = review.attributes?.title { dict["title"] = title } - if let body = review.attributes.body { + if let body = review.attributes?.body { dict["body"] = body } - if let territory = review.attributes.territory { + if let territory = review.attributes?.territory { dict["territory"] = territory } if let responseID = review.relationships?.response?.data?.id { diff --git a/Sources/asc-mcp/Workers/SandboxTestersWorker/SandboxTestersWorker+Handlers.swift b/Sources/asc-mcp/Workers/SandboxTestersWorker/SandboxTestersWorker+Handlers.swift index 3592dd5..3066ae3 100644 --- a/Sources/asc-mcp/Workers/SandboxTestersWorker/SandboxTestersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/SandboxTestersWorker/SandboxTestersWorker+Handlers.swift @@ -11,8 +11,16 @@ extension SandboxTestersWorker { do { let endpoint = "/v2/sandboxTesters" - let limit = arguments?["limit"]?.intValue ?? 25 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let limit: Int + if let value = arguments?["limit"] { + guard let requested = value.intValue, (1...200).contains(requested) else { + throw ASCError.parsing("limit must be an integer from 1 through 200") + } + limit = requested + } else { + limit = 25 + } + let queryParams = ["limit": String(limit)] let response: ASCSandboxTestersResponse if let nextUrl = try paginationURL(from: arguments?["next_url"]) { @@ -46,10 +54,7 @@ extension SandboxTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to list sandbox testers: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to list sandbox testers") } } @@ -122,10 +127,7 @@ extension SandboxTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update sandbox tester: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update sandbox tester") } } @@ -185,10 +187,7 @@ extension SandboxTestersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to clear purchase history: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to clear purchase history") } } diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift index 9f6b097..e7e4e6b 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift @@ -68,7 +68,7 @@ extension SubscriptionsWorker { } var queryParams = selection - queryParams["limit"] = String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + queryParams["limit"] = String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -145,24 +145,27 @@ extension SubscriptionsWorker { guard let arguments = params.arguments, let groupId = arguments["group_id"]?.stringValue, let name = arguments["name"]?.stringValue, - let productId = arguments["product_id"]?.stringValue, - let subscriptionPeriod = arguments["subscription_period"]?.stringValue else { + let productId = arguments["product_id"]?.stringValue else { return CallTool.Result( - content: [MCPContent.text("Error: Required parameters: group_id, name, product_id, subscription_period")], + content: [MCPContent.text("Error: Required parameters: group_id, name, product_id")], isError: true ) } do { + let subscriptionPeriod = try nullableSubscriptionPeriod(from: arguments) + let familySharable = try nullableSubscriptionBool("family_sharable", from: arguments) + let groupLevel = try nullableSubscriptionInt("group_level", from: arguments) + let reviewNote = try nullableSubscriptionString("review_note", from: arguments) let request = CreateSubscriptionRequest( data: CreateSubscriptionRequest.CreateData( attributes: CreateSubscriptionRequest.Attributes( name: name, productId: productId, subscriptionPeriod: subscriptionPeriod, - familySharable: arguments["family_sharable"]?.boolValue, - groupLevel: arguments["group_level"]?.intValue, - reviewNote: arguments["review_note"]?.stringValue + familySharable: familySharable, + groupLevel: groupLevel, + reviewNote: reviewNote ), relationships: CreateSubscriptionRequest.Relationships( group: CreateSubscriptionRequest.GroupRelationship( @@ -188,10 +191,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create subscription: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create subscription") } } @@ -222,14 +222,18 @@ extension SubscriptionsWorker { do { let subscriptionPeriod = try nullableSubscriptionPeriod(from: arguments) + let name = try nullableSubscriptionString("name", from: arguments) + let familySharable = try nullableSubscriptionBool("family_sharable", from: arguments) + let groupLevel = try nullableSubscriptionInt("group_level", from: arguments) + let reviewNote = try nullableSubscriptionString("review_note", from: arguments) let request = UpdateSubscriptionRequest( data: UpdateSubscriptionRequest.UpdateData( id: subscriptionId, attributes: UpdateSubscriptionRequest.Attributes( - name: arguments["name"]?.stringValue, - familySharable: arguments["family_sharable"]?.boolValue, - groupLevel: arguments["group_level"]?.intValue, - reviewNote: arguments["review_note"]?.stringValue, + name: name, + familySharable: familySharable, + groupLevel: groupLevel, + reviewNote: reviewNote, subscriptionPeriod: subscriptionPeriod ) ) @@ -251,16 +255,13 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update subscription: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update subscription") } } private func nullableSubscriptionPeriod( from arguments: [String: Value] - ) throws -> NullableAttributeValue? { + ) throws -> ASCNullable? { guard let value = arguments["subscription_period"] else { return nil } @@ -280,7 +281,55 @@ extension SubscriptionsWorker { "subscription_period must be null or one of: \(allowedValues.sorted().joined(separator: ", "))" ) } - return .string(string) + return .value(string) + } + + private func nullableSubscriptionString( + _ name: String, + from arguments: [String: Value] + ) throws -> ASCNullable? { + guard let value = arguments[name] else { + return nil + } + if value.isNull { + return .null + } + guard let string = value.stringValue else { + throw SubscriptionUpdateInputError("\(name) must be a string or null") + } + return .value(string) + } + + private func nullableSubscriptionBool( + _ name: String, + from arguments: [String: Value] + ) throws -> ASCNullable? { + guard let value = arguments[name] else { + return nil + } + if value.isNull { + return .null + } + guard let bool = value.boolValue else { + throw SubscriptionUpdateInputError("\(name) must be a Boolean or null") + } + return .value(bool) + } + + private func nullableSubscriptionInt( + _ name: String, + from arguments: [String: Value] + ) throws -> ASCNullable? { + guard let value = arguments[name] else { + return nil + } + if value.isNull { + return .null + } + guard let int = value.intValue else { + throw SubscriptionUpdateInputError("\(name) must be an integer or null") + } + return .value(int) } /// Deletes a subscription @@ -325,7 +374,7 @@ extension SubscriptionsWorker { let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/subscriptionLocalizations" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -410,7 +459,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to create subscription localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to create subscription localization") } } @@ -454,7 +503,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return MCPResult.error("Failed to update subscription localization: \(error.localizedDescription)") + return MCPResult.error(error, prefix: "Failed to update subscription localization") } } @@ -538,10 +587,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create subscription group: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create subscription group") } } @@ -583,10 +629,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update subscription group: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update subscription group") } } @@ -650,10 +693,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to submit subscription for review: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to submit subscription for review") } } @@ -703,7 +743,7 @@ extension SubscriptionsWorker { let endpoint = "/v1/subscriptionGroups/\(try ASCPathSegment.encode(groupId))/subscriptionGroupLocalizations" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -786,10 +826,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create subscription group localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create subscription group localization") } } @@ -865,10 +902,7 @@ extension SubscriptionsWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update subscription group localization: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update subscription group localization") } } @@ -1179,7 +1213,7 @@ extension SubscriptionsWorker { let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/images" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift index 3c24654..4840aab 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift @@ -78,23 +78,32 @@ extension SubscriptionsWorker { "description": .string("Unique product identifier") ]), "subscription_period": .object([ - "type": .string("string"), - "description": .string("Period: ONE_WEEK, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR") + "type": .array([.string("string"), .string("null")]), + "description": .string("Optional period, or null to preserve Apple's explicit nullable value"), + "enum": .array([ + .string("ONE_WEEK"), + .string("ONE_MONTH"), + .string("TWO_MONTHS"), + .string("THREE_MONTHS"), + .string("SIX_MONTHS"), + .string("ONE_YEAR"), + .null + ]) ]), "family_sharable": .object([ - "type": .string("boolean"), - "description": .string("Enable Family Sharing") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable Family Sharing, or null to preserve Apple's explicit nullable value") ]), "group_level": .object([ - "type": .string("integer"), - "description": .string("Level within the subscription group (1 = highest)") + "type": .array([.string("integer"), .string("null")]), + "description": .string("Level within the subscription group, or null to preserve Apple's explicit nullable value") ]), "review_note": .object([ - "type": .string("string"), - "description": .string("Notes for App Review") + "type": .array([.string("string"), .string("null")]), + "description": .string("Notes for App Review, or null to preserve Apple's explicit nullable value") ]) ]), - "required": .array([.string("group_id"), .string("name"), .string("product_id"), .string("subscription_period")]) + "required": .array([.string("group_id"), .string("name"), .string("product_id")]) ]) ) } @@ -105,6 +114,7 @@ extension SubscriptionsWorker { description: "Update an existing subscription", inputSchema: .object([ "type": .string("object"), + "additionalProperties": .bool(false), "minProperties": .int(2), "properties": .object([ "subscription_id": .object([ @@ -112,20 +122,20 @@ extension SubscriptionsWorker { "description": .string("Subscription ID") ]), "name": .object([ - "type": .string("string"), - "description": .string("New reference name") + "type": .array([.string("string"), .string("null")]), + "description": .string("New reference name, or null to clear") ]), "family_sharable": .object([ - "type": .string("boolean"), - "description": .string("Enable Family Sharing") + "type": .array([.string("boolean"), .string("null")]), + "description": .string("Enable Family Sharing, or null to clear") ]), "group_level": .object([ - "type": .string("integer"), - "description": .string("Level within the subscription group") + "type": .array([.string("integer"), .string("null")]), + "description": .string("Level within the subscription group, or null to clear") ]), "review_note": .object([ - "type": .string("string"), - "description": .string("Notes for App Review") + "type": .array([.string("string"), .string("null")]), + "description": .string("Notes for App Review, or null to clear") ]), "subscription_period": .object([ "type": .array([.string("string"), .string("null")]), @@ -324,9 +334,15 @@ extension SubscriptionsWorker { "type": .string("string"), "description": .string("Filter by subscription price point ID") ]), + "plan_types": subscriptionEnumListSchema( + "Filter by one or more Apple subscription plan types", + values: ASCSubscriptionPlanType.allCases.map(\.rawValue) + ), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -353,9 +369,16 @@ extension SubscriptionsWorker { "type": .string("string"), "description": .string("Filter by territory ID (e.g. USA, GBR). Recommended for AI-friendly output.") ]), + "upfront_price_point_ids": subscriptionStringListSchema("Filter by one or more upfront price point IDs"), + "plan_types": subscriptionEnumListSchema( + "Filter by one or more Apple subscription plan types", + values: ASCSubscriptionPlanType.allCases.map(\.rawValue) + ), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 8000)") + "description": .string("Max results (default: 25, max: 8000)"), + "minimum": .int(1), + "maximum": .int(8000) ]), "next_url": .object([ "type": .string("string"), @@ -439,7 +462,9 @@ extension SubscriptionsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -702,7 +727,9 @@ extension SubscriptionsWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3CommerceHandlers.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3CommerceHandlers.swift index 18c0ae7..f7b1aac 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3CommerceHandlers.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3CommerceHandlers.swift @@ -11,13 +11,20 @@ extension SubscriptionsWorker { do { let response: PassthroughAPIResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/prices" - var query = subscriptionPriceQuery(arguments: arguments, maxLimit: 200) + var query = try subscriptionPriceQuery(arguments: arguments, maxLimit: 200) if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } if let pricePointId = arguments["price_point_id"]?.stringValue { query["filter[subscriptionPricePoint]"] = pricePointId } + if let planTypes = try subscriptionCatalogQueryValue( + arguments["plan_types"], + field: "plan_types", + allowedValues: Set(ASCSubscriptionPlanType.allCases.map(\.rawValue)) + ) { + query["filter[planType]"] = planTypes + } if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -60,11 +67,24 @@ extension SubscriptionsWorker { "include": "territory", "fields[subscriptionPricePoints]": "customerPrice,proceeds,proceedsYear2,territory,equalizations", "fields[territories]": "currency", - "limit": String(clampedLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 8000)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 8000)) ] if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } + if let upfrontPricePointIds = try subscriptionCatalogQueryValue( + arguments["upfront_price_point_ids"], + field: "upfront_price_point_ids" + ) { + query["filter[upfrontPricePointId]"] = upfrontPricePointIds + } + if let planTypes = try subscriptionCatalogQueryValue( + arguments["plan_types"], + field: "plan_types", + allowedValues: Set(ASCSubscriptionPlanType.allCases.map(\.rawValue)) + ) { + query["filter[planType]"] = planTypes + } if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -159,9 +179,8 @@ extension SubscriptionsWorker { func createSubscriptionPrice(_ params: CallTool.Parameters) async throws -> CallTool.Result { guard let arguments = params.arguments, let subscriptionId = arguments["subscription_id"]?.stringValue, - let territoryId = arguments["territory_id"]?.stringValue, let pricePointId = arguments["price_point_id"]?.stringValue else { - return MCPResult.error("Required parameters: subscription_id, territory_id, price_point_id") + return MCPResult.error("Required parameters: subscription_id, price_point_id") } var attributes: [String: Any] = [:] @@ -184,15 +203,18 @@ extension SubscriptionsWorker { ) { return MCPResult.error(error) } + var relationships: [String: Any] = [ + "subscription": ["data": ["type": "subscriptions", "id": subscriptionId]], + "subscriptionPricePoint": ["data": ["type": "subscriptionPricePoints", "id": pricePointId]] + ] + if let territoryId = arguments["territory_id"]?.stringValue { + relationships["territory"] = ["data": ["type": "territories", "id": territoryId]] + } let body: [String: Any] = [ "data": [ "type": "subscriptionPrices", "attributes": attributes, - "relationships": [ - "subscription": ["data": ["type": "subscriptions", "id": subscriptionId]], - "territory": ["data": ["type": "territories", "id": territoryId]], - "subscriptionPricePoint": ["data": ["type": "subscriptionPricePoints", "id": pricePointId]] - ] + "relationships": relationships ] ] return try await postResource(endpoint: "/v1/subscriptionPrices", body: body, key: "price") @@ -309,13 +331,26 @@ extension SubscriptionsWorker { do { let response: PassthroughAPIResponse let endpoint = "/v1/subscriptionPricePoints/\(try ASCPathSegment.encode(pricePointId))/equalizations" - var query = pricePointQuery(limit: clampedLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 8000)) + var query = pricePointQuery(limit: try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 8000)) if let subscriptionId = arguments["subscription_id"]?.stringValue { query["filter[subscription]"] = subscriptionId } if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } + if let upfrontPricePointIds = try subscriptionCatalogQueryValue( + arguments["upfront_price_point_ids"], + field: "upfront_price_point_ids" + ) { + query["filter[upfrontPricePointId]"] = upfrontPricePointIds + } + if let planTypes = try subscriptionCatalogQueryValue( + arguments["plan_types"], + field: "plan_types", + allowedValues: Set(ASCSubscriptionPlanType.allCases.map(\.rawValue)) + ) { + query["filter[planType]"] = planTypes + } if let nextUrl = try paginationURL(from: arguments["next_url"]) { response = try await httpClient.getPage( @@ -412,7 +447,7 @@ extension SubscriptionsWorker { "/v1/apps/\(try ASCPathSegment.encode(appId))/subscriptionGroups", parameters: [ "include": "subscriptions", - "limit": String(clampedLimit(arguments["limit"]?.intValue, defaultValue: 200, max: 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 200, maximum: 200)) ], as: PassthroughAPIResponse.self ) @@ -455,7 +490,7 @@ extension SubscriptionsWorker { do { let prices: PassthroughAPIResponse = try await httpClient.get( "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/prices", - parameters: subscriptionPriceQuery(arguments: ["territory_id": .string(territoryId)], maxLimit: 200).merging(["filter[territory]": territoryId]) { _, new in new }, + parameters: try subscriptionPriceQuery(arguments: ["territory_id": .string(territoryId)], maxLimit: 200).merging(["filter[territory]": territoryId]) { _, new in new }, as: PassthroughAPIResponse.self ) let formatted = (prices.data.arrayValue ?? []).map { @@ -979,7 +1014,7 @@ extension SubscriptionsWorker { let response: PassthroughAPIResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/\(try ASCPathSegment.encode(endpointSuffix))" var query = defaultQuery - query["limit"] = String(clampedLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 200)) + query["limit"] = String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } @@ -1042,7 +1077,7 @@ extension SubscriptionsWorker { default: return MCPResult.error("Unsupported subscription offer price endpoint") } - var query = subscriptionOfferPriceQuery(arguments: arguments, fieldsKey: fieldsKey) + var query = try subscriptionOfferPriceQuery(arguments: arguments, fieldsKey: fieldsKey) if let territoryId = arguments["territory_id"]?.stringValue { query["filter[territory]"] = territoryId } @@ -1110,13 +1145,22 @@ extension SubscriptionsWorker { guard let customCodeId = params.arguments?["custom_code_id"]?.stringValue else { return MCPResult.error("Required parameter 'custom_code_id' is missing") } - var data: [String: Any] = [ + guard let activeValue = params.arguments?["active"] else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } + let active: Any + if activeValue.isNull { + active = NSNull() + } else if let bool = activeValue.boolValue { + active = bool + } else { + return MCPResult.error("Required parameter 'active' must be a Boolean or null") + } + let data: [String: Any] = [ "type": "subscriptionOfferCodeCustomCodes", - "id": customCodeId + "id": customCodeId, + "attributes": ["active": active] ] - if let active = params.arguments?["active"]?.boolValue { - data["attributes"] = ["active": active] - } let body: [String: Any] = ["data": data] return try await patchResource(endpoint: "/v1/subscriptionOfferCodeCustomCodes/\(try ASCPathSegment.encode(customCodeId))", body: body, key: "custom_code") } @@ -1137,7 +1181,7 @@ extension SubscriptionsWorker { do { let response: PassthroughAPIResponse var query = defaultQuery - query["limit"] = String(clampedLimit(params.arguments?["limit"]?.intValue, defaultValue: 25, max: 200)) + query["limit"] = String(try validatedCommerceLimit(params.arguments?["limit"], defaultValue: 25, maximum: 200)) if let nextUrl = try paginationURL(from: params.arguments?["next_url"]) { response = try await httpClient.getPage( @@ -1171,34 +1215,106 @@ extension SubscriptionsWorker { } private func postResource(endpoint: String, body: [String: Any], key: String) async throws -> CallTool.Result { + guard let requestData = body["data"] as? [String: Any], + let expectedType = requestData["type"] as? String else { + return MCPResult.error("Failed to create \(key): request resource type is missing") + } + + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: body) + } catch { + return MCPResult.error(error, prefix: "Failed to encode \(key)") + } + + let responseData: Data + do { + responseData = try await httpClient.post(endpoint, body: data) + } catch { + return MCPResult.error(error, prefix: "Failed to create \(key)") + } + do { - let data = try JSONSerialization.data(withJSONObject: body) - let responseData = try await httpClient.post(endpoint, body: data) let response = try JSONDecoder().decode(PassthroughAPIResponse.self, from: responseData) + guard let responseType = response.data.type, + let responseID = response.data.id else { + throw ASCError.parsing("POST response resource identity does not match the request contract") + } + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: responseType, + id: responseID, + expectedType: expectedType, + context: "Subscription commerce POST response" + ) return MCPResult.jsonObject(["success": true, key: formatGenericResource(response.data)]) } catch { - return MCPResult.error("Failed to create \(key): \(error.localizedDescription)") + return MCPResult.error( + subscriptionCommittedUnverifiedMutation(method: "POST", statusCode: 201, error: error), + prefix: "Failed to verify created \(key)" + ) } } private func patchResource(endpoint: String, body: [String: Any], key: String) async throws -> CallTool.Result { + guard let requestData = body["data"] as? [String: Any], + let expectedType = requestData["type"] as? String, + let expectedID = requestData["id"] as? String else { + return MCPResult.error("Failed to update \(key): request resource identity is missing") + } + + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: body) + } catch { + return MCPResult.error(error, prefix: "Failed to encode \(key)") + } + + let responseData: Data + do { + responseData = try await httpClient.patch(endpoint, body: data) + } catch { + return MCPResult.error(error, prefix: "Failed to update \(key)") + } + do { - let data = try JSONSerialization.data(withJSONObject: body) - let responseData = try await httpClient.patch(endpoint, body: data) let response = try JSONDecoder().decode(PassthroughAPIResponse.self, from: responseData) + guard let responseType = response.data.type, + let responseID = response.data.id else { + throw ASCError.parsing("PATCH response resource identity does not match the request contract") + } + try ASCNonIdempotentWriteRecovery.validateResourceIdentity( + type: responseType, + id: responseID, + expectedType: expectedType, + expectedID: expectedID, + context: "Subscription commerce PATCH response" + ) return MCPResult.jsonObject(["success": true, key: formatGenericResource(response.data)]) } catch { - return MCPResult.error("Failed to update \(key): \(error.localizedDescription)") + return MCPResult.error( + subscriptionCommittedUnverifiedMutation(method: "PATCH", statusCode: 200, error: error), + prefix: "Failed to verify updated \(key)" + ) } } - private func subscriptionPriceQuery(arguments: [String: Value], maxLimit: Int) -> [String: String] { + private func subscriptionCommittedUnverifiedMutation(method: String, statusCode: Int, error: Error) -> ASCError { + let cause = error as? ASCError ?? .parsing(error.localizedDescription) + return .mutationCommittedUnverified( + method: method, + expectedStatusCode: statusCode, + actualStatusCode: statusCode, + cause: cause + ) + } + + private func subscriptionPriceQuery(arguments: [String: Value], maxLimit: Int) throws -> [String: String] { [ "include": "territory,subscriptionPricePoint", "fields[subscriptionPrices]": "startDate,preserved,planType,territory,subscriptionPricePoint", "fields[subscriptionPricePoints]": "customerPrice,proceeds,proceedsYear2,territory,equalizations", "fields[territories]": "currency", - "limit": String(clampedLimit(arguments["limit"]?.intValue, defaultValue: 25, max: maxLimit)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: maxLimit)) ] } @@ -1223,20 +1339,16 @@ extension SubscriptionsWorker { ] } - private func subscriptionOfferPriceQuery(arguments: [String: Value], fieldsKey: String) -> [String: String] { + private func subscriptionOfferPriceQuery(arguments: [String: Value], fieldsKey: String) throws -> [String: String] { [ "include": "territory,subscriptionPricePoint", "fields[\(fieldsKey)]": "territory,subscriptionPricePoint", "fields[subscriptionPricePoints]": "customerPrice,proceeds,proceedsYear2,territory,equalizations", "fields[territories]": "currency", - "limit": String(clampedLimit(arguments["limit"]?.intValue, defaultValue: 25, max: 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] } - private func clampedLimit(_ value: Int?, defaultValue: Int, max: Int) -> Int { - min(Swift.max(value ?? defaultValue, 1), max) - } - private func appendNext(_ links: JSONValue?, to result: inout [String: Any]) { if let next = links?.objectValue?["next"]?.stringValue { result["next_url"] = next diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3ToolDefinitions.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3ToolDefinitions.swift index c3295da..c17bfbd 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+V3ToolDefinitions.swift @@ -8,9 +8,9 @@ extension SubscriptionsWorker { simpleTool("subscriptions_get_group", "Get a subscription group", ["group_id": "Subscription group ID"], ["group_id"]), simpleTool("subscriptions_submit_group", "DEPRECATED since App Store Connect API 4.4.1. Use subscriptions_create_group_version, then review_submissions_create, review_submissions_add_item, and review_submissions_submit. This tool keeps the legacy submission endpoint and never creates or selects a version automatically.", ["group_id": "Subscription group ID"], ["group_id"]), simpleTool("subscriptions_get_localization", "DEPRECATED since App Store Connect API 4.4.1. Use subscriptions_get_version_localization. Get a legacy product-scoped localization without automatic version migration.", ["localization_id": "Subscription localization ID"], ["localization_id"]), - simpleTool("subscriptions_create_price", "Create a subscription price for a territory and price point", ["subscription_id": "Subscription ID", "territory_id": "Territory ID", "price_point_id": "Subscription price point ID", "start_date": "Optional date in YYYY-MM-DD format; pass null to request an immediate price", "plan_type": "Optional subscription plan type; pass null to use Apple's default", "preserve_current_price": "Whether to preserve the current subscriber price; pass null to use Apple's default"], ["subscription_id", "territory_id", "price_point_id"]), + simpleTool("subscriptions_create_price", "Create a subscription price for a price point and optional territory", ["subscription_id": "Subscription ID", "territory_id": "Optional territory ID", "price_point_id": "Subscription price point ID", "start_date": "Optional date in YYYY-MM-DD format; pass null to request an immediate price", "plan_type": "Optional subscription plan type; pass null to use Apple's default", "preserve_current_price": "Whether to preserve the current subscriber price; pass null to use Apple's default"], ["subscription_id", "price_point_id"]), simpleTool("subscriptions_get_price_point", "Get one subscription price point", ["price_point_id": "Subscription price point ID"], ["price_point_id"]), - simpleTool("subscriptions_list_price_point_equalizations", "List equivalent subscription price points", ["price_point_id": "Subscription price point ID", "subscription_id": "Optional subscription filter", "territory_id": "Optional territory filter", "limit": "Max results, up to 8000", "next_url": "Pagination URL"], ["price_point_id"]), + simpleTool("subscriptions_list_price_point_equalizations", "List equivalent subscription price points", ["price_point_id": "Subscription price point ID", "subscription_id": "Optional subscription filter", "territory_id": "Optional territory filter", "upfront_price_point_ids": "Optional upfront price point ID filters", "plan_types": "Optional MONTHLY or UPFRONT plan filters", "limit": "Max results, up to 8000", "next_url": "Pagination URL"], ["price_point_id"]), simpleTool("subscriptions_get_availability", "Deprecated compatibility read for Apple's legacy subscriptionAvailability resource. Use subscriptions_list_plan_availabilities.", ["subscription_id": "Subscription ID"], ["subscription_id"]), simpleTool("subscriptions_set_availability", "Deprecated compatibility write for Apple's legacy subscriptionAvailability resource. Use subscriptions_create_plan_availability or subscriptions_update_plan_availability.", ["subscription_id": "Subscription ID", "available_in_new_territories": "Whether new territories are automatically enabled", "territory_ids": "Array of territory IDs"], ["subscription_id", "available_in_new_territories", "territory_ids"]), simpleTool("subscriptions_list_available_territories", "Deprecated compatibility listing for Apple's legacy subscriptionAvailability resource. Use subscriptions_list_plan_availability_territories.", ["availability_id": "Subscription availability ID", "limit": "Max results", "next_url": "Pagination URL"], ["availability_id"]), @@ -20,7 +20,7 @@ extension SubscriptionsWorker { simpleTool("subscriptions_prepare_offer_prices", "Find subscription price point candidates for offer creation", ["subscription_id": "Subscription ID", "territory_id": "Territory ID", "mode": "Offer mode", "customer_price": "Optional customer price to match"], ["subscription_id", "territory_id", "mode"]), simpleTool("subscriptions_list_intro_offers", "List introductory offers", ["subscription_id": "Subscription ID", "territory_id": "Optional territory filter", "limit": "Max results", "next_url": "Pagination URL"], ["subscription_id"]), simpleTool("subscriptions_create_intro_offer", "Create an introductory offer", ["subscription_id": "Subscription ID", "duration": "Offer duration", "offer_mode": "Offer mode", "number_of_periods": "Number of periods", "start_date": "Optional start date in YYYY-MM-DD format; pass null to use Apple's default", "end_date": "Optional end date in YYYY-MM-DD format; pass null for no end date", "target_subscription_plan_type": "Optional target subscription plan type; pass null to use Apple's default", "territory_id": "Territory ID", "price_point_id": "Price point ID for paid modes"], ["subscription_id", "duration", "offer_mode", "number_of_periods"]), - simpleTool("subscriptions_update_intro_offer", "Update an introductory offer", ["intro_offer_id": "Introductory offer ID", "end_date": "End date"], ["intro_offer_id"]), + simpleTool("subscriptions_update_intro_offer", "Update or clear an introductory offer end date", ["intro_offer_id": "Introductory offer ID", "end_date": "End date in YYYY-MM-DD format, or null to clear"], ["intro_offer_id", "end_date"]), simpleTool("subscriptions_delete_intro_offer", "Delete an introductory offer", ["intro_offer_id": "Introductory offer ID"], ["intro_offer_id"]), simpleTool("subscriptions_list_promotional_offers", "List promotional offers", ["subscription_id": "Subscription ID", "territory_id": "Optional territory filter", "limit": "Max results", "next_url": "Pagination URL"], ["subscription_id"]), simpleTool("subscriptions_get_promotional_offer", "Get a promotional offer", ["promotional_offer_id": "Promotional offer ID"], ["promotional_offer_id"]), @@ -31,7 +31,7 @@ extension SubscriptionsWorker { simpleTool("subscriptions_list_offer_codes", "List subscription offer codes", ["subscription_id": "Subscription ID", "territory_id": "Optional territory filter", "limit": "Max results", "next_url": "Pagination URL"], ["subscription_id"]), simpleTool("subscriptions_get_offer_code", "Get a subscription offer code", ["offer_code_id": "Offer code ID"], ["offer_code_id"]), simpleTool("subscriptions_create_offer_code", "Create a subscription offer code", ["subscription_id": "Subscription ID", "name": "Name", "customer_eligibilities": "One or more customer eligibility values", "offer_eligibility": "Introductory-offer stacking eligibility", "offer_mode": "Offer mode", "duration": "Duration", "number_of_periods": "Number of periods", "territory_ids": "Territory IDs", "price_point_ids": "Price point IDs for paid modes", "auto_renew_enabled": "Whether the subscription renews after the offer. false requires FREE_TRIAL and REPLACE_INTRO_OFFERS", "target_subscription_plan_type": "Optional target subscription plan type"], ["subscription_id", "name", "customer_eligibilities", "offer_eligibility", "offer_mode", "duration", "number_of_periods", "territory_ids"]), - simpleTool("subscriptions_update_offer_code", "Update an offer code", ["offer_code_id": "Offer code ID", "active": "Whether active"], ["offer_code_id"]), + simpleTool("subscriptions_update_offer_code", "Update or clear an offer code active state", ["offer_code_id": "Offer code ID", "active": "Whether active, or null to clear"], ["offer_code_id", "active"]), simpleTool("subscriptions_deactivate_offer_code", "Deactivate an offer code", ["offer_code_id": "Offer code ID"], ["offer_code_id"]), simpleTool("subscriptions_list_offer_code_prices", "List offer code prices", ["offer_code_id": "Offer code ID", "territory_id": "Optional territory filter", "limit": "Max results", "next_url": "Pagination URL"], ["offer_code_id"]), simpleTool("subscriptions_generate_one_time_codes", "Generate one-time offer codes", ["offer_code_id": "Offer code ID", "number_of_codes": "Number of codes", "expiration_date": "Expiration date", "environment": "Optional environment"], ["offer_code_id", "number_of_codes", "expiration_date"]), @@ -40,7 +40,7 @@ extension SubscriptionsWorker { simpleTool("subscriptions_get_one_time_code_values", "Get generated one-time code values", ["one_time_code_id": "One-time code resource ID"], ["one_time_code_id"]), simpleTool("subscriptions_create_custom_code", "Create a custom offer code", ["offer_code_id": "Offer code ID", "custom_code": "Custom code", "number_of_codes": "Number of codes", "expiration_date": "Expiration date"], ["offer_code_id", "custom_code", "number_of_codes"]), simpleTool("subscriptions_get_custom_code", "Get custom offer code details", ["custom_code_id": "Custom code ID"], ["custom_code_id"]), - simpleTool("subscriptions_update_custom_code", "Update custom offer code", ["custom_code_id": "Custom code ID", "active": "Whether active"], ["custom_code_id"]), + simpleTool("subscriptions_update_custom_code", "Update or clear a custom offer code active state", ["custom_code_id": "Custom code ID", "active": "Whether active, or null to clear"], ["custom_code_id", "active"]), simpleTool("subscriptions_deactivate_custom_code", "Deactivate custom offer code", ["custom_code_id": "Custom code ID"], ["custom_code_id"]), simpleTool("subscriptions_list_winback_offers", "List win-back offers", ["subscription_id": "Subscription ID", "limit": "Max results", "next_url": "Pagination URL"], ["subscription_id"]), simpleTool("subscriptions_get_winback_offer", "Get a win-back offer", ["winback_offer_id": "Win-back offer ID"], ["winback_offer_id"]), @@ -115,6 +115,12 @@ extension SubscriptionsWorker { if key == "sort", name == "subscriptions_list_groups" { return (key, subscriptionEnumListSchema(fieldDescription, values: Self.subscriptionGroupSortValues)) } + if key == "plan_types" { + return (key, subscriptionEnumListSchema(fieldDescription, values: ASCSubscriptionPlanType.allCases.map(\.rawValue))) + } + if key == "upfront_price_point_ids" { + return (key, subscriptionStringListSchema(fieldDescription)) + } return (key, propertySchema(toolName: name, key: key, description: fieldDescription)) })), "required": .array(required.map { .string($0) }) @@ -140,14 +146,22 @@ extension SubscriptionsWorker { ] let nullableCreatePriceFields: Set = ["start_date", "plan_type", "preserve_current_price"] let nullableIntroductoryOfferFields: Set = ["start_date", "end_date", "target_subscription_plan_type"] + let nullableActiveTools: Set = ["subscriptions_update_offer_code", "subscriptions_update_custom_code"] let isNullable = (toolName == "subscriptions_update_winback_offer" && nullableWinBackFields.contains(key)) || (toolName == "subscriptions_create_price" && nullableCreatePriceFields.contains(key)) || - (toolName == "subscriptions_create_intro_offer" && nullableIntroductoryOfferFields.contains(key)) + (toolName == "subscriptions_create_intro_offer" && nullableIntroductoryOfferFields.contains(key)) || + (toolName == "subscriptions_update_intro_offer" && key == "end_date") || + (nullableActiveTools.contains(toolName) && key == "active") var schema: [String: Value] = [ "type": isNullable ? .array([.string(baseType), .string("null")]) : .string(baseType), "description": .string(description) ] + if key == "limit" { + schema["minimum"] = .int(1) + schema["maximum"] = .int(toolName == "subscriptions_list_price_point_equalizations" ? 8000 : 200) + } + if baseType == "array" { var itemSchema: [String: Value] = ["type": .string("string"), "minLength": .int(1)] if key == "customer_eligibilities" { diff --git a/Sources/asc-mcp/Workers/UsersWorker/UsersWorker+Handlers.swift b/Sources/asc-mcp/Workers/UsersWorker/UsersWorker+Handlers.swift index f98c67d..3643bfb 100644 --- a/Sources/asc-mcp/Workers/UsersWorker/UsersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/UsersWorker/UsersWorker+Handlers.swift @@ -104,7 +104,6 @@ extension UsersWorker { isError: true ) } - do { var queryParams: [String: String] = [:] queryParams["include"] = try commaSeparated( @@ -153,40 +152,39 @@ extension UsersWorker { return MCPResult.error("Required parameter 'user_id' is missing") } - let roles: [String]? + let roles: ASCNullable<[String]>? if let rolesValue = arguments["roles"] { - do { - roles = try validatedStringArray( - rolesValue, - name: "roles", - allowEmpty: false, - allowedValues: Set(UsersWorker.assignableRoles) - ) - } catch { - return MCPResult.error(error.localizedDescription) + if rolesValue.isNull { + roles = .null + } else { + do { + roles = .value(try validatedStringArray( + rolesValue, + name: "roles", + allowEmpty: false, + allowedValues: Set(UsersWorker.assignableRoles) + )) + } catch { + return MCPResult.error(error.localizedDescription) + } } } else { roles = nil } - let allAppsVisible: Bool? - if let value = arguments["all_apps_visible"] { - guard let parsed = value.boolValue else { - return MCPResult.error("'all_apps_visible' must be a boolean") - } - allAppsVisible = parsed - } else { - allAppsVisible = nil - } - - let provisioningAllowed: Bool? - if let value = arguments["provisioning_allowed"] { - guard let parsed = value.boolValue else { - return MCPResult.error("'provisioning_allowed' must be a boolean") - } - provisioningAllowed = parsed - } else { - provisioningAllowed = nil + let allAppsVisible: ASCNullable? + let provisioningAllowed: ASCNullable? + do { + allAppsVisible = try nullableBool( + arguments["all_apps_visible"], + name: "all_apps_visible" + ) + provisioningAllowed = try nullableBool( + arguments["provisioning_allowed"], + name: "provisioning_allowed" + ) + } catch { + return MCPResult.error(error.localizedDescription) } let visibleAppIds: [String]? @@ -222,9 +220,9 @@ extension UsersWorker { data: UpdateUserRequest.UpdateUserData( id: userId, attributes: UpdateUserRequest.UpdateUserAttributes( - roles: roles, - allAppsVisible: allAppsVisible, - provisioningAllowed: provisioningAllowed + nullableRoles: roles, + nullableAllAppsVisible: allAppsVisible, + nullableProvisioningAllowed: provisioningAllowed ), relationships: relationships ) @@ -242,8 +240,8 @@ extension UsersWorker { "success": true, "user": user ] as [String: Any] - if let roles, - !UsersWorker.deprecatedRoles.isDisjoint(with: roles) { + if case .value(let roleValues)? = roles, + !UsersWorker.deprecatedRoles.isDisjoint(with: roleValues) { result["warnings"] = [ "ACCESS_TO_REPORTS is deprecated by Apple and remains accepted only for backward compatibility." ] @@ -253,10 +251,7 @@ extension UsersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to update user: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update user") } } @@ -303,10 +298,13 @@ extension UsersWorker { isError: true ) } + guard isValidEmail(email) else { + return MCPResult.error("'email' must be a valid email address") + } let roles: [String] - let allAppsVisible: Bool - let provisioningAllowed: Bool? + let allAppsVisible: ASCNullable? + let provisioningAllowed: ASCNullable? let visibleAppIds: [String]? do { roles = try validatedStringArray( @@ -315,11 +313,11 @@ extension UsersWorker { allowEmpty: false, allowedValues: Set(UsersWorker.assignableRoles) ) - allAppsVisible = try optionalBool( + allAppsVisible = try nullableBool( arguments["all_apps_visible"], name: "all_apps_visible" - ) ?? true - provisioningAllowed = try optionalBool( + ) + provisioningAllowed = try nullableBool( arguments["provisioning_allowed"], name: "provisioning_allowed" ) @@ -352,8 +350,8 @@ extension UsersWorker { firstName: firstName, lastName: lastName, roles: roles, - allAppsVisible: allAppsVisible, - provisioningAllowed: provisioningAllowed + nullableAllAppsVisible: allAppsVisible, + nullableProvisioningAllowed: provisioningAllowed ), relationships: relationships ) @@ -381,10 +379,7 @@ extension UsersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to invite user: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to invite user") } } @@ -518,8 +513,13 @@ extension UsersWorker { do { let endpoint = "/v1/users/\(try ASCPathSegment.encode(userId))/visibleApps" - let limit = arguments["limit"]?.intValue ?? 25 - let queryParams = ["limit": String(min(max(limit, 1), 200))] + let limit = try boundedInteger( + arguments["limit"], + name: "limit", + range: 1...200, + defaultValue: 25 + ) ?? 25 + let queryParams = ["limit": String(limit)] let response: ASCAppsResponse if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -546,6 +546,9 @@ extension UsersWorker { if let next = response.links.next { result["next_url"] = next } + if let total = response.meta?.paging.total { + result["total"] = total + } return MCPResult.jsonObject(result) @@ -581,15 +584,21 @@ extension UsersWorker { return MCPResult.error(error.localizedDescription) } + let bodyData: Data do { let request = BetaGroupRelationshipRequest( data: appIds.map { ASCResourceIdentifier(type: "apps", id: $0) } ) + bodyData = try JSONEncoder().encode(request) + } catch { + return MCPResult.error(error, prefix: "Failed to encode visible apps relationship request") + } - let bodyData = try JSONEncoder().encode(request) + do { _ = try await httpClient.post( "/v1/users/\(try ASCPathSegment.encode(userId))/relationships/visibleApps", - body: bodyData + body: bodyData, + expectedStatusCode: 204 ) let result = [ @@ -600,10 +609,7 @@ extension UsersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Failed to add visible apps: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to add visible apps") } } @@ -631,12 +637,17 @@ extension UsersWorker { return MCPResult.error(error.localizedDescription) } + let bodyData: Data do { let request = BetaGroupRelationshipRequest( data: appIds.map { ASCResourceIdentifier(type: "apps", id: $0) } ) + bodyData = try JSONEncoder().encode(request) + } catch { + return MCPResult.error(error, prefix: "Failed to encode visible apps relationship request") + } - let bodyData = try JSONEncoder().encode(request) + do { _ = try await httpClient.delete( "/v1/users/\(try ASCPathSegment.encode(userId))/relationships/visibleApps", body: bodyData @@ -717,14 +728,26 @@ extension UsersWorker { return integer } - private func optionalBool(_ value: Value?, name: String) throws -> Bool? { + private func nullableBool(_ value: Value?, name: String) throws -> ASCNullable? { guard let value else { return nil } + if value.isNull { + return .null + } guard let boolean = value.boolValue else { - throw UsersInputValidationError("'\(name)' must be a boolean") + throw UsersInputValidationError("'\(name)' must be a boolean or null") + } + return .value(boolean) + } + + private func isValidEmail(_ value: String) -> Bool { + let range = NSRange(value.startIndex.. [String: String] { - let limit = arguments["limit"]?.intValue ?? 25 - return ["limit": String(min(max(limit, 1), 200))] + private func defaultListQuery(arguments: [String: Value]) throws -> [String: String] { + guard let value = arguments["limit"] else { + return ["limit": "25"] + } + guard let limit = value.intValue, (1...200).contains(limit) else { + throw ASCError.parsing("limit must be an integer from 1 through 200") + } + return ["limit": String(limit)] } private func parseEventTypes(_ value: Value?) -> [String]? { @@ -299,6 +292,59 @@ extension WebhooksWorker { !eventTypes.isEmpty && eventTypes.allSatisfy { ASCWebhookEventTypes.all.contains($0) } } + private func webhookUpdateAttributes( + _ arguments: [String: Value] + ) throws -> ASCWebhookUpdateRequest.Attributes { + var values: [String: JSONValue] = [:] + + if let enabled = arguments["enabled"] { + if enabled.isNull { + values["enabled"] = .null + } else if let boolean = enabled.boolValue { + values["enabled"] = .bool(boolean) + } else { + throw ASCError.parsing("Parameter 'enabled' must be a boolean or null") + } + } + + if let eventTypes = arguments["event_types"] { + if eventTypes.isNull { + values["eventTypes"] = .null + } else { + guard let array = eventTypes.arrayValue else { + throw ASCError.parsing("Parameter 'event_types' must be an array of strings or null") + } + let strings = array.compactMap(\.stringValue) + guard strings.count == array.count, + validateEventTypes(strings), + Set(strings).count == strings.count else { + throw ASCError.parsing("Parameter 'event_types' must contain unique supported App Store Connect webhook event types") + } + values["eventTypes"] = .array(strings.map(JSONValue.string)) + } + } + + for key in ["name", "secret", "url"] { + guard let value = arguments[key] else { continue } + if value.isNull { + values[key] = .null + continue + } + guard let string = value.stringValue else { + throw ASCError.parsing("Parameter '\(key)' must be a string or null") + } + if key == "url", !validateWebhookURL(string) { + throw ASCError.parsing(webhookURLValidationMessage) + } + if key == "secret", !validateWebhookSecret(string) { + throw ASCError.parsing(webhookSecretValidationMessage) + } + values[key] = .string(string) + } + + return ASCWebhookUpdateRequest.Attributes(values: values) + } + private func validateWebhookURL(_ value: String) -> Bool { guard let components = URLComponents(string: value), components.scheme?.lowercased() == "https", diff --git a/Sources/asc-mcp/Workers/WebhooksWorker/WebhooksWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/WebhooksWorker/WebhooksWorker+ToolDefinitions.swift index b30d9fc..75fb31c 100644 --- a/Sources/asc-mcp/Workers/WebhooksWorker/WebhooksWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/WebhooksWorker/WebhooksWorker+ToolDefinitions.swift @@ -9,7 +9,7 @@ extension WebhooksWorker { inputSchema: baseSchema( properties: [ "app_id": stringSchema("App ID whose webhooks should be listed"), - "limit": integerSchema("Max results (default: 25, max: 200)"), + "limit": collectionLimitSchema("Max results (default: 25, max: 200)"), "include_app": boolSchema("Include related app resource when Apple returns it"), "next_url": stringSchema("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") ], @@ -57,11 +57,11 @@ extension WebhooksWorker { inputSchema: baseSchema( properties: [ "webhook_id": stringSchema("Webhook ID to update"), - "name": stringSchema("New webhook name"), - "url": stringSchema("Absolute HTTPS callback URL to replace the current URL. URL user info (user/password) and fragments are not allowed; custom ports, paths, and query parameters are supported."), - "secret": webhookSecretSchema("New cryptographically random webhook secret of at least 32 characters, such as a 32-byte random value encoded as hex or Base64. The secret is never returned by this tool."), - "event_types": eventTypesSchema("Replacement webhook event type list"), - "enabled": boolSchema("Whether the webhook should be enabled") + "name": nullableStringSchema("New webhook name, or null to clear Apple's nullable value"), + "url": nullableStringSchema("Absolute HTTPS callback URL to replace the current URL, or null to clear Apple's nullable value. URL user info (user/password) and fragments are not allowed; custom ports, paths, and query parameters are supported."), + "secret": nullableWebhookSecretSchema("New cryptographically random webhook secret of at least 32 characters, or null to clear Apple's nullable value. The secret is never returned by this tool."), + "event_types": nullableEventTypesSchema("Replacement webhook event type list, or null to clear Apple's nullable value"), + "enabled": nullableBoolSchema("Whether the webhook should be enabled, or null to clear Apple's nullable value") ], required: ["webhook_id"] ) @@ -92,7 +92,7 @@ extension WebhooksWorker { "created_after": stringSchema("Filter deliveries created at or after this ISO-8601 date-time"), "created_before": stringSchema("Filter deliveries created before this ISO-8601 date-time"), "include_event": boolSchema("Include related webhook event resources (default: true)"), - "limit": integerSchema("Max results (default: 25, max: 200)"), + "limit": collectionLimitSchema("Max results (default: 25, max: 200)"), "next_url": stringSchema("Apple continuation URL from the previous response. Repeat every originating list control, including the effective/default limit, filters, sort, include, fields, and nested limits when supported; the exact query and a non-empty cursor are validated.") ], required: ["webhook_id"] @@ -218,6 +218,21 @@ extension WebhooksWorker { ]) } + private func nullableWebhookSecretSchema(_ description: String) -> Value { + .object([ + "type": .array([.string("string"), .string("null")]), + "description": .string(description), + "minLength": .int(Self.minimumWebhookSecretLength) + ]) + } + + private func nullableStringSchema(_ description: String) -> Value { + .object([ + "type": .array([.string("string"), .string("null")]), + "description": .string(description) + ]) + } + private func integerSchema(_ description: String) -> Value { .object([ "type": .string("integer"), @@ -225,6 +240,15 @@ extension WebhooksWorker { ]) } + private func collectionLimitSchema(_ description: String) -> Value { + .object([ + "type": .string("integer"), + "description": .string(description), + "minimum": .int(1), + "maximum": .int(200) + ]) + } + private func boolSchema(_ description: String) -> Value { .object([ "type": .string("boolean"), @@ -232,6 +256,13 @@ extension WebhooksWorker { ]) } + private func nullableBoolSchema(_ description: String) -> Value { + .object([ + "type": .array([.string("boolean"), .string("null")]), + "description": .string(description) + ]) + } + private func enumSchema(_ description: String, values: [String]) -> Value { .object([ "type": .string("string"), @@ -250,4 +281,17 @@ extension WebhooksWorker { ]) ]) } + + private func nullableEventTypesSchema(_ description: String) -> Value { + .object([ + "type": .array([.string("array"), .string("null")]), + "description": .string(description), + "items": .object([ + "type": .string("string"), + "enum": .array(ASCWebhookEventTypes.all.map(Value.string)) + ]), + "minItems": .int(1), + "uniqueItems": .bool(true) + ]) + } } diff --git a/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+Handlers.swift b/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+Handlers.swift index 516a24d..08e780a 100644 --- a/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+Handlers.swift @@ -19,7 +19,7 @@ extension WinBackOffersWorker { let response: ASCWinBackOffersResponse let endpoint = "/v1/subscriptions/\(try ASCPathSegment.encode(subscriptionId))/winBackOffers" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { @@ -217,10 +217,7 @@ extension WinBackOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to create win-back offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to create win-back offer") } } @@ -351,10 +348,7 @@ extension WinBackOffersWorker { return MCPResult.jsonObject(result) } catch { - return CallTool.Result( - content: [MCPContent.text("Error: Failed to update win-back offer: \(error.localizedDescription)")], - isError: true - ) + return MCPResult.error(error, prefix: "Failed to update win-back offer") } } @@ -399,7 +393,7 @@ extension WinBackOffersWorker { let response: ASCWinBackOfferPricesResponse let endpoint = "/v1/winBackOffers/\(try ASCPathSegment.encode(winbackOfferId))/prices" let query = [ - "limit": String(min(max(arguments["limit"]?.intValue ?? 25, 1), 200)) + "limit": String(try validatedCommerceLimit(arguments["limit"], defaultValue: 25, maximum: 200)) ] if let nextUrl = try paginationURL(from: arguments["next_url"]) { diff --git a/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+ToolDefinitions.swift index f125e1b..c3ad12d 100644 --- a/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/WinBackOffersWorker/WinBackOffersWorker+ToolDefinitions.swift @@ -17,7 +17,9 @@ extension WinBackOffersWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), @@ -182,7 +184,9 @@ extension WinBackOffersWorker { ]), "limit": .object([ "type": .string("integer"), - "description": .string("Max results (default: 25, max: 200)") + "description": .string("Max results (default: 25, max: 200)"), + "minimum": .int(1), + "maximum": .int(200) ]), "next_url": .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+MutationToolDefinitions.swift b/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+MutationToolDefinitions.swift index 6c99faa..f33e033 100644 --- a/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+MutationToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+MutationToolDefinitions.swift @@ -8,7 +8,7 @@ extension XcodeCloudWorker { description: "Preview or permanently delete an Xcode Cloud product. Preview is the default. Permanent deletion requires the latest dynamic receipt plus the exact current product name, workflow count, build-run count, and confirm_permanent_deletion=true.", inputSchema: xcMutationObjectSchema( properties: [ - "product_id": xcMutationIDSchema("Xcode Cloud product ID"), + "product_id": xcMutationResourceIDSchema("Xcode Cloud product ID"), "confirm_permanent_deletion": xcMutationBooleanSchema("Must be true to execute permanent deletion"), "confirmation_receipt": xcMutationIDSchema("Dynamic receipt from the latest preview"), "expected_product_name": xcMutationStringSchema("Exact current product name from the latest preview"), @@ -42,7 +42,7 @@ extension XcodeCloudWorker { description: "Preview or permanently delete an Xcode Cloud workflow. Preview is the default. Permanent deletion requires the latest dynamic receipt plus the exact current workflow name, build-run count, and confirm_permanent_deletion=true.", inputSchema: xcMutationObjectSchema( properties: [ - "workflow_id": xcMutationIDSchema("Xcode Cloud workflow ID"), + "workflow_id": xcMutationResourceIDSchema("Xcode Cloud workflow ID"), "confirm_permanent_deletion": xcMutationBooleanSchema("Must be true to execute permanent deletion"), "confirmation_receipt": xcMutationIDSchema("Dynamic receipt from the latest preview"), "expected_workflow_name": xcMutationStringSchema("Exact current workflow name from the latest preview"), @@ -99,13 +99,13 @@ private extension XcodeCloudWorker { isCreate ? "Path to the Xcode project or workspace container" : "Container path; null clears the attribute", nullable: !isCreate ), - "xcode_version_id": xcMutationIDSchema("Compatible Xcode version ID"), - "macos_version_id": xcMutationIDSchema("Compatible macOS version ID") + "xcode_version_id": xcMutationResourceIDSchema("Compatible Xcode version ID"), + "macos_version_id": xcMutationResourceIDSchema("Compatible macOS version ID") ] if isCreate { - properties["product_id"] = xcMutationIDSchema("Xcode Cloud product ID") - properties["repository_id"] = xcMutationIDSchema("SCM repository ID") + properties["product_id"] = xcMutationResourceIDSchema("Xcode Cloud product ID") + properties["repository_id"] = xcMutationResourceIDSchema("SCM repository ID") return xcMutationObjectSchema( properties: properties, required: [ @@ -115,7 +115,7 @@ private extension XcodeCloudWorker { ) } - properties["workflow_id"] = xcMutationIDSchema("Xcode Cloud workflow ID") + properties["workflow_id"] = xcMutationResourceIDSchema("Xcode Cloud workflow ID") return xcMutationObjectSchema(properties: properties, required: ["workflow_id"]) } @@ -290,6 +290,15 @@ private extension XcodeCloudWorker { ]) } + func xcMutationResourceIDSchema(_ description: String) -> Value { + .object([ + "type": .string("string"), + "description": .string("\(description); canonical App Store Connect resource ID"), + "minLength": .int(1), + "pattern": .string(#"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"#) + ]) + } + func xcMutationStringSchema(_ description: String) -> Value { .object([ "type": .string("string"), diff --git a/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+ToolDefinitions.swift index 68f5f0f..e47bce8 100644 --- a/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/XcodeCloudWorker/XcodeCloudWorker+ToolDefinitions.swift @@ -9,7 +9,7 @@ extension XcodeCloudWorker { inputSchema: baseSchema( properties: listProperties([ "product_type": enumListSchema("Filter by one or more product types", values: ["APP", "FRAMEWORK"]), - "app_id": stringListSchema("Filter by one or more related App Store Connect app IDs"), + "app_id": identifierListSchema("Filter by one or more related App Store Connect app IDs"), "include": includeSchema("Related resources to include", values: ["app", "bundleId", "primaryRepositories"]), "primary_repositories_limit": integerSchema( "Maximum included primary repositories; requires include=primaryRepositories", @@ -61,7 +61,7 @@ extension XcodeCloudWorker { inputSchema: baseSchema( properties: listProperties([ "product_id": nonEmptyStringSchema("Xcode Cloud product ID"), - "build_id": stringListSchema("Filter by one or more related App Store Connect build IDs"), + "build_id": identifierListSchema("Filter by one or more related App Store Connect build IDs"), "sort": enumListSchema("Sort by one or more build number expressions", values: ["number", "-number"]), "include": includeSchema("Related resources to include", values: ["builds", "workflow", "product", "sourceBranchOrTag", "destinationBranch", "pullRequest"]), "builds_limit": integerSchema( @@ -96,7 +96,7 @@ extension XcodeCloudWorker { inputSchema: baseSchema( properties: listProperties([ "workflow_id": nonEmptyStringSchema("Xcode Cloud workflow ID"), - "build_id": stringListSchema("Filter by one or more related App Store Connect build IDs"), + "build_id": identifierListSchema("Filter by one or more related App Store Connect build IDs"), "sort": enumListSchema("Sort by one or more build number expressions", values: ["number", "-number"]), "include": includeSchema("Related resources to include", values: ["builds", "workflow", "product", "sourceBranchOrTag", "destinationBranch", "pullRequest"]), "builds_limit": integerSchema( @@ -178,11 +178,11 @@ extension XcodeCloudWorker { "Filter by one or more build audience types", values: ["INTERNAL_ONLY", "APP_STORE_ELIGIBLE"] ), - "pre_release_version_ids": stringListSchema("Filter by one or more pre-release version IDs"), - "app_ids": stringListSchema("Filter by one or more App Store Connect app IDs"), - "beta_group_ids": stringListSchema("Filter by one or more beta group IDs"), - "app_store_version_ids": stringListSchema("Filter by one or more App Store version IDs"), - "build_ids": stringListSchema("Filter by one or more build IDs"), + "pre_release_version_ids": identifierListSchema("Filter by one or more pre-release version IDs"), + "app_ids": identifierListSchema("Filter by one or more App Store Connect app IDs"), + "beta_group_ids": identifierListSchema("Filter by one or more beta group IDs"), + "app_store_version_ids": identifierListSchema("Filter by one or more App Store version IDs"), + "build_ids": identifierListSchema("Filter by one or more build IDs"), "uses_non_exempt_encryption_set": boolSchema("Filter by whether the usesNonExemptEncryption attribute is present"), "include": includeSchema( "Related resources to include", @@ -420,7 +420,7 @@ extension XcodeCloudWorker { inputSchema: baseSchema( properties: listProperties([ "provider_id": nonEmptyStringSchema("SCM provider ID"), - "repository_id": stringListSchema("Filter by one or more repository IDs"), + "repository_id": identifierListSchema("Filter by one or more repository IDs"), "include": includeSchema("Related resources to include", values: ["scmProvider", "defaultBranch"]) ]), required: ["provider_id"] @@ -434,7 +434,7 @@ extension XcodeCloudWorker { description: "List repositories available to Xcode Cloud.", inputSchema: baseSchema( properties: listProperties([ - "repository_id": stringListSchema("Filter by one or more repository IDs"), + "repository_id": identifierListSchema("Filter by one or more repository IDs"), "include": includeSchema("Related resources to include", values: ["scmProvider", "defaultBranch"]) ]) ) @@ -545,8 +545,9 @@ extension XcodeCloudWorker { private func nonEmptyStringSchema(_ description: String) -> Value { .object([ "type": .string("string"), - "description": .string(description), - "minLength": .int(1) + "description": .string("\(description); canonical App Store Connect resource ID"), + "minLength": .int(1), + "pattern": .string(#"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"#) ]) } @@ -581,6 +582,17 @@ extension XcodeCloudWorker { listSchema(description: description, item: .object(["type": .string("string"), "minLength": .int(1)])) } + private func identifierListSchema(_ description: String) -> Value { + listSchema( + description: description, + item: .object([ + "type": .string("string"), + "minLength": .int(1), + "pattern": .string(#"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"#) + ]) + ) + } + private func enumListSchema(_ description: String, values: [String]) -> Value { listSchema( description: description, diff --git a/Tests/ASCMCPTests/Fixtures/app.json b/Tests/ASCMCPTests/Fixtures/app.json index b1a07d9..82df3f6 100644 --- a/Tests/ASCMCPTests/Fixtures/app.json +++ b/Tests/ASCMCPTests/Fixtures/app.json @@ -8,7 +8,8 @@ "sku": "TEST_SKU", "primaryLocale": "en-US", "isOrEverWasMadeForKids": false, - "availableInNewTerritories": true + "accessibilityUrl": "https://example.com/accessibility", + "streamlinedPurchasingEnabled": true }, "relationships": { "appStoreVersions": { diff --git a/Tests/ASCMCPTests/Fixtures/metrics_xcode_metrics.json b/Tests/ASCMCPTests/Fixtures/metrics_xcode_metrics.json index a3c9052..a3d2e74 100644 --- a/Tests/ASCMCPTests/Fixtures/metrics_xcode_metrics.json +++ b/Tests/ASCMCPTests/Fixtures/metrics_xcode_metrics.json @@ -34,6 +34,13 @@ "metrics": [ { "identifier": "diskSpaceUsed", + "goalKeys": [ + { + "goalKey": "storageP90", + "lowerBound": 0, + "upperBound": 200 + } + ], "unit": { "identifier": "megabytes", "displayName": "MB" diff --git a/Tests/ASCMCPTests/Helpers/MCPResultBuilderTests.swift b/Tests/ASCMCPTests/Helpers/MCPResultBuilderTests.swift index 66f4346..8b2b158 100644 --- a/Tests/ASCMCPTests/Helpers/MCPResultBuilderTests.swift +++ b/Tests/ASCMCPTests/Helpers/MCPResultBuilderTests.swift @@ -90,11 +90,13 @@ struct MCPResultBuilderTests { #expect(payload["operationCommitState"] == .string("unknown")) #expect(payload["outcomeUnknown"] == .bool(true)) #expect(payload["retrySafe"] == .bool(false)) + #expect(payload["inspectionRequired"] == .bool(true)) #expect(details["type"] == .string("delete_unknown")) #expect(details["method"] == .string("DELETE")) #expect(details["operationCommitState"] == .string("unknown")) #expect(details["outcomeUnknown"] == .bool(true)) #expect(details["retrySafe"] == .bool(false)) + #expect(details["inspectionRequired"] == .bool(true)) #expect(try exactJSONMirror(from: normalized) == structuredJSON(from: normalized)) #expect(MCPResult.normalizeForTransport(normalized) == normalized) } @@ -118,6 +120,7 @@ struct MCPResultBuilderTests { #expect(payload["operationCommitState"] == .string("committed_unverified")) #expect(payload["operationCommitted"] == .bool(true)) + #expect(payload["outcomeUnknown"] == .bool(false)) #expect(payload["retrySafe"] == .bool(false)) #expect(payload["inspectionRequired"] == .bool(true)) #expect(details["type"] == .string("delete_unverified")) @@ -125,6 +128,7 @@ struct MCPResultBuilderTests { #expect(details["statusCode"] == .int(202)) #expect(details["operationCommitState"] == .string("committed_unverified")) #expect(details["operationCommitted"] == .bool(true)) + #expect(details["outcomeUnknown"] == .bool(false)) #expect(details["retrySafe"] == .bool(false)) #expect(details["inspectionRequired"] == .bool(true)) #expect(humanText.contains("api_token=[REDACTED]")) @@ -137,6 +141,47 @@ struct MCPResultBuilderTests { #expect(MCPResult.normalizeForTransport(normalized) == normalized) } + @Test("typed mutation errors expose unknown and committed-unverified states") + func typedMutationErrorsExposeRecoveryStates() throws { + let unknown = MCPResult.error( + ASCError.mutationOutcomeUnknown( + method: "POST", + cause: .api("upstream unavailable", 503) + ) + ) + let unverified = MCPResult.error( + ASCError.mutationCommittedUnverified( + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 202, + cause: nil + ) + ) + + guard case .object(let unknownPayload)? = unknown.structuredContent, + case .object(let unknownDetails)? = unknownPayload["details"], + case .object(let unverifiedPayload)? = unverified.structuredContent, + case .object(let unverifiedDetails)? = unverifiedPayload["details"] else { + Issue.record("Expected structured mutation recovery states") + return + } + + #expect(unknownPayload["operationCommitState"] == .string("unknown")) + #expect(unknownPayload["outcomeUnknown"] == .bool(true)) + #expect(unknownPayload["retrySafe"] == .bool(false)) + #expect(unknownPayload["inspectionRequired"] == .bool(true)) + #expect(unknownDetails["type"] == .string("mutation_unknown")) + #expect(unknownDetails["method"] == .string("POST")) + #expect(unverifiedPayload["operationCommitState"] == .string("committed_unverified")) + #expect(unverifiedPayload["operationCommitted"] == .bool(true)) + #expect(unverifiedPayload["outcomeUnknown"] == .bool(false)) + #expect(unverifiedPayload["inspectionRequired"] == .bool(true)) + #expect(unverifiedDetails["type"] == .string("mutation_unverified")) + #expect(unverifiedDetails["method"] == .string("PATCH")) + #expect(unverifiedDetails["expectedStatusCode"] == .int(200)) + #expect(unverifiedDetails["statusCode"] == .int(202)) + } + @Test("error normalization preserves machine status without exposing credentials") func errorNormalizationPreservesMachineStatusSafely() { let result = MCPResult.json( diff --git a/Tests/ASCMCPTests/Models/AppLifecycleModelTests.swift b/Tests/ASCMCPTests/Models/AppLifecycleModelTests.swift index ab8c0d5..a8cb1fb 100644 --- a/Tests/ASCMCPTests/Models/AppLifecycleModelTests.swift +++ b/Tests/ASCMCPTests/Models/AppLifecycleModelTests.swift @@ -5,7 +5,12 @@ import Foundation @Suite("AppLifecycle Model Tests") struct AppLifecycleModelTests { @Test func createVersionRequest() throws { - let request = CreateAppStoreVersionRequest(platform: "IOS", versionString: "2.0", releaseType: "MANUAL", earliestReleaseDate: nil, appId: "app-1") + let request = CreateAppStoreVersionRequest( + platform: "IOS", + versionString: "2.0", + releaseType: .string("MANUAL"), + appId: "app-1" + ) let data = try JSONEncoder().encode(request) let decoded = try JSONDecoder().decode(CreateAppStoreVersionRequest.self, from: data) #expect(decoded.data.attributes.versionString == "2.0") diff --git a/Tests/ASCMCPTests/Models/AppModelTests.swift b/Tests/ASCMCPTests/Models/AppModelTests.swift index 2663300..813e262 100644 --- a/Tests/ASCMCPTests/Models/AppModelTests.swift +++ b/Tests/ASCMCPTests/Models/AppModelTests.swift @@ -5,7 +5,7 @@ import Foundation @Suite("App Model Tests") struct AppModelTests { let appJSON = """ - {"id":"app-1","type":"apps","attributes":{"name":"My App","bundleId":"com.test","sku":"SKU1","primaryLocale":"en-US","isOrEverWasMadeForKids":false,"availableInNewTerritories":true}} + {"id":"app-1","type":"apps","attributes":{"name":"My App","bundleId":"com.test","sku":"SKU1","primaryLocale":"en-US","isOrEverWasMadeForKids":false,"accessibilityUrl":"https://example.com/accessibility","streamlinedPurchasingEnabled":true}} """.data(using: .utf8)! @Test func decodeApp() throws { @@ -21,7 +21,8 @@ struct AppModelTests { #expect(app.attributes?.sku == "SKU1") #expect(app.attributes?.primaryLocale == "en-US") #expect(app.attributes?.isOrEverWasMadeForKids == false) - #expect(app.attributes?.availableInNewTerritories == true) + #expect(app.attributes?.accessibilityUrl == "https://example.com/accessibility") + #expect(app.attributes?.streamlinedPurchasingEnabled == true) } @Test func appDisplayNameExtension() throws { @@ -127,6 +128,23 @@ struct AppModelTests { #expect(response.meta == nil) } + @Test func appsResponsePagingTotalIsOptional() throws { + let json = """ + {"data":[{"id":"a1","type":"apps"}],"links":{"self":"https://api.example.com/apps"},"meta":{"paging":{"limit":10}}} + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ASCAppsResponse.self, from: json) + #expect(response.meta?.paging.total == nil) + #expect(response.totalCount == 1) + } + + @Test func appsResponseDoesNotInventTotalBeforeLastPage() throws { + let json = """ + {"data":[{"id":"a1","type":"apps"}],"links":{"self":"https://api.example.com/apps","next":"https://api.example.com/apps?cursor=2"},"meta":{"paging":{"limit":10}}} + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ASCAppsResponse.self, from: json) + #expect(response.totalCount == nil) + } + @Test func appResponseSingle() throws { let json = """ {"data":{"id":"app-1","type":"apps"},"links":{"self":"https://api.example.com/apps/app-1"}} diff --git a/Tests/ASCMCPTests/Models/AppStoreVersionModelTests.swift b/Tests/ASCMCPTests/Models/AppStoreVersionModelTests.swift index a0e09e7..37ebf77 100644 --- a/Tests/ASCMCPTests/Models/AppStoreVersionModelTests.swift +++ b/Tests/ASCMCPTests/Models/AppStoreVersionModelTests.swift @@ -84,6 +84,15 @@ struct AppStoreVersionModelTests { #expect(response.data[0].version == "1.0") } + @Test func versionsResponsePagingTotalIsOptional() throws { + let json = """ + {"data":[],"links":{"self":"https://api.example.com/versions"},"meta":{"paging":{"limit":10}}} + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ASCAppStoreVersionsResponse.self, from: json) + #expect(response.meta?.paging?.total == nil) + #expect(response.meta?.paging?.limit == 10) + } + @Test func singleVersionResponseDecode() throws { let json = """ {"data":{"id":"v1","type":"appStoreVersions","attributes":{"versionString":"1.0"}}} diff --git a/Tests/ASCMCPTests/Models/BuildBetaDetailModelTests.swift b/Tests/ASCMCPTests/Models/BuildBetaDetailModelTests.swift index b97417a..f7f57d9 100644 --- a/Tests/ASCMCPTests/Models/BuildBetaDetailModelTests.swift +++ b/Tests/ASCMCPTests/Models/BuildBetaDetailModelTests.swift @@ -11,9 +11,9 @@ struct BuildBetaDetailModelTests { let detail = try JSONDecoder().decode(ASCBuildBetaDetail.self, from: json) #expect(detail.id == "bbd-1") #expect(detail.type == "buildBetaDetails") - #expect(detail.attributes.autoNotifyEnabled == true) - #expect(detail.attributes.internalBuildState == "PROCESSING") - #expect(detail.attributes.externalBuildState == "IN_BETA_TESTING") + #expect(detail.attributes?.autoNotifyEnabled == true) + #expect(detail.attributes?.internalBuildState == "PROCESSING") + #expect(detail.attributes?.externalBuildState == "IN_BETA_TESTING") } @Test func decodeBetaDetailMinimal() throws { @@ -22,9 +22,9 @@ struct BuildBetaDetailModelTests { """.data(using: .utf8)! let detail = try JSONDecoder().decode(ASCBuildBetaDetail.self, from: json) #expect(detail.id == "bbd-2") - #expect(detail.attributes.autoNotifyEnabled == nil) - #expect(detail.attributes.internalBuildState == nil) - #expect(detail.attributes.externalBuildState == nil) + #expect(detail.attributes?.autoNotifyEnabled == nil) + #expect(detail.attributes?.internalBuildState == nil) + #expect(detail.attributes?.externalBuildState == nil) } @Test func betaBuildLocalization() throws { @@ -34,8 +34,8 @@ struct BuildBetaDetailModelTests { let loc = try JSONDecoder().decode(ASCBetaBuildLocalization.self, from: json) #expect(loc.id == "bbl-1") #expect(loc.type == "betaBuildLocalizations") - #expect(loc.attributes.locale == "en-US") - #expect(loc.attributes.whatsNew == "Beta fixes") + #expect(loc.attributes?.locale == "en-US") + #expect(loc.attributes?.whatsNew == "Beta fixes") } @Test func betaBuildLocalizationWithBuildRelationship() throws { @@ -43,8 +43,8 @@ struct BuildBetaDetailModelTests { {"type":"betaBuildLocalizations","id":"bbl-2","attributes":{"locale":"de-DE","whatsNew":"Neue Features"},"relationships":{"build":{"data":{"type":"builds","id":"build-1"}}}} """.data(using: .utf8)! let loc = try JSONDecoder().decode(ASCBetaBuildLocalization.self, from: json) - #expect(loc.attributes.locale == "de-DE") - #expect(loc.attributes.whatsNew == "Neue Features") + #expect(loc.attributes?.locale == "de-DE") + #expect(loc.attributes?.whatsNew == "Neue Features") #expect(loc.relationships?.build?.data?.type == "builds") #expect(loc.relationships?.build?.data?.id == "build-1") } @@ -56,9 +56,9 @@ struct BuildBetaDetailModelTests { let group = try JSONDecoder().decode(ASCBetaGroup.self, from: json) #expect(group.id == "bg-1") #expect(group.type == "betaGroups") - #expect(group.attributes.name == "Internal Testers") - #expect(group.attributes.isInternalGroup == true) - #expect(group.attributes.publicLinkEnabled == false) + #expect(group.attributes?.name == "Internal Testers") + #expect(group.attributes?.isInternalGroup == true) + #expect(group.attributes?.publicLinkEnabled == false) } @Test func betaGroupFullAttributes() throws { @@ -66,10 +66,10 @@ struct BuildBetaDetailModelTests { {"type":"betaGroups","id":"bg-2","attributes":{"name":"External","isInternalGroup":false,"hasAccessToAllBuilds":true,"publicLinkEnabled":true,"publicLinkLimit":100,"publicLinkLimitEnabled":true,"feedbackEnabled":true}} """.data(using: .utf8)! let group = try JSONDecoder().decode(ASCBetaGroup.self, from: json) - #expect(group.attributes.hasAccessToAllBuilds == true) - #expect(group.attributes.publicLinkLimit == 100) - #expect(group.attributes.publicLinkLimitEnabled == true) - #expect(group.attributes.feedbackEnabled == true) + #expect(group.attributes?.hasAccessToAllBuilds == true) + #expect(group.attributes?.publicLinkLimit == 100) + #expect(group.attributes?.publicLinkLimitEnabled == true) + #expect(group.attributes?.feedbackEnabled == true) } @Test func betaTester() throws { @@ -79,10 +79,10 @@ struct BuildBetaDetailModelTests { let tester = try JSONDecoder().decode(ASCBetaTester.self, from: json) #expect(tester.id == "bt-1") #expect(tester.type == "betaTesters") - #expect(tester.attributes.email == "test@test.com") - #expect(tester.attributes.firstName == "John") - #expect(tester.attributes.lastName == "Doe") - #expect(tester.attributes.state == "ACCEPTED") + #expect(tester.attributes?.email == "test@test.com") + #expect(tester.attributes?.firstName == "John") + #expect(tester.attributes?.lastName == "Doe") + #expect(tester.attributes?.state == "ACCEPTED") } @Test func betaTesterAppDevices() throws { @@ -90,7 +90,7 @@ struct BuildBetaDetailModelTests { {"type":"betaTesters","id":"bt-devices","attributes":{"appDevices":[{"model":"iPhone17,1","platform":"IOS","osVersion":"18.0","appBuildVersion":"42"},{"model":"Mac15,3","platform":"MAC_OS","osVersion":"15.0","appBuildVersion":"43"},{"platform":"TV_OS"},{"platform":"WATCH_OS"},{"platform":"VISION_OS"}]}} """.data(using: .utf8)! let tester = try JSONDecoder().decode(ASCBetaTester.self, from: json) - let devices = try #require(tester.attributes.appDevices) + let devices = try #require(tester.attributes?.appDevices) #expect(devices.count == 5) #expect(devices.compactMap(\.platform) == [.iOS, .macOS, .tvOS, .watchOS, .visionOS]) @@ -115,10 +115,10 @@ struct BuildBetaDetailModelTests { {"type":"betaTesters","id":"bt-2","attributes":{}} """.data(using: .utf8)! let tester = try JSONDecoder().decode(ASCBetaTester.self, from: json) - #expect(tester.attributes.email == nil) - #expect(tester.attributes.firstName == nil) - #expect(tester.attributes.state == nil) - #expect(tester.attributes.appDevices == nil) + #expect(tester.attributes?.email == nil) + #expect(tester.attributes?.firstName == nil) + #expect(tester.attributes?.state == nil) + #expect(tester.attributes?.appDevices == nil) } @Test func betaIncludedResourceBuild() throws { @@ -141,7 +141,7 @@ struct BuildBetaDetailModelTests { let included = try JSONDecoder().decode(ASCBetaIncludedResource.self, from: json) if case .betaBuildLocalization(let loc) = included { #expect(loc.id == "bbl-1") - #expect(loc.attributes.locale == "en-US") + #expect(loc.attributes?.locale == "en-US") } else { Issue.record("Expected localization") } @@ -175,7 +175,7 @@ struct BuildBetaDetailModelTests { """.data(using: .utf8)! let response = try JSONDecoder().decode(ASCBetaGroupsResponse.self, from: json) #expect(response.data.count == 2) - #expect(response.data[0].attributes.name == "G1") + #expect(response.data[0].attributes?.name == "G1") } @Test func betaTestersResponse() throws { @@ -184,7 +184,7 @@ struct BuildBetaDetailModelTests { """.data(using: .utf8)! let response = try JSONDecoder().decode(ASCBetaTestersResponse.self, from: json) #expect(response.data.count == 1) - #expect(response.data[0].attributes.email == "a@b.com") + #expect(response.data[0].attributes?.email == "a@b.com") } @Test func buildBetaDetailResponse() throws { @@ -193,6 +193,32 @@ struct BuildBetaDetailModelTests { """.data(using: .utf8)! let response = try JSONDecoder().decode(ASCBuildBetaDetailResponse.self, from: json) #expect(response.data.id == "bbd-1") - #expect(response.data.attributes.autoNotifyEnabled == false) + #expect(response.data.attributes?.autoNotifyEnabled == false) + } + + @Test func sparseTestFlightResourcesDecodeWithoutAttributes() throws { + let decoder = JSONDecoder() + + let detail = try decoder.decode( + ASCBuildBetaDetail.self, + from: #"{"type":"buildBetaDetails","id":"detail-1"}"#.data(using: .utf8)! + ) + let localization = try decoder.decode( + ASCBetaBuildLocalization.self, + from: #"{"type":"betaBuildLocalizations","id":"localization-1"}"#.data(using: .utf8)! + ) + let group = try decoder.decode( + ASCBetaGroup.self, + from: #"{"type":"betaGroups","id":"group-1"}"#.data(using: .utf8)! + ) + let tester = try decoder.decode( + ASCBetaTester.self, + from: #"{"type":"betaTesters","id":"tester-1"}"#.data(using: .utf8)! + ) + + #expect(detail.attributes == nil) + #expect(localization.attributes == nil) + #expect(group.attributes == nil) + #expect(tester.attributes == nil) } } diff --git a/Tests/ASCMCPTests/Models/BuildModelTests.swift b/Tests/ASCMCPTests/Models/BuildModelTests.swift index 76b34b0..a3964dd 100644 --- a/Tests/ASCMCPTests/Models/BuildModelTests.swift +++ b/Tests/ASCMCPTests/Models/BuildModelTests.swift @@ -132,7 +132,7 @@ struct BuildModelTests { let included = try JSONDecoder().decode(ASCBuildIncludedResource.self, from: json) if case .buildBetaDetail(let detail) = included { #expect(detail.id == "bbd-1") - #expect(detail.attributes.autoNotifyEnabled == true) + #expect(detail.attributes?.autoNotifyEnabled == true) } else { Issue.record("Expected buildBetaDetail") } diff --git a/Tests/ASCMCPTests/Models/InAppPurchaseModelTests.swift b/Tests/ASCMCPTests/Models/InAppPurchaseModelTests.swift index b29a485..3ed8ac5 100644 --- a/Tests/ASCMCPTests/Models/InAppPurchaseModelTests.swift +++ b/Tests/ASCMCPTests/Models/InAppPurchaseModelTests.swift @@ -42,11 +42,18 @@ struct InAppPurchaseModelTests { } @Test func updateIAPRequest() throws { - let request = UpdateInAppPurchaseV2Request(data: .init(id: "iap-1", attributes: .init(name: "Updated", reviewNote: "New note", familySharable: true))) + let request = UpdateInAppPurchaseV2Request(data: .init( + id: "iap-1", + attributes: .init( + name: .value("Updated"), + reviewNote: .value("New note"), + familySharable: .value(true) + ) + )) let data = try JSONEncoder().encode(request) let decoded = try JSONDecoder().decode(UpdateInAppPurchaseV2Request.self, from: data) #expect(decoded.data.id == "iap-1") - #expect(decoded.data.attributes.name == "Updated") + #expect(decoded.data.attributes.name == .value("Updated")) } @Test func iapResponseSingle() throws { diff --git a/Tests/ASCMCPTests/Models/LocalizationModelTests.swift b/Tests/ASCMCPTests/Models/LocalizationModelTests.swift index 183c656..e4424bc 100644 --- a/Tests/ASCMCPTests/Models/LocalizationModelTests.swift +++ b/Tests/ASCMCPTests/Models/LocalizationModelTests.swift @@ -116,6 +116,15 @@ struct LocalizationModelTests { #expect(response.data[1].locale == "ru-RU") } + @Test func localizationsResponsePagingTotalIsOptional() throws { + let json = """ + {"data":[],"links":{"self":"https://api.example.com/locs"},"meta":{"paging":{"limit":10}}} + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ASCAppStoreVersionLocalizationsResponse.self, from: json) + #expect(response.meta?.paging?.total == nil) + #expect(response.meta?.paging?.limit == 10) + } + @Test func localizationResponseSingle() throws { let json = """ {"data":{"id":"l1","type":"appStoreVersionLocalizations","attributes":{"locale":"en-US","whatsNew":"Bug fixes"}}} diff --git a/Tests/ASCMCPTests/Models/MetricsModelTests.swift b/Tests/ASCMCPTests/Models/MetricsModelTests.swift index 3ee5c26..ac9b1e5 100644 --- a/Tests/ASCMCPTests/Models/MetricsModelTests.swift +++ b/Tests/ASCMCPTests/Models/MetricsModelTests.swift @@ -13,7 +13,12 @@ struct MetricsModelTests { #expect(insight.metricCategory == "STORAGE") #expect(insight.populations?.first?.device == "iPhone15,2") - let dataset = try #require(response.productData?.first?.metricCategories?.first?.metrics?.first?.datasets?.first) + let metric = try #require(response.productData?.first?.metricCategories?.first?.metrics?.first) + let goalKey = try #require(metric.goalKeys?.first) + #expect(goalKey.goalKey == "storageP90") + #expect(goalKey.lowerBound == 0) + #expect(goalKey.upperBound == 200) + let dataset = try #require(metric.datasets?.first) let point = try #require(dataset.points?.first) #expect(point.errorMargin == 2.25) #expect(point.goal == "Less than 200 MB") diff --git a/Tests/ASCMCPTests/Models/ProvisioningModelTests.swift b/Tests/ASCMCPTests/Models/ProvisioningModelTests.swift index 311d1a2..5e014fc 100644 --- a/Tests/ASCMCPTests/Models/ProvisioningModelTests.swift +++ b/Tests/ASCMCPTests/Models/ProvisioningModelTests.swift @@ -10,7 +10,7 @@ struct ProvisioningModelTests { """.data(using: .utf8)! let bid = try JSONDecoder().decode(ASCBundleId.self, from: json) #expect(bid.id == "bid-1") - #expect(bid.attributes.identifier == "com.test") + #expect(bid.attributes?.identifier == "com.test") } @Test func decodeDevice() throws { @@ -18,8 +18,8 @@ struct ProvisioningModelTests { {"type":"devices","id":"dev-1","attributes":{"name":"iPhone","platform":"IOS","udid":"AAAA","deviceClass":"IPHONE","status":"ENABLED"}} """.data(using: .utf8)! let device = try JSONDecoder().decode(ASCDevice.self, from: json) - #expect(device.attributes.name == "iPhone") - #expect(device.attributes.status == "ENABLED") + #expect(device.attributes?.name == "iPhone") + #expect(device.attributes?.status == "ENABLED") } @Test func decodeCertificate() throws { @@ -27,7 +27,7 @@ struct ProvisioningModelTests { {"type":"certificates","id":"cert-1","attributes":{"name":"iOS Dist","certificateType":"IOS_DISTRIBUTION","serialNumber":"ABC123"}} """.data(using: .utf8)! let cert = try JSONDecoder().decode(ASCCertificate.self, from: json) - #expect(cert.attributes.certificateType == "IOS_DISTRIBUTION") + #expect(cert.attributes?.certificateType == "IOS_DISTRIBUTION") } @Test func decodeProfile() throws { @@ -35,7 +35,7 @@ struct ProvisioningModelTests { {"type":"profiles","id":"prof-1","attributes":{"name":"Test Profile","profileType":"IOS_APP_STORE","profileState":"ACTIVE","uuid":"1234-5678"}} """.data(using: .utf8)! let profile = try JSONDecoder().decode(ASCProfile.self, from: json) - #expect(profile.attributes.profileState == "ACTIVE") + #expect(profile.attributes?.profileState == "ACTIVE") } @Test func decodeCapability() throws { @@ -43,8 +43,8 @@ struct ProvisioningModelTests { {"type":"bundleIdCapabilities","id":"cap-1","attributes":{"capabilityType":"PUSH_NOTIFICATIONS","settings":[{"key":"PUSH","name":"Push","options":[{"key":"ON","enabled":true}]}]}} """.data(using: .utf8)! let cap = try JSONDecoder().decode(ASCBundleIdCapability.self, from: json) - #expect(cap.attributes.capabilityType == "PUSH_NOTIFICATIONS") - #expect(cap.attributes.settings?.first?.key == "PUSH") + #expect(cap.attributes?.capabilityType == "PUSH_NOTIFICATIONS") + #expect(cap.attributes?.settings?.first?.key == "PUSH") } @Test func bundleIdResponse() throws { @@ -60,7 +60,7 @@ struct ProvisioningModelTests { let data = try JSONEncoder().encode(request) let decoded = try JSONDecoder().decode(CreateBundleIdRequest.self, from: data) #expect(decoded.data.attributes.identifier == "com.new") - #expect(decoded.data.attributes.seedId == "SEED123") + #expect(decoded.data.attributes.seedId == .value("SEED123")) } @Test func registerDeviceRequest() throws { @@ -69,4 +69,83 @@ struct ProvisioningModelTests { let decoded = try JSONDecoder().decode(RegisterDeviceRequest.self, from: data) #expect(decoded.data.attributes.udid == "AAAA-BBBB") } + + @Test("provisioning resources accept Apple responses without attributes") + func sparseResources() throws { + let bundleId = try JSONDecoder().decode( + ASCBundleId.self, + from: Data(#"{"type":"bundleIds","id":"bundle-1"}"#.utf8) + ) + let device = try JSONDecoder().decode( + ASCDevice.self, + from: Data(#"{"type":"devices","id":"device-1"}"#.utf8) + ) + let certificate = try JSONDecoder().decode( + ASCCertificate.self, + from: Data(#"{"type":"certificates","id":"certificate-1"}"#.utf8) + ) + let profile = try JSONDecoder().decode( + ASCProfile.self, + from: Data(#"{"type":"profiles","id":"profile-1"}"#.utf8) + ) + let capability = try JSONDecoder().decode( + ASCBundleIdCapability.self, + from: Data(#"{"type":"bundleIdCapabilities","id":"capability-1"}"#.utf8) + ) + + #expect(bundleId.attributes == nil) + #expect(device.attributes == nil) + #expect(certificate.attributes == nil) + #expect(profile.attributes == nil) + #expect(capability.attributes == nil) + } + + @Test("provisioning requests preserve explicit null attributes") + func nullableRequestAttributes() throws { + let bundleRequest = CreateBundleIdRequest( + data: .init( + attributes: .init( + name: "App", + identifier: "com.example.app", + platform: "IOS", + nullableSeedId: .null + ) + ) + ) + let deviceRequest = UpdateDeviceRequest( + data: .init( + id: "device-1", + attributes: .init(nullableName: .null, nullableStatus: .null) + ) + ) + + let bundleObject = try provisioningModelObject(JSONEncoder().encode(bundleRequest)) + let bundleData = try provisioningModelObject(bundleObject["data"]) + let bundleAttributes = try provisioningModelObject(bundleData["attributes"]) + #expect(bundleAttributes["seedId"] is NSNull) + + let deviceObject = try provisioningModelObject(JSONEncoder().encode(deviceRequest)) + let deviceData = try provisioningModelObject(deviceObject["data"]) + let deviceAttributes = try provisioningModelObject(deviceData["attributes"]) + #expect(deviceAttributes["name"] is NSNull) + #expect(deviceAttributes["status"] is NSNull) + } +} + +private func provisioningModelObject(_ data: Data) throws -> [String: Any] { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProvisioningModelTestFailure.expectedObject + } + return object +} + +private func provisioningModelObject(_ value: Any?) throws -> [String: Any] { + guard let object = value as? [String: Any] else { + throw ProvisioningModelTestFailure.expectedObject + } + return object +} + +private enum ProvisioningModelTestFailure: Error { + case expectedObject } diff --git a/Tests/ASCMCPTests/Models/UserModelTests.swift b/Tests/ASCMCPTests/Models/UserModelTests.swift index 9aa1fa9..4969749 100644 --- a/Tests/ASCMCPTests/Models/UserModelTests.swift +++ b/Tests/ASCMCPTests/Models/UserModelTests.swift @@ -43,6 +43,64 @@ struct UserModelTests { let data = try JSONEncoder().encode(request) let decoded = try JSONDecoder().decode(UpdateUserRequest.self, from: data) #expect(decoded.data.id == "u1") - #expect(decoded.data.attributes.roles == ["ADMIN", "APP_MANAGER"]) + #expect(decoded.data.attributes.roles == .value(["ADMIN", "APP_MANAGER"])) } + + @Test("user request models preserve omission explicit null and values") + func nullableUserRequestAttributes() throws { + let update = UpdateUserRequest( + data: .init( + id: "u1", + attributes: .init( + nullableRoles: .null, + nullableAllAppsVisible: .value(false), + nullableProvisioningAllowed: .null + ) + ) + ) + let invitation = CreateUserInvitationRequest( + data: .init( + attributes: .init( + email: "new@example.com", + firstName: "New", + lastName: "User", + roles: ["DEVELOPER"], + nullableAllAppsVisible: nil, + nullableProvisioningAllowed: .null + ), + relationships: nil + ) + ) + + let updateObject = try userModelObject(JSONEncoder().encode(update)) + let updateData = try userModelObject(updateObject["data"]) + let updateAttributes = try userModelObject(updateData["attributes"]) + #expect(updateAttributes["roles"] is NSNull) + #expect(updateAttributes["allAppsVisible"] as? Bool == false) + #expect(updateAttributes["provisioningAllowed"] is NSNull) + + let invitationObject = try userModelObject(JSONEncoder().encode(invitation)) + let invitationData = try userModelObject(invitationObject["data"]) + let invitationAttributes = try userModelObject(invitationData["attributes"]) + #expect(invitationAttributes.keys.contains("allAppsVisible") == false) + #expect(invitationAttributes["provisioningAllowed"] is NSNull) + } +} + +private func userModelObject(_ data: Data) throws -> [String: Any] { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw UserModelTestFailure.expectedObject + } + return object +} + +private func userModelObject(_ value: Any?) throws -> [String: Any] { + guard let object = value as? [String: Any] else { + throw UserModelTestFailure.expectedObject + } + return object +} + +private enum UserModelTestFailure: Error { + case expectedObject } diff --git a/Tests/ASCMCPTests/Services/HTTPClientTests.swift b/Tests/ASCMCPTests/Services/HTTPClientTests.swift index 80cd109..c6b942f 100644 --- a/Tests/ASCMCPTests/Services/HTTPClientTests.swift +++ b/Tests/ASCMCPTests/Services/HTTPClientTests.swift @@ -163,6 +163,41 @@ struct HTTPClientTests { #expect(await transport.requestCount() == 2) } + @Test("generic DELETE rejects a non-empty HTTP 204 response body") + func genericDeleteRejectsNonEmpty204Response() async throws { + for includesRequestBody in [false, true] { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 204, headers: [:], body: #"{"unexpected":true}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + if includesRequestBody { + _ = try await client.delete( + "/v1/resources/resource-1/relationships/apps", + body: Data(#"{"data":[]}"#.utf8) + ) + } else { + _ = try await client.delete("/v1/resources/resource-1") + } + Issue.record("Expected a committed-unverified DELETE response") + } catch let error as ASCError { + guard case .deleteCommittedUnverified(let statusCode) = error else { + Issue.record("Expected deleteCommittedUnverified, got \(error)") + continue + } + #expect(statusCode == 204) + } + + #expect(await transport.requestCount() == 1) + } + } + @Test( "generic DELETE treats unexpected successful status as committed but unverified", arguments: [200, 201, 202, 206, 299] @@ -374,6 +409,39 @@ struct HTTPClientTests { #expect(await transport.requestCount() == 2) } + @Test("typed PUT decode failures are committed but unverified") + func typedPutDecodeFailureIsCommittedUnverified() async throws { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 200, headers: [:], body: #"{"unexpected":true}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + let _: MutationDocument = try await client.put( + "/v1/resources/resource-1", + body: MutationRequest(value: "requested"), + as: MutationDocument.self + ) + Issue.record("Expected committed-unverified PUT decode failure") + } catch let error as ASCError { + guard case .mutationCommittedUnverified( + method: "PUT", + expectedStatusCode: 200, + actualStatusCode: 200, + cause: .some(.parsing(let message)) + ) = error else { + Issue.record("Expected typed committed-unverified PUT error, got \(error)") + return + } + #expect(message.contains("MutationDocument")) + } + } + @Test("mutation receipts preserve the exact successful status") func mutationReceiptsPreserveExactSuccessfulStatus() async throws { let transport = ScriptedHTTPTransport(steps: [ @@ -397,6 +465,211 @@ struct HTTPClientTests { #expect(await transport.requestCount() == 2) } + @Test("generic POST and PATCH require their Apple default success statuses") + func genericMutationsRequireExactDefaultSuccessStatuses() async throws { + for (method, unexpectedStatusCode, expectedStatusCode) in [ + ("POST", 200, 201), + ("PATCH", 204, 200) + ] { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: unexpectedStatusCode, headers: [:], body: #"{"data":{}}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + if method == "POST" { + _ = try await client.post("/v1/resources", body: Data()) + } else { + _ = try await client.patch("/v1/resources/resource-1", body: Data()) + } + Issue.record("Expected exact-status validation for \(method)") + } catch let error as ASCError { + guard case .mutationCommittedUnverified( + let actualMethod, + let actualExpectedStatusCode, + let actualStatusCode, + nil + ) = error else { + Issue.record("Expected committed-unverified \(method), got \(error)") + continue + } + #expect(actualMethod == method) + #expect(actualExpectedStatusCode == expectedStatusCode) + #expect(actualStatusCode == unexpectedStatusCode) + } + + #expect(await transport.requestCount() == 1) + } + } + + @Test("relationship mutations can require Apple's exact 204 status") + func relationshipMutationsAcceptExplicit204() async throws { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 204, headers: [:], body: ""), + .response(statusCode: 204, headers: [:], body: "") + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + let created = try await client.post( + "/v1/resources/resource-1/relationships/items", + body: Data(), + expectedStatusCode: 204 + ) + let replaced = try await client.patch( + "/v1/resources/resource-1/relationships/items", + body: Data(), + expectedStatusCode: 204 + ) + + #expect(created.isEmpty) + #expect(replaced.isEmpty) + #expect(await transport.requestCount() == 2) + } + + @Test("HTTP 204 mutation responses reject an impossible response body") + func relationshipMutationRejectsNonEmpty204Body() async throws { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 204, headers: [:], body: #"{"unexpected":true}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + _ = try await client.post( + "/v1/resources/resource-1/relationships/items", + body: Data(), + expectedStatusCode: 204 + ) + Issue.record("Expected a committed-unverified 204 response") + } catch let error as ASCError { + guard case .mutationCommittedUnverified( + method: "POST", + expectedStatusCode: 204, + actualStatusCode: 204, + cause: .some(.parsing(let message)) + ) = error else { + Issue.record("Expected a typed committed-unverified 204 response, got \(error)") + return + } + #expect(message.contains("must not contain")) + } + } + + @Test("accepted mutation with an undecodable document is committed but unverified") + func typedMutationDecodeFailureIsCommittedUnverified() async throws { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 201, headers: [:], body: #"{"unexpected":true}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + let _: MutationDocument = try await client.post( + "/v1/resources", + body: MutationRequest(value: "requested"), + as: MutationDocument.self + ) + Issue.record("Expected committed-unverified decode failure") + } catch let error as ASCError { + guard case .mutationCommittedUnverified( + method: "POST", + expectedStatusCode: 201, + actualStatusCode: 201, + cause: .some(.parsing(let message)) + ) = error else { + Issue.record("Expected typed committed-unverified error, got \(error)") + return + } + #expect(message.contains("MutationDocument")) + } + } + + @Test("typed PATCH decode failures are committed but unverified") + func typedPatchDecodeFailureIsCommittedUnverified() async throws { + let transport = ScriptedHTTPTransport(steps: [ + .response(statusCode: 200, headers: [:], body: #"{"unexpected":true}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + let _: MutationDocument = try await client.patch( + "/v1/resources/resource-1", + body: MutationRequest(value: "requested"), + as: MutationDocument.self + ) + Issue.record("Expected committed-unverified PATCH decode failure") + } catch let error as ASCError { + guard case .mutationCommittedUnverified( + method: "PATCH", + expectedStatusCode: 200, + actualStatusCode: 200, + cause: .some(.parsing(let message)) + ) = error else { + Issue.record("Expected typed committed-unverified PATCH error, got \(error)") + return + } + #expect(message.contains("MutationDocument")) + } + } + + @Test("POST and PATCH do not repeat ambiguous network failures") + func mutationsDoNotRetryNetworkFailures() async throws { + for method in ["POST", "PATCH"] { + let transport = ScriptedHTTPTransport(steps: [ + .networkFailure, + .response(statusCode: 200, headers: [:], body: #"{"data":{}}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 2 + ) + + do { + if method == "POST" { + _ = try await client.postReceipt("/v1/resources", body: Data()) + } else { + _ = try await client.patchReceipt("/v1/resources/resource-1", body: Data()) + } + Issue.record("Expected an ambiguous network outcome for \(method)") + } catch let error as ASCError { + guard case .mutationOutcomeUnknown(let actualMethod, let cause) = error, + case .network(let message) = cause else { + Issue.record("Expected a typed unknown mutation outcome for \(method), got \(error)") + continue + } + #expect(actualMethod == method) + #expect(message.contains("HTTP request failed")) + } + + #expect(await transport.requestCount() == 1) + } + } + @Test("POST and PATCH do not repeat ambiguous server failures") func mutationsDoNotRepeatAmbiguousServerFailures() async throws { for method in ["POST", "PATCH"] { @@ -423,10 +696,12 @@ struct HTTPClientTests { } Issue.record("Expected HTTP 500 for \(method)") } catch let error as ASCError { - guard case .apiResponse(_, let statusCode) = error else { - Issue.record("Expected a typed API response error for \(method), got \(error)") + guard case .mutationOutcomeUnknown(let actualMethod, let cause) = error, + case .apiResponse(_, let statusCode) = cause else { + Issue.record("Expected a typed unknown mutation outcome for \(method), got \(error)") continue } + #expect(actualMethod == method) #expect(statusCode == 500) } @@ -519,6 +794,18 @@ struct HTTPClientTests { } } +private struct MutationRequest: Codable, Sendable { + let value: String +} + +private struct MutationDocument: Codable, Sendable { + let data: MutationResource +} + +private struct MutationResource: Codable, Sendable { + let id: String +} + private actor MockHTTPTransport: HTTPTransport { struct Response: Sendable { let statusCode: Int diff --git a/Tests/ASCMCPTests/Services/HTTPTransportRedirectSafetyTests.swift b/Tests/ASCMCPTests/Services/HTTPTransportRedirectSafetyTests.swift index 98ee3a6..103c072 100644 --- a/Tests/ASCMCPTests/Services/HTTPTransportRedirectSafetyTests.swift +++ b/Tests/ASCMCPTests/Services/HTTPTransportRedirectSafetyTests.swift @@ -111,8 +111,19 @@ struct HTTPTransportRedirectSafetyTests { } Issue.record("Expected the original HTTP 307 response") } catch let error as ASCError { - guard case .apiResponse(let response, let statusCode) = error else { - Issue.record("Expected an App Store Connect API response, got \(error)") + let cause: ASCError + switch (method, error) { + case ("DELETE", .deleteOutcomeUnknown(let underlying)): + cause = underlying + case (_, .mutationOutcomeUnknown(let actualMethod, let underlying)): + #expect(actualMethod == method) + cause = underlying + default: + Issue.record("Expected a typed unknown mutation outcome, got \(error)") + return + } + guard case .apiResponse(let response, let statusCode) = cause else { + Issue.record("Expected the original App Store Connect redirect response, got \(cause)") return } #expect(statusCode == 307) diff --git a/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift index 4a7caf5..6debc22 100644 --- a/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift @@ -77,11 +77,11 @@ struct BuildUploadsManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_103) + #expect(pin.bound == 1_108) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_762) + #expect(pin.intentionallyOmitted == 1_757) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "733827d5ad0fc66d541bdd51922567a7f7cd7a941b882ca2e89a0ea6d6a1cfee") + #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") } @Test("operation methods paths statuses and effects are exact") diff --git a/Tests/ASCMCPTests/Tooling/CoreAccessManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/CoreAccessManifestContractTests.swift new file mode 100644 index 0000000..2a347c0 --- /dev/null +++ b/Tests/ASCMCPTests/Tooling/CoreAccessManifestContractTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import asc_mcp + +@Suite("Core Access Manifest Contract Tests") +struct CoreAccessManifestContractTests { + @Test("optional relationship linkages declare every fixed resource type") + func optionalRelationshipTypes() throws { + let manifest = try ASCOperationManifestBundle.loadBundled() + let invitation = try #require(manifest.mapping(for: "users_invite")) + let profile = try #require(manifest.mapping(for: "provisioning_create_profile")) + + let invitationInputs = try #require(invitation.operations.first?.inputs) + #expect(invitationInputs.contains { + $0.jsonPointer == "/data/relationships/visibleApps/data/*/type" && + $0.fixedValue == .string("apps") + }) + + let profileInputs = try #require(profile.operations.first?.inputs) + #expect(profileInputs.contains { + $0.jsonPointer == "/data/relationships/bundleId/data/type" && + $0.fixedValue == .string("bundleIds") + }) + #expect(profileInputs.contains { + $0.jsonPointer == "/data/relationships/certificates/data/*/type" && + $0.fixedValue == .string("certificates") + }) + #expect(profileInputs.contains { + $0.jsonPointer == "/data/relationships/devices/data/*/type" && + $0.fixedValue == .string("devices") + }) + } +} diff --git a/Tests/ASCMCPTests/Tooling/OperationToolManifestTests.swift b/Tests/ASCMCPTests/Tooling/OperationToolManifestTests.swift index bfc9899..a3276f9 100644 --- a/Tests/ASCMCPTests/Tooling/OperationToolManifestTests.swift +++ b/Tests/ASCMCPTests/Tooling/OperationToolManifestTests.swift @@ -986,6 +986,30 @@ struct OperationToolManifestTests { } } + @Test("direct Apple DELETE mapping must be destructive") + func directAppleDeleteMustBeDestructive() throws { + let spec = try ASCOpenAPISpec.parse(loadFixture("openapi_minimal.oas")) + let manifest = try loadValidManifest() + let update = try #require(manifest.mapping(for: "apps_update")) + let operation = try #require(update.operations.first) + let invalid = replacing( + update, + effect: .write, + operations: [replacing(operation, method: "delete")] + ) + + let diagnostics = ASCOperationToolAnalyzer().analyze( + spec: spec, + manifest: replacingTool(in: manifest, with: invalid) + ) + + #expect(diagnostics.contains { diagnostic in + diagnostic.code == .toolAppleEffectMismatch && + diagnostic.tool == "apps_update" && + diagnostic.message.contains("DELETE") + }) + } + @Test("bundled manifest tracks the exact public MCP catalog") func bundledManifestTracksPublicCatalog() async throws { let manifest = try ASCOperationManifestBundle.loadBundled() @@ -1123,6 +1147,7 @@ struct OperationToolManifestTests { private func replacing( _ mapping: ASCToolOperationMapping, implementationState: ASCImplementationState? = nil, + effect: ASCToolEffect? = nil, operations: [ASCOperationUse]? = nil, fields: [ASCToolFieldBinding]? = nil, response: ASCResponseMapping? = nil @@ -1131,7 +1156,7 @@ struct OperationToolManifestTests { tool: mapping.tool, kind: mapping.kind, status: mapping.status, - effect: mapping.effect, + effect: effect ?? mapping.effect, implementationState: implementationState ?? mapping.implementationState, replacementTool: mapping.replacementTool, operations: operations ?? mapping.operations, @@ -1144,6 +1169,7 @@ struct OperationToolManifestTests { private func replacing( _ operation: ASCOperationUse, invocationID: String? = nil, + method: String? = nil, role: ASCOperationRole? = nil, inputs: [ASCOperationInputBinding]? = nil, optionalParameterClassifications: [ASCOptionalParameterClassification]? = nil @@ -1151,7 +1177,7 @@ struct OperationToolManifestTests { ASCOperationUse( invocationID: invocationID, operationID: operation.operationID, - method: operation.method, + method: method ?? operation.method, path: operation.path, role: role ?? operation.role, condition: operation.condition, diff --git a/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift index 871918a..bde7fbf 100644 --- a/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift @@ -70,11 +70,11 @@ struct TestFlightManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_103) + #expect(pin.bound == 1_108) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_762) + #expect(pin.intentionallyOmitted == 1_757) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "733827d5ad0fc66d541bdd51922567a7f7cd7a941b882ca2e89a0ea6d6a1cfee") + #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") } @Test("TestFlight operation methods paths and success statuses are exact") diff --git a/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift index 6e15a4a..d5dc1fa 100644 --- a/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift @@ -316,11 +316,11 @@ struct XcodeCloudManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_103) + #expect(pin.bound == 1_108) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_762) + #expect(pin.intentionallyOmitted == 1_757) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "733827d5ad0fc66d541bdd51922567a7f7cd7a941b882ca2e89a0ea6d6a1cfee") + #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") } @Test("selected projections distinguish relationship self from related URLs") diff --git a/Tests/ASCMCPTests/Workers/AccessibilityWorkerTests.swift b/Tests/ASCMCPTests/Workers/AccessibilityWorkerTests.swift index cfea35f..787d000 100644 --- a/Tests/ASCMCPTests/Workers/AccessibilityWorkerTests.swift +++ b/Tests/ASCMCPTests/Workers/AccessibilityWorkerTests.swift @@ -110,6 +110,72 @@ struct AccessibilityWorkerTests { #expect(await transport.requestCount() == 1) } + @Test("present invalid limits fail before network") + func invalidLimitsFailBeforeNetwork() async throws { + let cases: [(String, [String: Value])] = [ + ("accessibility_list", ["app_id": .string("app-1"), "limit": .int(0)]), + ("accessibility_list", ["app_id": .string("app-1"), "limit": .string("25")]), + ("accessibility_list_relationships", ["app_id": .string("app-1"), "limit": .int(201)]) + ] + + for (tool, arguments) in cases { + let transport = TestHTTPTransport(responses: []) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 1 + ) + let worker = AccessibilityWorker(httpClient: client) + + let result = try await worker.handleTool(.init(name: tool, arguments: arguments)) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + + @Test("accepted create and update identity failures are committed unverified") + func mutationIdentityFailuresPreserveCommitState() async throws { + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"apps","id":"decl-1"}}"#) + ]) + let createClient = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: createTransport, + maxRetries: 1 + ) + let create = try await AccessibilityWorker(httpClient: createClient).handleTool(.init( + name: "accessibility_create", + arguments: ["app_id": .string("app-1"), "device_family": .string("IPHONE")] + )) + + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"accessibilityDeclarations","id":"decl-other"}}"#) + ]) + let updateClient = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: updateTransport, + maxRetries: 1 + ) + let update = try await AccessibilityWorker(httpClient: updateClient).handleTool(.init( + name: "accessibility_update", + arguments: ["declaration_id": .string("decl-1"), "publish": .bool(true)] + )) + + for result in [create, update] { + guard case .object(let payload)? = result.structuredContent else { + Issue.record("Expected structured mutation failure") + continue + } + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + } + @Test("request models encode Apple OpenAPI JSON API shape") func requestModelsEncodeAppleShape() throws { let create = ASCAccessibilityDeclarationCreateRequest( @@ -300,10 +366,23 @@ struct AccessibilityWorkerTests { #expect(deviceFamilyAlternatives.count == 2) #expect(stateAlternatives.count == 2) + #expect(deviceFamilyAlternatives.first?.objectValue?["pattern"]?.stringValue != nil) + #expect(stateAlternatives.first?.objectValue?["pattern"]?.stringValue != nil) #expect(limit["minimum"]?.intValue == 1) #expect(limit["maximum"]?.intValue == 200) } + @Test("list manifests declare page count and optional Apple total") + func listManifestOutputParity() throws { + let manifest = try ASCOperationManifestBundle.loadBundled() + for toolName in ["accessibility_list", "accessibility_list_relationships"] { + let mapping = try #require(manifest.mapping(for: toolName)) + let fields = Set(mapping.response.fields.map(\.outputField)) + #expect(fields.contains("count")) + #expect(fields.contains("total")) + } + } + @Test("list sends multi-value filters and sparse fields using Apple query names") func listSendsMultiValueFilters() async throws { let transport = TestHTTPTransport(responses: [ @@ -324,7 +403,7 @@ struct AccessibilityWorkerTests { "device_family": .array([.string("IPHONE"), .string("IPAD")]), "state": .array([.string("DRAFT"), .string("PUBLISHED")]), "fields": .array([.string("deviceFamily"), .string("state")]), - "limit": .int(500) + "limit": .int(200) ] )) diff --git a/Tests/ASCMCPTests/Workers/AnalyticsSalesReportHardeningTests.swift b/Tests/ASCMCPTests/Workers/AnalyticsSalesReportHardeningTests.swift index c3b7c2f..7dd058f 100644 --- a/Tests/ASCMCPTests/Workers/AnalyticsSalesReportHardeningTests.swift +++ b/Tests/ASCMCPTests/Workers/AnalyticsSalesReportHardeningTests.swift @@ -69,17 +69,9 @@ struct AnalyticsSalesReportHardeningTests { #expect(rows.count == 1) } - @Test("financial raw row limit is advertised and clamped to 1 through 200") + @Test("financial raw row limit is advertised and rejects values outside 1 through 200") func financialRawRowLimitIsBounded() async throws { - let header = "Quantity\tPartner Share\tPartner Share Currency\tCountry Of Sale (Region)" - let body = header + "\n" + Array( - repeating: "1\t1.00\tUSD\tUS", - count: 205 - ).joined(separator: "\n") - let transport = TestHTTPTransport(responses: [ - .init(statusCode: 200, body: body), - .init(statusCode: 200, body: body) - ]) + let transport = TestHTTPTransport(responses: []) let worker = try await makeAnalyticsWorker(transport: transport) let tool = try #require(await worker.getTools().first { $0.name == "analytics_financial_report" }) let properties = try analyticsProperties(tool) @@ -111,11 +103,9 @@ struct AnalyticsSalesReportHardeningTests { ] )) - let minimumRoot = try analyticsObject(minimumResult.structuredContent) - let maximumRoot = try analyticsObject(maximumResult.structuredContent) - #expect(minimumRoot["showing_rows"] == .int(1)) - #expect(maximumRoot["showing_rows"] == .int(200)) - #expect(await transport.requestCount() == 2) + #expect(minimumResult.isError == true) + #expect(maximumResult.isError == true) + #expect(await transport.requestCount() == 0) } @Test("daily sales report can request the latest available date") diff --git a/Tests/ASCMCPTests/Workers/AnalyticsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/AnalyticsWorkerContractTests.swift index 4c7c210..8adf0cb 100644 --- a/Tests/ASCMCPTests/Workers/AnalyticsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AnalyticsWorkerContractTests.swift @@ -357,6 +357,29 @@ struct AnalyticsWorkerContractTests { #expect(result.isError == true) #expect(await transport.requestCount() == 2) } + + @Test("analytics report request creation preserves unknown mutation state") + func reportRequestCreatePreservesUnknownMutationState() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 500, body: #"{"errors":[{"status":"500","detail":"unavailable"}]}"#) + ]) + let worker = try await analyticsContractWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "analytics_create_report_request", + arguments: [ + "app_id": .string("app-1"), + "access_type": .string("ONGOING") + ] + )) + + let payload = try analyticsContractObject(result.structuredContent) + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("unknown")) + #expect(payload["outcomeUnknown"] == .bool(true)) + #expect(payload["retrySafe"] == .bool(false)) + #expect(await transport.requestCount() == 1) + } } private func analyticsContractWorker(transport: TestHTTPTransport) async throws -> AnalyticsWorker { diff --git a/Tests/ASCMCPTests/Workers/AppEventsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/AppEventsWorkerContractTests.swift index 743cfd6..d843f05 100644 --- a/Tests/ASCMCPTests/Workers/AppEventsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AppEventsWorkerContractTests.swift @@ -16,6 +16,16 @@ struct AppEventsWorkerContractTests { #expect(list["include"] != nil) #expect(list["localizations_limit"] != nil) #expect(list["next_url"] != nil) + for field in ["event_states", "include"] { + let variants = try #require(list[field]?.objectValue?["oneOf"]?.arrayValue) + let scalar = try appEventsContractObject(try #require(variants.first)) + #expect(scalar["pattern"]?.stringValue?.isEmpty == false) + } + let eventStateVariants = try #require(list["event_states"]?.objectValue?["oneOf"]?.arrayValue) + let eventStateScalar = try appEventsContractObject(try #require(eventStateVariants.first)) + let eventStatePattern = try #require(eventStateScalar["pattern"]?.stringValue) + #expect("DRAFT, READY_FOR_REVIEW".range(of: eventStatePattern, options: .regularExpression) != nil) + #expect("DRAFT, INVALID".range(of: eventStatePattern, options: .regularExpression) == nil) let localizationList = try appEventsContractProperties( try #require(tools.first { $0.name == "app_events_list_localizations" }) @@ -31,12 +41,11 @@ struct AppEventsWorkerContractTests { #expect(update["priority"] != nil) #expect(update["territory_schedules"]?.objectValue?["oneOf"]?.arrayValue?.count == 3) #expect(update["deep_link"]?.objectValue?["format"]?.stringValue == "uri") - let purchaseValues = try #require(update["purchase_requirement"]?.objectValue?["enum"]?.arrayValue) - #expect(purchaseValues.compactMap(\.stringValue) == ["NO_COST_ASSOCIATED", "IN_APP_PURCHASE"]) - #expect(purchaseValues.contains { $0.isNull }) - let createPurchaseValues = try #require(create["purchase_requirement"]?.objectValue?["enum"]?.arrayValue) - #expect(createPurchaseValues.compactMap(\.stringValue) == ["NO_COST_ASSOCIATED", "IN_APP_PURCHASE"]) - #expect(createPurchaseValues.contains { $0.isNull }) + for properties in [create, update] { + let purchase = try #require(properties["purchase_requirement"]?.objectValue) + #expect(purchase["type"]?.arrayValue?.compactMap(\.stringValue) == ["string", "null"]) + #expect(purchase["enum"] == nil) + } } @Test("current collection controls map to Apple query names") @@ -64,8 +73,8 @@ struct AppEventsWorkerContractTests { "event_states": .array([.string("DRAFT"), .string("READY_FOR_REVIEW")]), "event_ids": .string("event-1,event-2"), "include": .array([.string("localizations")]), - "limit": .int(500), - "localizations_limit": .int(90) + "limit": .int(200), + "localizations_limit": .int(50) ] )) @@ -113,9 +122,9 @@ struct AppEventsWorkerContractTests { arguments: [ "event_id": .string("event-1"), "include": .array([.string("appEvent"), .string("appEventScreenshots"), .string("appEventVideoClips")]), - "limit": .int(300), - "screenshots_limit": .int(75), - "video_clips_limit": .int(80) + "limit": .int(200), + "screenshots_limit": .int(50), + "video_clips_limit": .int(50) ] )) @@ -215,7 +224,77 @@ struct AppEventsWorkerContractTests { "deep_link": .string("events launch") ] )) - let invalidCreatePurchaseRequirement = try await worker.handleTool(CallTool.Parameters( + #expect(invalidSchedule.isError == true) + #expect(noOpEvent.isError == true) + #expect(noOpLocalization.isError == true) + #expect(invalidCreateDeepLink.isError == true) + #expect(invalidUpdateDeepLink.isError == true) + #expect(await transport.requestCount() == 0) + } + + @Test("present invalid collection limits fail before network") + func invalidLimitsFailBeforeNetwork() async throws { + let cases: [(String, [String: Value])] = [ + ("app_events_list", ["app_id": .string("app-1"), "limit": .int(0)]), + ("app_events_list", ["app_id": .string("app-1"), "localizations_limit": .string("50")]), + ("app_events_get", ["event_id": .string("event-1"), "localizations_limit": .int(51)]), + ("app_events_list_localizations", ["event_id": .string("event-1"), "limit": .int(201)]), + ("app_events_list_localizations", ["event_id": .string("event-1"), "screenshots_limit": .int(0)]), + ("app_events_list_localizations", ["event_id": .string("event-1"), "video_clips_limit": .string("50")]) + ] + + for (tool, arguments) in cases { + let transport = TestHTTPTransport(responses: []) + let worker = try await appEventsContractWorker(transport: transport) + + let result = try await worker.handleTool(.init(name: tool, arguments: arguments)) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + + @Test("accepted create and update identity failures are committed unverified") + func mutationIdentityFailuresPreserveCommitState() async throws { + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"apps","id":"event-1"}}"#) + ]) + let createWorker = try await appEventsContractWorker(transport: createTransport) + let create = try await createWorker.handleTool(.init( + name: "app_events_create", + arguments: ["app_id": .string("app-1"), "reference_name": .string("Launch")] + )) + + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"appEventLocalizations","id":"loc-other"}}"#) + ]) + let updateWorker = try await appEventsContractWorker(transport: updateTransport) + let update = try await updateWorker.handleTool(.init( + name: "app_events_update_localization", + arguments: ["localization_id": .string("loc-1"), "name": .string("Launch")] + )) + + for result in [create, update] { + let payload = try appEventsContractObject(result.structuredContent) + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + } + + @Test("purchase requirement forwards arbitrary Apple strings on create and update") + func purchaseRequirementIsUnconstrained() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: """ + {"data":{"type":"appEvents","id":"event-1","attributes":{"referenceName":"Launch","purchaseRequirement":"SUBSCRIPTION"}}} + """), + .init(statusCode: 200, body: """ + {"data":{"type":"appEvents","id":"event-1","attributes":{"referenceName":"Launch","purchaseRequirement":"IN_APP_PURCHASE_OR_SUBSCRIPTION"}}} + """) + ]) + let worker = try await appEventsContractWorker(transport: transport) + + let create = try await worker.handleTool(CallTool.Parameters( name: "app_events_create", arguments: [ "app_id": .string("app-1"), @@ -223,7 +302,7 @@ struct AppEventsWorkerContractTests { "purchase_requirement": .string("SUBSCRIPTION") ] )) - let invalidUpdatePurchaseRequirement = try await worker.handleTool(CallTool.Parameters( + let update = try await worker.handleTool(CallTool.Parameters( name: "app_events_update", arguments: [ "event_id": .string("event-1"), @@ -231,14 +310,20 @@ struct AppEventsWorkerContractTests { ] )) - #expect(invalidSchedule.isError == true) - #expect(noOpEvent.isError == true) - #expect(noOpLocalization.isError == true) - #expect(invalidCreateDeepLink.isError == true) - #expect(invalidUpdateDeepLink.isError == true) - #expect(invalidCreatePurchaseRequirement.isError == true) - #expect(invalidUpdatePurchaseRequirement.isError == true) - #expect(await transport.requestCount() == 0) + #expect(create.isError != true) + #expect(update.isError != true) + let requests = await transport.recordedRequests() + let createRequest = try #require(requests.first) + let updateRequest = try #require(requests.last) + #expect(try appEventsContractAttributes(createRequest)["purchaseRequirement"] as? String == "SUBSCRIPTION") + #expect(try appEventsContractAttributes(updateRequest)["purchaseRequirement"] as? String == "IN_APP_PURCHASE_OR_SUBSCRIPTION") + + let manifest = try ASCOperationManifestBundle.loadBundled() + for toolName in ["app_events_create", "app_events_update"] { + let mapping = try #require(manifest.mapping(for: toolName)) + let purchaseRequirement = try #require(mapping.fields.first { $0.toolField == "purchase_requirement" }) + #expect(purchaseRequirement.localRole?.contains("any non-null string") == true) + } } @Test("localization update sends explicit null") diff --git a/Tests/ASCMCPTests/Workers/AppInfoWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/AppInfoWorkerContractTests.swift index b4e9535..6dc6691 100644 --- a/Tests/ASCMCPTests/Workers/AppInfoWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AppInfoWorkerContractTests.swift @@ -12,6 +12,9 @@ struct AppInfoWorkerContractTests { let list = try appInfoContractProperties(try #require(tools.first { $0.name == "app_info_list" })) #expect(list["include"] != nil) + let listInclude = try appInfoContractObject(list["include"]) + let listAlternatives = try appInfoContractArray(listInclude["oneOf"]) + #expect(try appInfoContractObject(listAlternatives.first)["pattern"]?.stringValue != nil) #expect(list["limit"] != nil) #expect(list["localizations_limit"] != nil) #expect(list["next_url"] != nil) @@ -21,6 +24,9 @@ struct AppInfoWorkerContractTests { ) #expect(localizations["locale"] != nil) #expect(localizations["include"] != nil) + let localizationInclude = try appInfoContractObject(localizations["include"]) + let localizationAlternatives = try appInfoContractArray(localizationInclude["oneOf"]) + #expect(try appInfoContractObject(localizationAlternatives.first)["pattern"]?.stringValue != nil) #expect(localizations["limit"] != nil) #expect(localizations["next_url"] != nil) @@ -52,8 +58,8 @@ struct AppInfoWorkerContractTests { arguments: [ "app_id": .string("app:1"), "include": .array([.string("app"), .string("ageRatingDeclaration")]), - "limit": .int(500), - "localizations_limit": .int(80) + "limit": .int(200), + "localizations_limit": .int(50) ] )) @@ -97,7 +103,7 @@ struct AppInfoWorkerContractTests { "info_id": .string("info-1"), "locale": .array([.string("en-US"), .string("fr-FR")]), "include": .string("appInfo"), - "limit": .int(300) + "limit": .int(200) ] )) @@ -180,6 +186,58 @@ struct AppInfoWorkerContractTests { #expect(await transport.requestCount() == 0) } + @Test("present invalid collection limits fail before network") + func invalidLimitsFailBeforeNetwork() async throws { + let cases: [(String, [String: Value])] = [ + ("app_info_list", ["app_id": .string("app-1"), "limit": .string("25")]), + ("app_info_list", ["app_id": .string("app-1"), "localizations_limit": .int(51)]), + ("app_info_get", ["info_id": .string("info-1"), "localizations_limit": .int(0)]), + ("app_info_list_localizations", ["info_id": .string("info-1"), "limit": .int(201)]) + ] + + for (tool, arguments) in cases { + let transport = TestHTTPTransport(responses: []) + let worker = try await appInfoContractWorker(transport: transport) + + let result = try await worker.handleTool(.init(name: tool, arguments: arguments)) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + + @Test("accepted create and update validation failures are committed unverified") + func mutationValidationFailuresPreserveCommitState() async throws { + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"appInfos","id":"info-other"}}"#) + ]) + let updateWorker = try await appInfoContractWorker(transport: updateTransport) + let update = try await updateWorker.handleTool(.init( + name: "app_info_update", + arguments: ["info_id": .string("info-1"), "primary_category_id": .string("cat-1")] + )) + + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":"not-an-eula"}"#) + ]) + let createWorker = try await appInfoContractWorker(transport: createTransport) + let create = try await createWorker.handleTool(.init( + name: "app_info_create_eula", + arguments: [ + "app_id": .string("app-1"), + "agreement_text": .string("Terms"), + "territory_ids": .array([.string("USA")]) + ] + )) + + for result in [update, create] { + let payload = try appInfoContractObject(result.structuredContent) + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + } + @Test("pagination continuation must preserve include controls") func paginationPreservesControls() async throws { let transport = TestHTTPTransport(responses: []) diff --git a/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift b/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift index f6371df..350e311 100644 --- a/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift +++ b/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift @@ -82,10 +82,13 @@ struct AppLifecycleReliabilityTests { #expect(list["limit"]?.objectValue?["minimum"]?.intValue == 1) #expect(list["limit"]?.objectValue?["maximum"]?.intValue == 200) #expect(list["limit"]?.objectValue?["default"]?.intValue == 25) - #expect(create["copyright"] != nil) - #expect(create["review_type"] != nil) + for field in ["release_type", "earliest_release_date", "copyright", "review_type"] { + #expect(create[field]?.objectValue?["type"]?.arrayValue?.compactMap(\.stringValue) == ["string", "null"]) + } + #expect(create["earliest_release_date"]?.objectValue?["format"]?.stringValue == "date-time") #expect(update["review_type"]?.objectValue?["type"]?.arrayValue?.compactMap(\.stringValue) == ["string", "null"]) #expect(update["downloadable"]?.objectValue?["type"]?.arrayValue?.compactMap(\.stringValue) == ["boolean", "null"]) + #expect(update["earliest_release_date"]?.objectValue?["format"]?.stringValue == "date-time") #expect(age["kids_age_band"]?.objectValue?["type"]?.arrayValue?.compactMap(\.stringValue) == ["string", "null"]) #expect(tools.contains { $0.name == "app_versions_delete_phased_release" }) #expect(Set(deletePhased.inputSchema.objectValue?["required"]?.arrayValue?.compactMap(\.stringValue) ?? []) == [ @@ -122,28 +125,102 @@ struct AppLifecycleReliabilityTests { #expect(query.first(where: { $0.name == "limit" })?.value == "25") } - @Test("create version sends copyright and review type") - func createVersionSendsCurrentAttributes() async throws { - let transport = TestHTTPTransport(responses: [ + @Test("create version preserves omitted, null, and concrete nullable attributes") + func createVersionPreservesNullableTriState() async throws { + let omittedTransport = TestHTTPTransport(responses: [ .init(statusCode: 201, body: #"{"data":{"type":"appStoreVersions","id":"ver-1"}}"#) ]) + let omittedWorker = try await makeLifecycleReliabilityWorker(omittedTransport) + let omittedResult = try await omittedWorker.handleTool(.init( + name: "app_versions_create", + arguments: [ + "app_id": .string("app-1"), + "platform": .string("IOS"), + "version_string": .string("2.0") + ] + )) + + let nullTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"appStoreVersions","id":"ver-2"}}"#) + ]) + let nullWorker = try await makeLifecycleReliabilityWorker(nullTransport) + let nullResult = try await nullWorker.handleTool(.init( + name: "app_versions_create", + arguments: [ + "app_id": .string("app-1"), + "platform": .string("IOS"), + "version_string": .string("2.1"), + "release_type": .null, + "earliest_release_date": .null, + "copyright": .null, + "review_type": .null, + "uses_idfa": .null + ] + )) + + let valueTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"appStoreVersions","id":"ver-3"}}"#) + ]) + let valueWorker = try await makeLifecycleReliabilityWorker(valueTransport) + let valueResult = try await valueWorker.handleTool(.init( + name: "app_versions_create", + arguments: [ + "app_id": .string("app-1"), + "platform": .string("IOS"), + "version_string": .string("2.2"), + "release_type": .string("SCHEDULED"), + "earliest_release_date": .string("2026-09-01T12:00:00Z"), + "copyright": .string("2026 Example"), + "review_type": .string("APP_STORE"), + "uses_idfa": .bool(false) + ] + )) + + #expect(omittedResult.isError != true) + #expect(nullResult.isError != true) + #expect(valueResult.isError != true) + + let omitted = try lifecycleReliabilityRequestAttributes(try #require(await omittedTransport.recordedBodyStrings().first)) + #expect(Set(omitted.keys) == ["platform", "versionString"]) + + let null = try lifecycleReliabilityRequestAttributes(try #require(await nullTransport.recordedBodyStrings().first)) + for field in ["releaseType", "earliestReleaseDate", "copyright", "reviewType", "usesIdfa"] { + #expect(null[field] is NSNull) + } + + let value = try lifecycleReliabilityRequestAttributes(try #require(await valueTransport.recordedBodyStrings().first)) + #expect(value["releaseType"] as? String == "SCHEDULED") + #expect(value["earliestReleaseDate"] as? String == "2026-09-01T12:00:00Z") + #expect(value["copyright"] as? String == "2026 Example") + #expect(value["reviewType"] as? String == "APP_STORE") + #expect(value["usesIdfa"] as? Bool == false) + } + + @Test("version date-time inputs reject invalid values before network access") + func versionDateTimeValidation() async throws { + let transport = TestHTTPTransport(responses: []) let worker = try await makeLifecycleReliabilityWorker(transport) - let result = try await worker.handleTool(.init( + let create = try await worker.handleTool(.init( name: "app_versions_create", arguments: [ "app_id": .string("app-1"), "platform": .string("IOS"), "version_string": .string("2.0"), - "copyright": .string("2026 Example"), - "review_type": .string("APP_STORE") + "earliest_release_date": .string("2026-09-01") + ] + )) + let update = try await worker.handleTool(.init( + name: "app_versions_update", + arguments: [ + "version_id": .string("ver-1"), + "earliest_release_date": .string("tomorrow") ] )) - #expect(result.isError != true) - let attributes = try lifecycleReliabilityRequestAttributes(try #require(await transport.recordedBodyStrings().first)) - #expect(attributes["copyright"] as? String == "2026 Example") - #expect(attributes["reviewType"] as? String == "APP_STORE") + #expect(create.isError == true) + #expect(update.isError == true) + #expect(await transport.requestCount() == 0) } @Test("update version preserves nullable tri-state and exposes current state") @@ -183,6 +260,84 @@ struct AppLifecycleReliabilityTests { #expect(version["earliest_release_date"] as? String == "2026-08-01T00:00:00Z") } + @Test("attach build requires Apple's 204 relationship status") + func attachBuildExactStatus() async throws { + let acceptedTransport = TestHTTPTransport(responses: [.init(statusCode: 204, body: "")]) + let acceptedWorker = try await makeLifecycleReliabilityWorker(acceptedTransport) + let accepted = try await acceptedWorker.handleTool(.init( + name: "app_versions_attach_build", + arguments: ["version_id": .string("ver-1"), "build_id": .string("build-1")] + )) + + let rejectedTransport = TestHTTPTransport(responses: [.init(statusCode: 200, body: "")]) + let rejectedWorker = try await makeLifecycleReliabilityWorker(rejectedTransport) + let rejected = try await rejectedWorker.handleTool(.init( + name: "app_versions_attach_build", + arguments: ["version_id": .string("ver-1"), "build_id": .string("build-1")] + )) + + #expect(accepted.isError != true) + #expect(rejected.isError == true) + let payload = try lifecycleReliabilityStructuredObject(rejected) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["operationCommitted"] == .bool(true)) + #expect(payload["outcomeUnknown"] == .bool(false)) + #expect(payload["retrySafe"] == .bool(false)) + #expect(payload["inspectionRequired"] == .bool(true)) + } + + @Test("create mutation catch preserves committed-unverified and unknown states") + func createMutationCatchPreservesCommitState() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 202, body: #"{"data":{"type":"appStoreVersions","id":"ver-1"}}"#) + ]) + let worker = try await makeLifecycleReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "app_versions_create", + arguments: [ + "app_id": .string("app-1"), + "platform": .string("IOS"), + "version_string": .string("2.0") + ] + )) + + #expect(result.isError == true) + let payload = try lifecycleReliabilityStructuredObject(result) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["operationCommitted"] == .bool(true)) + #expect(payload["outcomeUnknown"] == .bool(false)) + #expect(payload["retrySafe"] == .bool(false)) + #expect(payload["inspectionRequired"] == .bool(true)) + let details = try lifecycleReliabilityValueObject(payload["details"]) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string("POST")) + #expect(details["statusCode"] == .int(202)) + + let unknownTransport = TestHTTPTransport(responses: [ + .init(statusCode: 503, body: lifecycleReliabilityAPIError(503)) + ]) + let unknownWorker = try await makeLifecycleReliabilityWorker(unknownTransport) + let unknownResult = try await unknownWorker.handleTool(.init( + name: "app_versions_create", + arguments: [ + "app_id": .string("app-1"), + "platform": .string("IOS"), + "version_string": .string("2.0") + ] + )) + + #expect(unknownResult.isError == true) + let unknownPayload = try lifecycleReliabilityStructuredObject(unknownResult) + #expect(unknownPayload["operationCommitState"] == .string("unknown")) + #expect(unknownPayload["outcomeUnknown"] == .bool(true)) + #expect(unknownPayload["retrySafe"] == .bool(false)) + #expect(unknownPayload["inspectionRequired"] == .bool(true)) + let unknownDetails = try lifecycleReliabilityValueObject(unknownPayload["details"]) + #expect(unknownDetails["type"] == .string("mutation_unknown")) + #expect(unknownDetails["method"] == .string("POST")) + } + @Test("kids age band preserves explicit null") func kidsAgeBandPreservesNull() async throws { let transport = TestHTTPTransport(responses: [ diff --git a/Tests/ASCMCPTests/Workers/AppLifecycleWorkerHardeningTests.swift b/Tests/ASCMCPTests/Workers/AppLifecycleWorkerHardeningTests.swift index c93da77..58e4b73 100644 --- a/Tests/ASCMCPTests/Workers/AppLifecycleWorkerHardeningTests.swift +++ b/Tests/ASCMCPTests/Workers/AppLifecycleWorkerHardeningTests.swift @@ -535,6 +535,37 @@ struct AppLifecycleWorkerHardeningTests { #expect(payload["message"]?.stringValue?.contains("review_submission_id set to the returned submission_id") == true) } + @Test("partial submit mutation preserves typed committed-unverified state") + func partialSubmitMutationPreservesCommitState() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: submissionVersionBody(appId: "app-1")), + .init(statusCode: 201, body: #"{"data":{"type":"reviewSubmissions","id":"sub-1"}}"#), + .init(statusCode: 202, body: #"{"data":{"type":"reviewSubmissionItems","id":"item-1"}}"#) + ]) + let worker = try await makeWorker(transport: transport) + + let result = try await worker.handleTool(.init( + name: "app_versions_submit_for_review", + arguments: ["version_id": .string("ver-1"), "app_id": .string("app-1")] + )) + + #expect(result.isError == true) + let payload = try object(result.structuredContent) + #expect(payload["partial_success"] == .bool(true)) + #expect(payload["submission_id"] == .string("sub-1")) + #expect(payload["failed_step"] == .string("create_review_submission_item")) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["operationCommitted"] == .bool(true)) + #expect(payload["outcomeUnknown"] == .bool(false)) + #expect(payload["retrySafe"] == .bool(false)) + #expect(payload["inspectionRequired"] == .bool(true)) + let details = try object(payload["details"]) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string("POST")) + #expect(details["statusCode"] == .int(202)) + #expect(await transport.requestCount() == 3) + } + @Test("submit for review exposes submission id after confirm failure") func submitForReviewExposesSubmissionIdAfterConfirmFailure() async throws { let transport = TestHTTPTransport(responses: [ diff --git a/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift b/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift index d22fe8f..666f8ef 100644 --- a/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift +++ b/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift @@ -28,7 +28,52 @@ struct AppsWorkerReliabilityTests { #expect(appsReliabilityQueryValue(request, "limit") == "25") } - @Test("apps list pagination preserves clamped limit sort and filters") + @Test("apps list projects current App attributes") + func appsListProjectsCurrentAttributes() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: """ + { + "data": [{ + "type": "apps", + "id": "app-1", + "attributes": { + "name": "Example", + "accessibilityUrl": "https://example.test/accessibility", + "streamlinedPurchasingEnabled": true + } + }], + "links": {"self": "https://api.example.test/v1/apps"} + } + """) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init(name: "apps_list", arguments: [:])) + + #expect(result.isError != true) + let payload = try appsReliabilityObject(result) + let app = try #require((payload["apps"] as? [[String: Any]])?.first) + let attributes = try #require(app["attributes"] as? [String: Any]) + #expect(attributes["accessibilityUrl"] as? String == "https://example.test/accessibility") + #expect(attributes["streamlinedPurchasingEnabled"] as? Bool == true) + #expect(attributes["availableInNewTerritories"] == nil) + } + + @Test("apps list output schema allows an unknown Apple total") + func appsListOutputSchemaAllowsNullTotal() async throws { + let worker = try await makeAppsReliabilityWorker(TestHTTPTransport(responses: [])) + let rawTool = try #require(await worker.getTools().first { $0.name == "apps_list" }) + let tool = ToolMetadataPolicy.apply(to: rawTool) + let schema = try appsReliabilityValueObject(tool.outputSchema) + let properties = try appsReliabilityValueObject(schema["properties"]) + let totalCount = try appsReliabilityValueObject(properties["totalCount"]) + let typeValues = try #require(totalCount["type"]?.arrayValue) + let types = typeValues.compactMap(\.stringValue) + + #expect(types == ["integer", "null"]) + } + + @Test("apps list pagination preserves explicit limit sort and filters") func appsListPaginationPreservesControls() async throws { let nextURL = "https://api.example.test/v1/apps?cursor=next&limit=200&sort=-name&filter%5BbundleId%5D=com.example.app" let transport = TestHTTPTransport(responses: [ @@ -37,7 +82,7 @@ struct AppsWorkerReliabilityTests { ]) let worker = try await makeAppsReliabilityWorker(transport) let arguments: [String: Value] = [ - "limit": .int(500), + "limit": .int(200), "sort": .string("-name"), "bundle_id": .string("com.example.app") ] @@ -49,6 +94,7 @@ struct AppsWorkerReliabilityTests { #expect(first.isError != true) #expect(second.isError != true) + #expect(try appsReliabilityObject(first)["totalCount"] is NSNull) let requests = await transport.recordedRequests() #expect(requests.count == 2) for request in requests { @@ -62,7 +108,7 @@ struct AppsWorkerReliabilityTests { func appsListPaginationRejectsControlDrift() async throws { let cases: [([String: Value], String)] = [ ( - ["limit": .int(500), "sort": .string("-name")], + ["limit": .int(200), "sort": .string("-name")], "https://api.example.test/v1/apps?cursor=next&limit=200" ), ( @@ -84,6 +130,32 @@ struct AppsWorkerReliabilityTests { } } + @Test("present invalid app and localization limits fail before network") + func invalidLimitsFailBeforeNetwork() async throws { + let calls = [ + CallTool.Parameters(name: "apps_list", arguments: ["limit": .int(0)]), + CallTool.Parameters(name: "apps_list", arguments: ["limit": .string("25")]), + CallTool.Parameters( + name: "apps_list_localizations", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "limit": .int(201) + ] + ) + ] + + for call in calls { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(call) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + @Test("manifest records fixed query controls used by Apps flows") func manifestRecordsFixedQueries() throws { let manifest = try ASCOperationManifestBundle.loadBundled() @@ -126,6 +198,16 @@ struct AppsWorkerReliabilityTests { #expect(try appsReliabilityFixedQueries(manifest, "apps_update_metadata", "appStoreVersions_appStoreVersionLocalizations_getToManyRelated")["fields[appStoreVersionLocalizations]"] == .array([ .string("locale"), .string("appStoreVersion") ])) + + let metadataMapping = try #require(manifest.mapping(for: "apps_get_metadata")) + let metadataFields = Set(metadataMapping.response.fields.map(\.outputField)) + #expect(metadataFields.isSuperset(of: ["localization", "localizations", "totalLocalizations"])) + let localizationMapping = try #require(manifest.mapping(for: "apps_list_localizations")) + let localizationFields = Set(localizationMapping.response.fields.map(\.outputField)) + #expect(localizationFields.isSuperset(of: ["appId", "versionId", "count", "totalLocalizations"])) + let appsListMapping = try #require(manifest.mapping(for: "apps_list")) + let appsListFields = Set(appsListMapping.response.fields.map(\.outputField)) + #expect(appsListFields.isSuperset(of: ["count", "totalCount", "hasNextPage", "links"])) } @Test("app details preserve included resources and relationship cardinality") @@ -136,7 +218,14 @@ struct AppsWorkerReliabilityTests { "data": { "type": "apps", "id": "app-1", - "attributes": { "name": "Example", "bundleId": "com.example.app", "sku": "EXAMPLE", "primaryLocale": "en-US" }, + "attributes": { + "name": "Example", + "bundleId": "com.example.app", + "sku": "EXAMPLE", + "primaryLocale": "en-US", + "accessibilityUrl": "https://example.test/accessibility", + "streamlinedPurchasingEnabled": true + }, "relationships": { "appStoreVersions": { "data": [{ "type": "appStoreVersions", "id": "ver-1" }] }, "betaLicenseAgreement": { "data": { "type": "betaLicenseAgreements", "id": "license-1" } }, @@ -159,6 +248,7 @@ struct AppsWorkerReliabilityTests { #expect(result.isError != true) let payload = try appsReliabilityObject(result) let app = try #require(payload["app"] as? [String: Any]) + let attributes = try #require(app["attributes"] as? [String: Any]) let relationships = try #require(app["relationships"] as? [String: Any]) let versions = try #require(relationships["appStoreVersions"] as? [String: Any]) let license = try #require(relationships["betaLicenseAgreement"] as? [String: Any]) @@ -169,6 +259,47 @@ struct AppsWorkerReliabilityTests { #expect(icon["data"] is NSNull) #expect(webhooks["data"] is [[String: Any]]) #expect((payload["included"] as? [[String: Any]])?.first?["id"] as? String == "ver-1") + #expect(attributes["accessibilityUrl"] as? String == "https://example.test/accessibility") + #expect(attributes["streamlinedPurchasingEnabled"] as? Bool == true) + #expect(attributes["availableInNewTerritories"] == nil) + } + + @Test("app details include publishes exact enums and rejects invalid tokens") + func appDetailsIncludeIsFailClosed() async throws { + let schemaWorker = try await makeAppsReliabilityWorker(TestHTTPTransport(responses: [])) + let tool = try #require(await schemaWorker.getTools().first { $0.name == "apps_get_details" }) + let properties = try appsReliabilityProperties(tool) + let include = try appsReliabilityValueObject(properties["include"]) + let alternatives = try #require(include["oneOf"]?.arrayValue) + let scalar = try appsReliabilityValueObject(alternatives[0]) + let array = try appsReliabilityValueObject(alternatives[1]) + let items = try appsReliabilityValueObject(array["items"]) + let enumItems = try #require(items["enum"]?.arrayValue) + let enumValues = enumItems.compactMap(\.stringValue) + #expect(scalar["pattern"]?.stringValue != nil) + #expect(enumValues.count == 25) + #expect(enumValues.contains("appStoreVersions")) + #expect(enumValues.contains("androidToIosAppMappingDetails")) + + let invalidValues: [Value] = [ + .string(""), + .string("appStoreVersions,unknown"), + .string("appStoreVersions,appStoreVersions"), + .string("appStoreVersions, appInfos"), + .array([.string("appStoreVersions"), .int(1)]) + ] + for includeValue in invalidValues { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_get_details", + arguments: ["app_id": .string("app-1"), "include": includeValue] + )) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } } @Test("version and localization lists request current ownership fields") @@ -203,6 +334,48 @@ struct AppsWorkerReliabilityTests { #expect(appsReliabilityQueryValue(requests[2], "limit") == "200") } + @Test("localization list distinguishes page count from an unknown total") + func localizationListReportsHonestCounts() async throws { + let nextURL = "https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=next" + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: localizationPage( + id: "loc-1", + versionId: "ver-1", + next: nextURL + )) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_list_localizations", + arguments: ["app_id": .string("app-1"), "version_id": .string("ver-1")] + )) + + #expect(result.isError != true) + let payload = try appsReliabilityObject(result) + #expect(payload["count"] as? Int == 1) + #expect(payload["totalLocalizations"] is NSNull) + #expect(payload["next_url"] as? String == nextURL) + + let totalTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: localizationPage( + id: "loc-1", + versionId: "ver-1", + total: 12 + )) + ]) + let totalWorker = try await makeAppsReliabilityWorker(totalTransport) + let totalResult = try await totalWorker.handleTool(.init( + name: "apps_list_localizations", + arguments: ["app_id": .string("app-1"), "version_id": .string("ver-1")] + )) + let totalPayload = try appsReliabilityObject(totalResult) + #expect(totalPayload["count"] as? Int == 1) + #expect(totalPayload["totalLocalizations"] as? Int == 12) + } + @Test("version list pagination rejects a changed fixed projection") func versionListPaginationRequiresFixedProjection() async throws { let transport = TestHTTPTransport(responses: []) @@ -300,6 +473,94 @@ struct AppsWorkerReliabilityTests { #expect(selected["appStoreState"] as? String == "READY_FOR_SALE") } + @Test("metadata without locale follows every localization page") + func metadataFollowsEveryLocalizationPage() async throws { + let nextURL = "https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=page-2&fields%5BappStoreVersionLocalizations%5D=description%2Clocale%2Ckeywords%2CmarketingUrl%2CpromotionalText%2CsupportUrl%2CwhatsNew%2CappStoreVersion&limit=200" + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: localizationPage( + id: "loc-en", + versionId: "ver-1", + locale: "en-US", + next: nextURL + )), + .init(statusCode: 200, body: localizationPage( + id: "loc-ja", + versionId: "ver-1", + locale: "ja" + )) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_get_metadata", + arguments: ["app_id": .string("app-1"), "version_id": .string("ver-1")] + )) + + #expect(result.isError != true) + #expect(await transport.requestCount() == 3) + let payload = try appsReliabilityObject(result) + let localizations = try #require(payload["localizations"] as? [[String: Any]]) + #expect(localizations.compactMap { $0["locale"] as? String } == ["en-US", "ja"]) + #expect(payload["totalLocalizations"] as? Int == 2) + } + + @Test("metadata exact-locale lookup rejects multiple resources") + func metadataExactLocaleRejectsMultipleResources() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: """ + { + "data": [ + { + "type": "appStoreVersionLocalizations", + "id": "loc-1", + "attributes": {"locale": "en-US"}, + "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": "ver-1"}}} + }, + { + "type": "appStoreVersionLocalizations", + "id": "loc-2", + "attributes": {"locale": "en-US"}, + "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": "ver-1"}}} + } + ] + } + """) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_get_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US") + ] + )) + + #expect(result.isError == true) + #expect(appsReliabilityText(result).contains("returned 2 localizations")) + #expect(await transport.requestCount() == 2) + } + + @Test("metadata media requires an explicit locale before network access") + func metadataMediaRequiresLocale() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeAppsReliabilityWorker(transport) + let tool = try #require(await worker.getTools().first { $0.name == "apps_get_metadata" }) + + let result = try await worker.handleTool(.init( + name: "apps_get_metadata", + arguments: ["app_id": .string("app-1"), "include_media": .bool(true)] + )) + + #expect(result.isError == true) + #expect(appsReliabilityText(result).contains("requires an explicit 'locale'")) + #expect(tool.description?.contains("requires locale") == true) + #expect(await transport.requestCount() == 0) + } + @Test("metadata selection returns a canonical structured error when the app has no versions") func metadataSelectionWithoutVersionsReturnsCanonicalError() async throws { let transport = TestHTTPTransport(responses: [ @@ -454,6 +715,44 @@ struct AppsWorkerReliabilityTests { #expect(appsReliabilityQueryValue(requests[1], "limit") == "1") } + @Test("accepted localization create and metadata update identity failures are committed unverified") + func localizationMutationIdentityFailuresPreserveCommitState() async throws { + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"appStoreVersions","id":"loc-1","attributes":{"locale":"en-US"}}}"#) + ]) + let createWorker = try await makeAppsReliabilityWorker(createTransport) + let create = try await createWorker.handleTool(.init( + name: "apps_create_localization", + arguments: ["version_id": .string("ver-1"), "locale": .string("en-US")] + )) + + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: localizationPage(id: "loc-1", versionId: "ver-1")), + .init(statusCode: 200, body: #"{"data":{"type":"appStoreVersionLocalizations","id":"loc-other","attributes":{"locale":"en-US"}}}"#) + ]) + let updateWorker = try await makeAppsReliabilityWorker(updateTransport) + let update = try await updateWorker.handleTool(.init( + name: "apps_update_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US"), + "keywords": .string("example") + ] + )) + + for result in [create, update] { + guard case .object(let payload)? = result.structuredContent else { + Issue.record("Expected structured mutation failure") + continue + } + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + } + @Test("metadata update rejects app ownership mismatch before localization lookup") func metadataUpdateRejectsOwnershipMismatch() async throws { let transport = TestHTTPTransport(responses: [ @@ -475,6 +774,89 @@ struct AppsWorkerReliabilityTests { #expect(await transport.requestCount() == 1) } + @Test("metadata update rejects mismatched localization identity before patch") + func metadataUpdateRejectsMismatchedLocalizationIdentity() async throws { + let cases = [ + ("otherLocalizationType", "en-US"), + ("appStoreVersionLocalizations", "fr-FR") + ] + + for (type, returnedLocale) in cases { + let localization = """ + { + "data": [{ + "type": "\(type)", + "id": "loc-1", + "attributes": {"locale": "\(returnedLocale)"}, + "relationships": { + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": "ver-1"}} + } + }] + } + """ + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: localization) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_update_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US"), + "keywords": .string("example") + ] + )) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 2) + #expect((await transport.recordedRequests()).allSatisfy { $0.httpMethod == "GET" }) + } + } + + @Test("metadata update rejects ambiguous exact-locale lookup before patch") + func metadataUpdateRejectsAmbiguousLocalizationLookup() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: """ + { + "data": [ + { + "type": "appStoreVersionLocalizations", + "id": "loc-1", + "attributes": {"locale": "en-US"}, + "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": "ver-1"}}} + }, + { + "type": "appStoreVersionLocalizations", + "id": "loc-2", + "attributes": {"locale": "en-US"}, + "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": "ver-1"}}} + } + ] + } + """) + ]) + let worker = try await makeAppsReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "apps_update_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US"), + "keywords": .string("example") + ] + )) + + #expect(result.isError == true) + #expect(appsReliabilityText(result).contains("returned 2 localizations")) + #expect(await transport.requestCount() == 2) + #expect((await transport.recordedRequests()).allSatisfy { $0.httpMethod == "GET" }) + } + @Test("media reads propagate errors") func mediaErrorsAreNotSwallowed() async throws { let transport = TestHTTPTransport(responses: [ @@ -740,8 +1122,16 @@ private func versionResponse(id: String, appId: String) -> String { #"{"data":{"type":"appStoreVersions","id":"\#(id)","attributes":{"platform":"IOS","versionString":"1.0","appVersionState":"PREPARE_FOR_SUBMISSION"},"relationships":{"app":{"data":{"type":"apps","id":"\#(appId)"}}}}}"# } -private func localizationPage(id: String, versionId: String) -> String { - #"{"data":[{"type":"appStoreVersionLocalizations","id":"\#(id)","attributes":{"locale":"en-US"},"relationships":{"appStoreVersion":{"data":{"type":"appStoreVersions","id":"\#(versionId)"}}}}]}"# +private func localizationPage( + id: String, + versionId: String, + locale: String = "en-US", + next: String? = nil, + total: Int? = nil +) -> String { + let nextField = next.map { #", "next": "\#($0)""# } ?? "" + let totalField = total.map { #", "meta":{"paging":{"total":\#($0),"limit":200}}"# } ?? "" + return #"{"data":[{"type":"appStoreVersionLocalizations","id":"\#(id)","attributes":{"locale":"\#(locale)"},"relationships":{"appStoreVersion":{"data":{"type":"appStoreVersions","id":"\#(versionId)"}}}}],"links":{"self":"https://api.example.test/v1/appStoreVersions/\#(versionId)/appStoreVersionLocalizations"\#(nextField)}\#(totalField)}"# } private func emptyCollectionPage(selfURL: String, next: String?) -> String { @@ -782,6 +1172,13 @@ private func appsReliabilityProperties(_ tool: Tool) throws -> [String: Value] { return properties } +private func appsReliabilityValueObject(_ value: Value?) throws -> [String: Value] { + guard case .object(let object) = value else { + throw AppsReliabilityTestError.expectedProperties + } + return object +} + private func appsReliabilityFixedQueries( _ manifest: ASCOperationManifestBundle, _ tool: String, diff --git a/Tests/ASCMCPTests/Workers/BetaAdministrativeWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/BetaAdministrativeWorkerContractTests.swift index 91896b8..635d269 100644 --- a/Tests/ASCMCPTests/Workers/BetaAdministrativeWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/BetaAdministrativeWorkerContractTests.swift @@ -215,6 +215,44 @@ struct BetaAdministrativeWorkerContractTests { #expect(root["request_id"] == .string("clear-1")) } + @Test("sandbox mutations preserve committed-unverified state") + func sandboxMutationsPreserveCommittedUnverifiedState() async throws { + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 204, body: "") + ]) + let updateWorker = SandboxTestersWorker( + httpClient: try await makeBetaAdministrativeClient(updateTransport) + ) + let update = try await updateWorker.handleTool(CallTool.Parameters( + name: "sandbox_update", + arguments: [ + "sandbox_tester_id": .string("tester-1"), + "interrupt_purchases": .bool(true) + ] + )) + let updatePayload = try betaAdministrativeObject(update.structuredContent) + #expect(update.isError == true) + #expect(updatePayload["operationCommitState"] == .string("committed_unverified")) + #expect(updatePayload["operationCommitted"] == .bool(true)) + #expect(updatePayload["retrySafe"] == .bool(false)) + + let clearTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"sandboxTestersClearPurchaseHistoryRequest","id":"clear-1"}}"#) + ]) + let clearWorker = SandboxTestersWorker( + httpClient: try await makeBetaAdministrativeClient(clearTransport) + ) + let clear = try await clearWorker.handleTool(CallTool.Parameters( + name: "sandbox_clear_purchase_history", + arguments: ["sandbox_tester_ids": .array([.string("tester-1")])] + )) + let clearPayload = try betaAdministrativeObject(clear.structuredContent) + #expect(clear.isError == true) + #expect(clearPayload["operationCommitState"] == .string("committed_unverified")) + #expect(clearPayload["operationCommitted"] == .bool(true)) + #expect(clearPayload["retrySafe"] == .bool(false)) + } + @Test("beta administrative schemas expose nullable writes and collection bounds") func betaAdministrativeSchemasExposeContracts() async throws { let client = try await TestFactory.makeHTTPClient() diff --git a/Tests/ASCMCPTests/Workers/BetaAppWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/BetaAppWorkerContractTests.swift index 27c91e3..2c58654 100644 --- a/Tests/ASCMCPTests/Workers/BetaAppWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/BetaAppWorkerContractTests.swift @@ -519,7 +519,9 @@ struct BetaAppWorkerContractTests { #expect(result.isError == true) let root = try betaAppContractObject(result.structuredContent) #expect(root["operationCommitted"] == .bool(true)) + #expect(root["operationCommitState"] == .string("committed_unverified")) #expect(root["retrySafe"] == .bool(false)) + #expect(root["inspectionRequired"] == .bool(true)) #expect(root["submissionId"] == .string("submission-created")) #expect(root["submissionIdKnown"] == .bool(true)) #expect(root["requestedBuildId"] == .string("build-1")) @@ -547,6 +549,7 @@ struct BetaAppWorkerContractTests { #expect(result.isError == true) let root = try betaAppContractObject(result.structuredContent) #expect(root["operationCommitted"] == .bool(true)) + #expect(root["operationCommitState"] == .string("committed_unverified")) #expect(root["retrySafe"] == .bool(false)) #expect(await transport.requestCount() == 1) } @@ -566,9 +569,15 @@ struct BetaAppWorkerContractTests { #expect(result.isError == true) let root = try betaAppContractObject(result.structuredContent) #expect(root["operationCommitted"] == .bool(true)) + #expect(root["operationCommitState"] == .string("committed_unverified")) #expect(root["retrySafe"] == .bool(false)) #expect(root["submissionIdKnown"] == .bool(false)) #expect(root["requestedBuildId"] == .string("build-1")) + let details = try betaAppContractObject(root["details"]) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string("POST")) + #expect(details["expectedStatusCode"] == .int(201)) + #expect(details["statusCode"] == .int(201)) #expect(await transport.requestCount() == 1) } @@ -1198,20 +1207,21 @@ struct BetaAppWorkerContractTests { #expect(query["limit"] == "25") } - @Test("localization list rejects a malformed limit before network") - func localizationListRejectsMalformedLimit() async throws { + @Test("localization list rejects malformed and out-of-range limits before network") + func localizationListRejectsInvalidLimit() async throws { let transport = TestHTTPTransport(responses: []) let worker = BetaAppWorker(httpClient: try await makeBetaAppContractClient(transport)) - let result = try await worker.handleTool(CallTool.Parameters( - name: "beta_app_list_localizations", - arguments: [ - "app_id": .string("app-1"), - "limit": .string("25") - ] - )) - - #expect(result.isError == true) + for limit in [Value.string("25"), .int(0), .int(201)] { + let result = try await worker.handleTool(CallTool.Parameters( + name: "beta_app_list_localizations", + arguments: [ + "app_id": .string("app-1"), + "limit": limit + ] + )) + #expect(result.isError == true) + } #expect(await transport.requestCount() == 0) } @@ -1231,7 +1241,7 @@ struct BetaAppWorkerContractTests { name: "beta_app_list_localizations", arguments: [ "app_id": .string("app-1"), - "limit": .int(500), + "limit": .int(200), "next_url": .string(betaAppContractPaginationURL(path: path, parameters: explicitParameters)) ] )) @@ -1303,7 +1313,7 @@ struct BetaAppWorkerContractTests { name: "beta_app_list_localizations", arguments: [ "app_id": .string("app-1"), - "limit": .int(500), + "limit": .int(200), "next_url": .string(invalidURL) ] )) @@ -1327,7 +1337,7 @@ struct BetaAppWorkerContractTests { let arguments: [String: Value] = [ "build_id": .array([.string("build-1"), .string("build-2")]), "review_state": .array([.string("WAITING_FOR_REVIEW"), .string("IN_REVIEW")]), - "limit": .int(500) + "limit": .int(200) ] let acceptedTransport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: betaAppSubmissionPage()) @@ -1429,7 +1439,7 @@ struct BetaAppWorkerContractTests { name: "beta_app_list_submissions", arguments: [ "build_id": .array([.string("build-1"), .string("build-2")]), - "limit": .int(500), + "limit": .int(200), "next_url": .string(betaAppContractPaginationURL(path: path, parameters: documentedInjection)) ] )) diff --git a/Tests/ASCMCPTests/Workers/BuildTestFlightContractTests.swift b/Tests/ASCMCPTests/Workers/BuildTestFlightContractTests.swift index 798d037..199c273 100644 --- a/Tests/ASCMCPTests/Workers/BuildTestFlightContractTests.swift +++ b/Tests/ASCMCPTests/Workers/BuildTestFlightContractTests.swift @@ -201,6 +201,56 @@ struct BuildTestFlightContractTests { #expect(Set(attributes.keys) == ["autoNotifyEnabled"]) } + @Test("beta detail update preserves explicit null") + func betaDetailUpdatePreservesExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"buildBetaDetails","id":"detail-1"}}"#) + ]) + let worker = try await makeBuildBetaDetailsWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "builds_update_beta_detail", + arguments: [ + "beta_detail_id": .string("detail-1"), + "auto_notify": .null + ] + )) + + #expect(result.isError != true) + let body = try buildContractJSONBody(try #require(await transport.recordedRequests().first)) + let data = try buildContractDictionary(body["data"]) + let attributes = try buildContractDictionary(data["attributes"]) + #expect(attributes["autoNotifyEnabled"] is NSNull) + #expect(Set(attributes.keys) == ["autoNotifyEnabled"]) + } + + @Test("beta localization update preserves explicit null") + func betaLocalizationUpdatePreservesExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":[{"type":"betaBuildLocalizations","id":"localization-1"}]}"#), + .init(statusCode: 200, body: #"{"data":{"type":"betaBuildLocalizations","id":"localization-1"}}"#) + ]) + let worker = try await makeBuildBetaDetailsWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "builds_set_beta_localization", + arguments: [ + "build_id": .string("build-1"), + "locale": .string("en-US"), + "whats_new": .null + ] + )) + + #expect(result.isError != true) + let requests = await transport.recordedRequests() + #expect(requests.map(\.httpMethod) == ["GET", "PATCH"]) + let body = try buildContractJSONBody(requests[1]) + let data = try buildContractDictionary(body["data"]) + let attributes = try buildContractDictionary(data["attributes"]) + #expect(attributes["whatsNew"] is NSNull) + #expect(Set(attributes.keys) == ["whatsNew"]) + } + @Test("beta detail rejects read-only states before network") func betaDetailRejectsReadOnlyStates() async throws { let transport = TestHTTPTransport(responses: []) @@ -234,6 +284,8 @@ struct BuildTestFlightContractTests { for field in ["feedback_email", "marketing_url", "privacy_policy_url", "tv_os_privacy_policy"] { #expect(try buildContractProperty(field, in: localization)["deprecated"] == .bool(true)) } + #expect(try buildContractProperty("auto_notify", in: betaDetail)["type"] == .array([.string("boolean"), .string("null")])) + #expect(try buildContractProperty("whats_new", in: localization)["type"] == .array([.string("string"), .string("null")])) } } diff --git a/Tests/ASCMCPTests/Workers/CommerceCatalogOptionalInputTests.swift b/Tests/ASCMCPTests/Workers/CommerceCatalogOptionalInputTests.swift index c15462b..5d6f42f 100644 --- a/Tests/ASCMCPTests/Workers/CommerceCatalogOptionalInputTests.swift +++ b/Tests/ASCMCPTests/Workers/CommerceCatalogOptionalInputTests.swift @@ -380,9 +380,9 @@ struct CommerceCatalogOptionalInputTests { .filter { $0.tool != "subscriptions_pricing_summary" } .flatMap(\.operations) .flatMap { $0.optionalParameterClassifications ?? [] } - #expect(classifications.count == 174) + #expect(classifications.count == 169) #expect(classifications.filter { $0.disposition == .internalControl }.count == 31) - #expect(classifications.filter { $0.disposition == .intentionallyOmitted }.count == 143) + #expect(classifications.filter { $0.disposition == .intentionallyOmitted }.count == 138) #expect(classifications.allSatisfy { $0.reviewAtSpec == "4.4.1" && !$0.reason.isEmpty }) } } diff --git a/Tests/ASCMCPTests/Workers/CustomPagesV319ContractTests.swift b/Tests/ASCMCPTests/Workers/CustomPagesV319ContractTests.swift index 9164037..aa0919f 100644 --- a/Tests/ASCMCPTests/Workers/CustomPagesV319ContractTests.swift +++ b/Tests/ASCMCPTests/Workers/CustomPagesV319ContractTests.swift @@ -45,6 +45,13 @@ struct CustomPagesV319ContractTests { #expect(try cppV319Required(localizationDelete) == Set(["localization_id", "confirm_localization_id"])) let updateVersion = try #require(tools.first { $0.name == "custom_pages_update_version" }) #expect(try cppV319Required(updateVersion) == Set(["version_id", "deep_link"])) + let updatePage = try #require(tools.first { $0.name == "custom_pages_update" }) + let updatePageSchema = try cppV319Object(updatePage.inputSchema) + #expect(updatePageSchema["minProperties"] == .int(2)) + #expect(updatePageSchema["anyOf"]?.arrayValue?.count == 2) + let publishedUpdatePageSchema = try cppV319Object(ToolMetadataPolicy.apply(to: updatePage).inputSchema) + #expect(publishedUpdatePageSchema["minProperties"] == .int(2)) + #expect(publishedUpdatePageSchema["anyOf"] == nil) let updateLocalization = try #require(tools.first { $0.name == "custom_pages_update_localization" }) #expect(try cppV319Required(updateLocalization) == Set(["localization_id", "promotional_text"])) let removeKeywords = try #require(tools.first { $0.name == "custom_pages_remove_search_keywords" }) diff --git a/Tests/ASCMCPTests/Workers/ExportComplianceWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/ExportComplianceWorkerContractTests.swift index 9e81967..57b4951 100644 --- a/Tests/ASCMCPTests/Workers/ExportComplianceWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ExportComplianceWorkerContractTests.swift @@ -10,7 +10,7 @@ struct ExportComplianceWorkerContractTests { let transport = TestHTTPTransport(responses: [ .init( statusCode: 200, - body: #"{"data":[{"type":"appEncryptionDeclarations","id":"declaration-1","attributes":{"appDescription":"Encrypted chat","appEncryptionDeclarationState":"APPROVED","codeValue":"CCATS-1"}}],"links":{"self":"https://api.example.test/v1/apps/app%2Fone/appEncryptionDeclarations","next":"https://api.example.test/v1/apps/app%2Fone/appEncryptionDeclarations?fields%5BappEncryptionDeclarations%5D=appDescription%2CcreatedDate%2Cexempt%2CcontainsProprietaryCryptography%2CcontainsThirdPartyCryptography%2CavailableOnFrenchStore%2CappEncryptionDeclarationState%2CcodeValue%2CappEncryptionDeclarationDocument&limit=37&cursor=next-page"},"meta":{"paging":{"total":4,"limit":37}}}"# + body: #"{"data":[{"type":"appEncryptionDeclarations","id":"declaration-1","attributes":{"appDescription":"Encrypted chat","appEncryptionDeclarationState":"APPROVED","codeValue":"CCATS-1"}}],"links":{"self":"https://api.example.test/v1/apps/app-one/appEncryptionDeclarations","next":"https://api.example.test/v1/apps/app-one/appEncryptionDeclarations?fields%5BappEncryptionDeclarations%5D=appDescription%2CcreatedDate%2Cexempt%2CcontainsProprietaryCryptography%2CcontainsThirdPartyCryptography%2CavailableOnFrenchStore%2CappEncryptionDeclarationState%2CcodeValue%2CappEncryptionDeclarationDocument&limit=37&cursor=next-page"},"meta":{"paging":{"total":4,"limit":37}}}"# ) ]) let worker = try await exportComplianceWorker(apiTransport: transport) @@ -57,7 +57,7 @@ struct ExportComplianceWorkerContractTests { @Test("continuation preserves a validated nondefault limit when limit is omitted") func nondefaultLimitContinuation() async throws { let transport = TestHTTPTransport(responses: [ - .init(statusCode: 200, body: #"{"data":[],"meta":{"paging":{"total":4,"limit":37}}}"#) + .init(statusCode: 200, body: #"{"data":[],"links":{"self":"https://api.example.test/v1/apps/app-1/appEncryptionDeclarations"},"meta":{"paging":{"total":4,"limit":37}}}"#) ]) let worker = try await exportComplianceWorker(apiTransport: transport) let nextURL = exportComplianceNextURL( @@ -91,6 +91,170 @@ struct ExportComplianceWorkerContractTests { #expect(await mismatchTransport.requestCount() == 0) } + @Test("list fails closed on malformed Apple pagination, cardinality, and identity") + func listResponseValidation() async throws { + let validSelf = exportComplianceNextURL(appID: "app-1", limit: 1, additions: [:]) + let wrongNext = exportComplianceNextURL( + appID: "another-app", + limit: 1, + additions: ["cursor": "next-page"] + ) + let actualNext = exportComplianceNextURL( + appID: "app-1", + limit: 1, + additions: ["cursor": "actual-cursor"] + ) + let invalidResponses: [(body: String, limit: Int)] = [ + (#"{"data":[]}"#, 1), + (#"{"data":[],"links":{"self":"https://api.example.test/v1/apps/another-app/appEncryptionDeclarations"}}"#, 1), + (#"{"data":[{"type":"appEncryptionDeclarations","id":"one"},{"type":"appEncryptionDeclarations","id":"two"}],"links":{"self":"\#(validSelf)"}}"#, 1), + (#"{"data":[{"type":"appEncryptionDeclarations","id":"same"},{"type":"appEncryptionDeclarations","id":"same"}],"links":{"self":"\#(validSelf)"}}"#, 2), + (#"{"data":[{"type":"unexpected","id":"one"}],"links":{"self":"\#(validSelf)"}}"#, 1), + (#"{"data":[],"links":{"self":"\#(validSelf)","next":"\#(wrongNext)"}}"#, 1), + (#"{"data":[],"links":{"self":"\#(validSelf)","next":"\#(actualNext)"},"meta":{"paging":{"limit":1,"nextCursor":"reported-cursor"}}}"#, 1) + ] + + for invalid in invalidResponses { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: invalid.body) + ]) + let worker = try await exportComplianceWorker(apiTransport: transport) + let result = try await worker.handleTool(.init( + name: "export_compliance_list_declarations", + arguments: ["app_id": .string("app-1"), "limit": .int(invalid.limit)] + )) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 1) + } + } + + @Test("all tools reject undeclared arguments before transport") + func unknownArgumentsRejected() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await exportComplianceWorker(apiTransport: transport) + let tools = await worker.getTools() + + for tool in tools { + let result = try await worker.handleTool(.init( + name: tool.name, + arguments: ["unexpected": .bool(true)] + )) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("every input ID publishes and enforces the canonical path-segment contract") + func canonicalIdentifierSchemaAndRuntimeParity() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await exportComplianceWorker(apiTransport: transport) + let canonicalPattern = #"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"# + let tools = await worker.getTools() + + for tool in tools { + let schema = try exportComplianceValueObject(tool.inputSchema) + let properties = try exportComplianceValueObject(schema["properties"]) + for (name, value) in properties where name.hasSuffix("_id") { + let identifier = try exportComplianceValueObject(value) + #expect(identifier["minLength"] == .int(1)) + #expect(identifier["pattern"] == .string(canonicalPattern)) + } + } + + var invalidCreate = exportComplianceDeclarationCreateArguments() + invalidCreate["app_id"] = .string("идентификатор") + let calls: [CallTool.Parameters] = [ + .init(name: "export_compliance_list_declarations", arguments: ["app_id": .string("bad id")]), + .init(name: "export_compliance_get_declaration", arguments: ["declaration_id": .string(".")]), + .init(name: "export_compliance_create_declaration", arguments: invalidCreate), + .init( + name: "export_compliance_create_document", + arguments: ["declaration_id": .string(".."), "file_path": .string("/tmp/export.pdf")] + ), + .init(name: "export_compliance_get_document", arguments: ["document_id": .string("bad/document")]), + .init( + name: "export_compliance_update_document", + arguments: ["document_id": .string("bad%2Fdocument"), "uploaded": .bool(true)] + ), + .init( + name: "export_compliance_upload_document", + arguments: [ + "document_id": .string("bad document"), + "file_path": .string("/tmp/export.pdf"), + "source_file_checksum": .string(exportComplianceHelloMD5) + ] + ), + .init(name: "export_compliance_inspect_document", arguments: ["declaration_id": .string("bad\\id")]), + .init(name: "export_compliance_get_build_declaration", arguments: ["build_id": .string("bad?id")]), + .init( + name: "export_compliance_attach_build_declaration", + arguments: ["build_id": .string("bad#id"), "declaration_id": .string("declaration-1")] + ), + .init( + name: "export_compliance_attach_build_declaration", + arguments: ["build_id": .string("build-1"), "declaration_id": .string("bad id")] + ), + .init(name: "export_compliance_check_release_readiness", arguments: ["build_id": .string(" build-1 ")]) + ] + + for call in calls { + #expect(try await worker.handleTool(call).isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("single-resource responses require canonical configured-origin self links") + func singleResourceSelfLinkValidation() async throws { + let declarationBody = exportComplianceDeclarationResponse( + id: "declaration-1", + state: "CREATED" + ).replacingOccurrences( + of: "/appEncryptionDeclarations/declaration-1\"", + with: "/appEncryptionDeclarations/another-declaration\"" + ) + let declarationTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: declarationBody) + ]) + let declarationWorker = try await exportComplianceWorker(apiTransport: declarationTransport) + let declaration = try await declarationWorker.handleTool(.init( + name: "export_compliance_get_declaration", + arguments: ["declaration_id": .string("declaration-1")] + )) + #expect(declaration.isError == true) + + let documentBody = exportComplianceDocumentResponse( + id: "document-1", + fileName: "export.pdf", + state: "COMPLETE" + ).replacingOccurrences( + of: "/appEncryptionDeclarationDocuments/document-1\"", + with: "/appEncryptionDeclarationDocuments/another-document\"" + ) + let documentTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: documentBody) + ]) + let documentWorker = try await exportComplianceWorker(apiTransport: documentTransport) + let document = try await documentWorker.handleTool(.init( + name: "export_compliance_get_document", + arguments: ["document_id": .string("document-1")] + )) + #expect(document.isError == true) + + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: declarationBody) + ]) + let createWorker = try await exportComplianceWorker(apiTransport: createTransport) + let create = try await createWorker.handleTool(.init( + name: "export_compliance_create_declaration", + arguments: exportComplianceDeclarationCreateArguments() + )) + let createDetails = try exportComplianceErrorDetails(create) + #expect(create.isError == true) + #expect(createDetails["creationState"] == .string("committed_unverified")) + #expect(createDetails["retrySafe"] == .bool(false)) + } + @Test("declaration create sends exact required questionnaire body") func createDeclarationContract() async throws { let transport = TestHTTPTransport(responses: [ @@ -171,7 +335,7 @@ struct ExportComplianceWorkerContractTests { let malformedInspection = try exportComplianceValueObject(malformedDetails["inspection"]) #expect(malformedInspection["tool"] == .string("export_compliance_list_declarations")) - for invalidID in ["", " ", " declaration-1 "] { + for invalidID in ["", " ", " declaration-1 ", "bad id", "идентификатор"] { let identityTransport = TestHTTPTransport(responses: [ .init( statusCode: 201, @@ -681,6 +845,11 @@ struct ExportComplianceWorkerContractTests { $0.name == "export_compliance_update_document" }) let schema = try exportComplianceValueObject(tool.inputSchema) + #expect(schema["minProperties"] == .int(2)) + #expect(schema["anyOf"]?.arrayValue?.count == 2) + let publishedSchema = try exportComplianceValueObject(ToolMetadataPolicy.apply(to: tool).inputSchema) + #expect(publishedSchema["minProperties"] == .int(2)) + #expect(publishedSchema["anyOf"] == nil) let properties = try exportComplianceValueObject(schema["properties"]) for field in ["source_file_checksum", "uploaded"] { let fieldSchema = try exportComplianceValueObject(properties[field]) @@ -1367,6 +1536,28 @@ struct ExportComplianceWorkerContractTests { ] as NSDictionary) let payload = try exportComplianceObject(result.structuredContent) #expect(payload["attachmentVerified"] == .bool(true)) + #expect(payload["reconciledAfterUpdate"] == nil) + } + + @Test("attachment accepts exact build-update 200 and does not treat 204 as direct success") + func attachBuildDeclarationExactStatus() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: exportComplianceDeclarationResponse(id: "decl-1", state: "APPROVED")), + .init(statusCode: 200, body: exportComplianceDocumentResponse(id: "document-1", fileName: "export.pdf", state: "COMPLETE")), + .init(statusCode: 204, body: ""), + .init(statusCode: 404, body: exportComplianceAPIError(404)) + ]) + let worker = try await exportComplianceWorker(apiTransport: transport) + let result = try await worker.handleTool(.init( + name: "export_compliance_attach_build_declaration", + arguments: ["build_id": .string("build-1"), "declaration_id": .string("decl-1")] + )) + + #expect(result.isError == true) + #expect(await transport.recordedRequests().map(\.httpMethod) == ["GET", "GET", "PATCH", "GET"]) + let details = try exportComplianceErrorDetails(result) + #expect(details["attachmentState"] == .string("unverified")) + #expect(details["retrySafe"] == .bool(false)) } @Test("attachment manifest uses Apple's nondeprecated build update replacement") @@ -1742,7 +1933,7 @@ private func exportComplianceDeclarationResponse( exempt: Bool? = nil ) -> String { let exemptValue = exempt.map { String($0) } ?? "null" - return #"{"data":{"type":"appEncryptionDeclarations","id":"\#(id)","attributes":{"appDescription":"Encrypted app","createdDate":"2026-07-20T00:00:00Z","exempt":\#(exemptValue),"containsProprietaryCryptography":true,"containsThirdPartyCryptography":false,"availableOnFrenchStore":true,"appEncryptionDeclarationState":"\#(state)","codeValue":"CCATS-1"}}}"# + return #"{"data":{"type":"appEncryptionDeclarations","id":"\#(id)","attributes":{"appDescription":"Encrypted app","createdDate":"2026-07-20T00:00:00Z","exempt":\#(exemptValue),"containsProprietaryCryptography":true,"containsThirdPartyCryptography":false,"availableOnFrenchStore":true,"appEncryptionDeclarationState":"\#(state)","codeValue":"CCATS-1"}},"links":{"self":"https://api.example.test/v1/appEncryptionDeclarations/\#(id)"}}"# } private func exportComplianceDocumentResponse( @@ -1769,7 +1960,7 @@ private func exportComplianceDocumentResponse( let errors = includeDeliverySecrets ? #"[{"code":"UPLOAD_FAILED","description":"Retry https://upload.example.test/chunk?signed=signed-secret with token=header-secret"}]"# : "[]" - return #"{"data":{"type":"appEncryptionDeclarationDocuments","id":"\#(id)","attributes":{"fileSize":5,"fileName":"\#(fileName)","assetDeliveryState":{"state":\#(stateValue),"errors":\#(errors),"warnings":[]}\#(operations)\#(secrets)\#(checksumValue)}}}"# + return #"{"data":{"type":"appEncryptionDeclarationDocuments","id":"\#(id)","attributes":{"fileSize":5,"fileName":"\#(fileName)","assetDeliveryState":{"state":\#(stateValue),"errors":\#(errors),"warnings":[]}\#(operations)\#(secrets)\#(checksumValue)}},"links":{"self":"https://api.example.test/v1/appEncryptionDeclarationDocuments/\#(id)"}}"# } private func exportComplianceBuildResponse( diff --git a/Tests/ASCMCPTests/Workers/IAPLegacyDeprecationTests.swift b/Tests/ASCMCPTests/Workers/IAPLegacyDeprecationTests.swift index eee4791..9bef7fd 100644 --- a/Tests/ASCMCPTests/Workers/IAPLegacyDeprecationTests.swift +++ b/Tests/ASCMCPTests/Workers/IAPLegacyDeprecationTests.swift @@ -239,7 +239,7 @@ private enum IAPLegacyCase: String, CaseIterable, Sendable, CustomTestStringConv var statusCode: Int { switch self { - case .createLocalization: 201 + case .createLocalization, .submit: 201 case .deleteLocalization, .deleteImage: 204 default: 200 } @@ -251,12 +251,14 @@ private enum IAPLegacyCase: String, CaseIterable, Sendable, CustomTestStringConv #"{"data":[],"links":{"self":"https://api.example.test/v2/inAppPurchases/iap-1/inAppPurchaseLocalizations?limit=25"}}"# case .createLocalization, .updateLocalization: #"{"data":{"type":"inAppPurchaseLocalizations","id":"loc-1","attributes":{"locale":"en-US","name":"Premium","description":"Copy"}}}"# - case .deleteLocalization, .deleteImage, .submit: + case .deleteLocalization, .deleteImage: "" case .getImage: #"{"data":{"type":"inAppPurchaseImages","id":"image-1","attributes":{"fileSize":5,"fileName":"image.png","state":"APPROVED"}}}"# case .listImages: #"{"data":[],"links":{"self":"https://api.example.test/v2/inAppPurchases/iap-1/images?limit=25"}}"# + case .submit: + #"{"data":{"type":"inAppPurchaseSubmissions","id":"submission-1"}}"# } } diff --git a/Tests/ASCMCPTests/Workers/InAppPurchaseImagesContractTests.swift b/Tests/ASCMCPTests/Workers/InAppPurchaseImagesContractTests.swift index 0bfbd68..c99548d 100644 --- a/Tests/ASCMCPTests/Workers/InAppPurchaseImagesContractTests.swift +++ b/Tests/ASCMCPTests/Workers/InAppPurchaseImagesContractTests.swift @@ -19,7 +19,7 @@ struct InAppPurchaseImagesContractTests { name: "iap_list_images", arguments: [ "iap_id": .string("iap-1"), - "limit": .int(250) + "limit": .int(200) ] )) diff --git a/Tests/ASCMCPTests/Workers/InAppPurchasesV3WorkerTests.swift b/Tests/ASCMCPTests/Workers/InAppPurchasesV3WorkerTests.swift index 6c4167a..6b7da8d 100644 --- a/Tests/ASCMCPTests/Workers/InAppPurchasesV3WorkerTests.swift +++ b/Tests/ASCMCPTests/Workers/InAppPurchasesV3WorkerTests.swift @@ -215,6 +215,88 @@ struct InAppPurchasesV3WorkerTests { #expect(future["price_point_id"] == .string("iap-pp-future")) } + @Test("IAP pricing summary exhausts manual and automatic pagination") + func pricingSummaryExhaustsAllPages() async throws { + let manualNext = try iapPricingContinuationURL( + path: "/v1/inAppPurchasePriceSchedules/schedule-1/manualPrices", + cursor: "manual-next" + ) + let automaticNext = try iapPricingContinuationURL( + path: "/v1/inAppPurchasePriceSchedules/schedule-1/automaticPrices", + cursor: "automatic-next" + ) + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"inAppPurchasePriceSchedules","id":"schedule-1"}}"#), + .init(statusCode: 200, body: """ + { + "data": [{ + "type": "inAppPurchasePrices", + "id": "price-expired", + "attributes": {"startDate": "2020-01-01", "endDate": "2020-12-31", "manual": true}, + "relationships": { + "territory": {"data": {"type": "territories", "id": "USA"}}, + "inAppPurchasePricePoint": {"data": {"type": "inAppPurchasePricePoints", "id": "point-expired"}} + } + }], + "links": {"next": "\(manualNext)"} + } + """), + .init(statusCode: 200, body: """ + { + "data": [{ + "type": "inAppPurchasePrices", + "id": "price-current-page-two", + "attributes": {"startDate": "2021-01-01", "manual": true}, + "relationships": { + "territory": {"data": {"type": "territories", "id": "USA"}}, + "inAppPurchasePricePoint": {"data": {"type": "inAppPurchasePricePoints", "id": "point-current"}} + } + }], + "included": [ + {"type": "territories", "id": "USA", "attributes": {"currency": "USD"}}, + {"type": "inAppPurchasePricePoints", "id": "point-current", "attributes": {"customerPrice": "4.99", "proceeds": "3.50"}} + ], + "links": {"next": null} + } + """), + .init(statusCode: 200, body: """ + { + "data": [{ + "type": "inAppPurchasePrices", + "id": "price-future", + "attributes": {"startDate": "2099-01-01", "manual": false}, + "relationships": { + "territory": {"data": {"type": "territories", "id": "USA"}}, + "inAppPurchasePricePoint": {"data": {"type": "inAppPurchasePricePoints", "id": "point-future"}} + } + }], + "links": {"next": "\(automaticNext)"} + } + """), + .init(statusCode: 200, body: #"{"data":[],"links":{"next":null}}"#) + ]) + let worker = try await makeIAPWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "iap_pricing_summary", + arguments: ["iap_id": .string("iap-1"), "territory_id": .string("USA")] + )) + + #expect(result.isError != true) + #expect(await transport.requestCount() == 5) + let requests = await transport.recordedRequests() + #expect(iapQueryItems(requests[2])["cursor"] == "manual-next") + #expect(iapQueryItems(requests[4])["cursor"] == "automatic-next") + let root = try iapObject(result.structuredContent) + #expect(root["manual_pages_fetched"] == .int(2)) + #expect(root["automatic_pages_fetched"] == .int(2)) + #expect(root["complete"] == .bool(true)) + #expect(root["truncated"] == .bool(false)) + let current = try iapObject(root["current_price"]) + #expect(current["id"] == .string("price-current-page-two")) + #expect(current["customer_price"] == .string("4.99")) + } + @Test("IAP offer code prices support territory filter and normalized price fields") func offerCodePricesSupportTerritoryFilter() async throws { let transport = TestHTTPTransport(responses: [ @@ -280,6 +362,141 @@ struct InAppPurchasesV3WorkerTests { #expect(iapText(result).contains("same count")) } + @Test("IAP create and update preserve explicit null and reject update no-op") + func iapWritesPreserveExplicitNull() async throws { + let response = #"{"data":{"type":"inAppPurchases","id":"iap-1","attributes":{}}}"# + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: response), + .init(statusCode: 200, body: response) + ]) + let worker = try await makeIAPWorker(transport: transport) + + let created = try await worker.handleTool(CallTool.Parameters( + name: "iap_create", + arguments: [ + "app_id": .string("app-1"), + "name": .string("Coins"), + "product_id": .string("coins"), + "iap_type": .string("CONSUMABLE"), + "review_note": .null, + "family_sharable": .null + ] + )) + let updated = try await worker.handleTool(CallTool.Parameters( + name: "iap_update", + arguments: [ + "iap_id": .string("iap-1"), + "name": .null, + "review_note": .null, + "family_sharable": .null + ] + )) + let noOp = try await worker.handleTool(CallTool.Parameters( + name: "iap_update", + arguments: ["iap_id": .string("iap-1")] + )) + + #expect(created.isError != true) + #expect(updated.isError != true) + #expect(noOp.isError == true) + #expect(await transport.requestCount() == 2) + let bodies = await transport.recordedBodyStrings() + let createAttributes = try iapRequestAttributes(bodies[0]) + #expect(createAttributes["reviewNote"] is NSNull) + #expect(createAttributes["familySharable"] is NSNull) + let updateAttributes = try iapRequestAttributes(bodies[1]) + #expect(Set(updateAttributes.keys) == ["name", "reviewNote", "familySharable"]) + #expect(updateAttributes.values.allSatisfy { $0 is NSNull }) + } + + @Test("IAP active updates require a value and preserve explicit null") + func activeUpdatesPreserveExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"inAppPurchaseOfferCodes","id":"offer-1","attributes":{}}}"#), + .init(statusCode: 200, body: #"{"data":{"type":"inAppPurchaseOfferCodeOneTimeUseCodes","id":"batch-1","attributes":{}}}"#), + .init(statusCode: 200, body: #"{"data":{"type":"inAppPurchaseOfferCodeCustomCodes","id":"custom-1","attributes":{}}}"#) + ]) + let worker = try await makeIAPWorker(transport: transport) + let calls: [(String, String, String)] = [ + ("iap_update_offer_code", "offer_code_id", "offer-1"), + ("iap_update_one_time_code", "one_time_code_id", "batch-1"), + ("iap_update_custom_code", "custom_code_id", "custom-1") + ] + + for (tool, idKey, id) in calls { + let result = try await worker.handleTool(CallTool.Parameters( + name: tool, + arguments: [idKey: .string(id), "active": .null] + )) + #expect(result.isError != true) + } + let missing = try await worker.handleTool(CallTool.Parameters( + name: "iap_update_offer_code", + arguments: ["offer_code_id": .string("offer-1")] + )) + + #expect(missing.isError == true) + #expect(await transport.requestCount() == 3) + let bodies = await transport.recordedBodyStrings() + for body in bodies { + let attributes = try iapRequestAttributes(body) + #expect(attributes["active"] is NSNull) + } + } + + @Test("IAP offer eligibility and one-time environment reject unsupported values") + func offerEnumsRejectUnsupportedValues() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeIAPWorker(transport: transport) + + let eligibility = try await worker.handleTool(CallTool.Parameters( + name: "iap_create_offer_code", + arguments: [ + "iap_id": .string("iap-1"), + "name": .string("Launch"), + "customer_eligibilities": .array([.string("NEW")]), + "territory_ids": .array([.string("USA")]), + "price_point_ids": .array([.string("point-1")]) + ] + )) + let environment = try await worker.handleTool(CallTool.Parameters( + name: "iap_generate_one_time_codes", + arguments: [ + "offer_code_id": .string("offer-1"), + "number_of_codes": .int(1), + "expiration_date": .string("2026-12-31"), + "environment": .string("TEST") + ] + )) + + #expect(eligibility.isError == true) + #expect(environment.isError == true) + #expect(await transport.requestCount() == 0) + } + + @Test("IAP one-time code generation preserves explicit null environment") + func oneTimeEnvironmentPreservesExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"inAppPurchaseOfferCodeOneTimeUseCodes","id":"batch-1","attributes":{}}}"#) + ]) + let worker = try await makeIAPWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "iap_generate_one_time_codes", + arguments: [ + "offer_code_id": .string("offer-1"), + "number_of_codes": .int(1), + "expiration_date": .string("2026-12-31"), + "environment": .null + ] + )) + + #expect(result.isError != true) + let body = try #require(await transport.recordedBodyStrings().first) + let attributes = try iapRequestAttributes(body) + #expect(attributes["environment"] is NSNull) + } + @Test("one-time code values use the non-paginated CSV contract losslessly") func oneTimeCodeValuesUseRawCSVContract() async throws { let csv = "code,status\r\n\"A,1\",ACTIVE\r\nКОД-2,ACTIVE\r\n" @@ -449,6 +666,43 @@ struct InAppPurchasesV3WorkerTests { } } + @Test("IAP schemas expose nullable writes and bounded offer enums") + func schemasExposeNullableWritesAndOfferEnums() async throws { + let worker = try await makeIAPWorker(transport: TestHTTPTransport(responses: [])) + let tools = await worker.getTools() + + for toolName in ["iap_create", "iap_update"] { + let tool = try #require(tools.first { $0.name == toolName }) + let properties = try iapObject(try iapObject(tool.inputSchema)["properties"]) + #expect(Set(try iapArray(try iapObject(properties["review_note"])["type"]).compactMap(\.stringValue)) == ["string", "null"]) + #expect(Set(try iapArray(try iapObject(properties["family_sharable"])["type"]).compactMap(\.stringValue)) == ["boolean", "null"]) + } + + let offer = try #require(tools.first { $0.name == "iap_create_offer_code" }) + let offerProperties = try iapObject(try iapObject(offer.inputSchema)["properties"]) + let eligibilities = try iapObject(offerProperties["customer_eligibilities"]) + let eligibilityItems = try iapObject(eligibilities["items"]) + #expect(eligibilities["minItems"] == .int(1)) + #expect(eligibilities["uniqueItems"] == .bool(true)) + #expect(Set(try iapArray(eligibilityItems["enum"]).compactMap(\.stringValue)) == ["NON_SPENDER", "ACTIVE_SPENDER", "CHURNED_SPENDER"]) + + let generate = try #require(tools.first { $0.name == "iap_generate_one_time_codes" }) + let generateProperties = try iapObject(try iapObject(generate.inputSchema)["properties"]) + let environment = try iapObject(generateProperties["environment"]) + #expect(Set(try iapArray(environment["type"]).compactMap(\.stringValue)) == ["string", "null"]) + let environmentValues = try iapArray(environment["enum"]) + #expect(Set(environmentValues.compactMap(\.stringValue)) == ["PRODUCTION", "SANDBOX"]) + #expect(environmentValues.contains(.null)) + + for toolName in ["iap_update_offer_code", "iap_update_one_time_code", "iap_update_custom_code"] { + let tool = try #require(tools.first { $0.name == toolName }) + let root = try iapObject(tool.inputSchema) + let properties = try iapObject(root["properties"]) + #expect(Set(try iapArray(root["required"]).compactMap(\.stringValue)).contains("active")) + #expect(Set(try iapArray(try iapObject(properties["active"])["type"]).compactMap(\.stringValue)) == ["boolean", "null"]) + } + } + @Test("IAP price and availability schemas expose the bounded Apple forms") func reliabilitySchemasExposeInlinePricesAndTerritoryProjection() async throws { let worker = try await makeIAPWorker(transport: TestHTTPTransport(responses: [])) @@ -652,6 +906,70 @@ struct InAppPurchasesV3WorkerTests { #expect(listRoot["page_is_last"] == .bool(false)) #expect(listRoot["next_url"] == .string(nextURL)) } + + @Test("IAP mutation failures retain commit-state semantics") + func mutationFailuresRetainCommitState() async throws { + let unknownTransport = TestHTTPTransport(responses: []) + let unknownWorker = try await makeIAPWorker(transport: unknownTransport) + let unknown = try await unknownWorker.handleTool(.init( + name: "iap_update", + arguments: ["iap_id": .string("iap-1"), "name": .string("Updated")] + )) + + #expect(unknown.isError == true) + let unknownRoot = try iapObject(unknown.structuredContent) + #expect(unknownRoot["operationCommitState"] == .string("unknown")) + #expect(unknownRoot["outcomeUnknown"] == .bool(true)) + #expect(unknownRoot["retrySafe"] == .bool(false)) + + let unverifiedTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: "not-json") + ]) + let unverifiedWorker = try await makeIAPWorker(transport: unverifiedTransport) + let unverified = try await unverifiedWorker.handleTool(.init( + name: "iap_update_offer_code", + arguments: ["offer_code_id": .string("offer-1"), "active": .bool(false)] + )) + + #expect(unverified.isError == true) + let unverifiedRoot = try iapObject(unverified.structuredContent) + #expect(unverifiedRoot["operationCommitState"] == .string("committed_unverified")) + #expect(unverifiedRoot["operationCommitted"] == .bool(true)) + #expect(unverifiedRoot["retrySafe"] == .bool(false)) + } + + @Test("IAP limits reject present invalid values before network") + func limitsRejectInvalidValuesBeforeNetwork() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeIAPWorker(transport: transport) + let cases: [(String, [String: Value])] = [ + ("iap_list", ["app_id": .string("app-1"), "limit": .int(0)]), + ("iap_list_price_points", ["iap_id": .string("iap-1"), "limit": .int(8001)]), + ("iap_list_offer_codes", ["iap_id": .string("iap-1"), "limit": .int(201)]), + ("iap_list_images", ["iap_id": .string("iap-1"), "limit": .string("25")]) + ] + + for (name, arguments) in cases { + let result = try await worker.handleTool(.init(name: name, arguments: arguments)) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("IAP limit schemas publish the runtime bounds") + func limitSchemasPublishRuntimeBounds() async throws { + let worker = try await makeIAPWorker(transport: TestHTTPTransport(responses: [])) + let largeLimitTools: Set = ["iap_list_price_points", "iap_list_price_point_equalizations"] + + for tool in await worker.getTools() { + let root = try iapObject(tool.inputSchema) + let properties = try iapObject(root["properties"]) + guard let limitValue = properties["limit"] else { continue } + let limit = try iapObject(limitValue) + #expect(limit["minimum"] == .int(1)) + #expect(limit["maximum"] == .int(largeLimitTools.contains(tool.name) ? 8000 : 200)) + } + } } private func makeIAPWorker(transport: TestHTTPTransport) async throws -> InAppPurchasesWorker { @@ -671,6 +989,29 @@ private func iapQueryItems(_ request: URLRequest) -> [String: String] { }) } +private func iapPricingContinuationURL(path: String, cursor: String) throws -> String { + var components = URLComponents() + components.scheme = "https" + components.host = "api.example.test" + components.path = path + components.queryItems = [ + .init(name: "filter[territory]", value: "USA"), + .init(name: "include", value: "inAppPurchasePricePoint,territory"), + .init(name: "fields[inAppPurchasePrices]", value: "startDate,endDate,manual,inAppPurchasePricePoint,territory"), + .init(name: "fields[inAppPurchasePricePoints]", value: "customerPrice,proceeds,territory,equalizations"), + .init(name: "fields[territories]", value: "currency"), + .init(name: "limit", value: "200"), + .init(name: "cursor", value: cursor) + ] + return try #require(components.url?.absoluteString) +} + +private func iapRequestAttributes(_ body: String) throws -> [String: Any] { + let root = try #require(JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any]) + let data = try #require(root["data"] as? [String: Any]) + return try #require(data["attributes"] as? [String: Any]) +} + private func iapObject(_ value: Value?) throws -> [String: Value] { guard case .object(let object) = value else { Issue.record("Expected object, got \(String(describing: value))") diff --git a/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift b/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift index 65a5781..c59fb8e 100644 --- a/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift +++ b/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift @@ -112,7 +112,7 @@ struct InternalPaginationScopeTests { "AppEventsWorker": 2, "AppInfoWorker": 2, "AppLifecycleWorker": 3, - "AppsWorker": 9, + "AppsWorker": 10, "BetaFeedbackWorker": 2, "BetaGroupsWorker": 3, "BetaLicenseAgreementsWorker": 1, @@ -158,7 +158,7 @@ struct InternalPaginationScopeTests { strictScopeCount += workerStrictScopeCount } - #expect(strictScopeCount == 61) + #expect(strictScopeCount == 62) #expect(scopedLinkScopeCounts == expectedScopedLinkScopeCounts) } } diff --git a/Tests/ASCMCPTests/Workers/MetricsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/MetricsWorkerContractTests.swift index eea9fe6..edad849 100644 --- a/Tests/ASCMCPTests/Workers/MetricsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/MetricsWorkerContractTests.swift @@ -93,6 +93,11 @@ struct MetricsWorkerContractTests { let category = try metricsObject(try #require(categories.first)) let metrics = try metricsArray(category["metrics"]) let metric = try metricsObject(try #require(metrics.first)) + let goalKeys = try metricsArray(metric["goal_keys"]) + let goalKey = try metricsObject(try #require(goalKeys.first)) + #expect(goalKey["goal_key"] == .string("storageP90")) + #expect(goalKey["lower_bound"] == .int(0)) + #expect(goalKey["upper_bound"] == .int(200)) let datasets = try metricsArray(metric["datasets"]) let dataset = try metricsObject(try #require(datasets.first)) let points = try metricsArray(dataset["points"]) @@ -384,7 +389,7 @@ struct MetricsWorkerContractTests { name: "metrics_get_diagnostic_logs", arguments: [ "signature_id": .string("signature-1"), - "limit": .int(500) + "limit": .int(200) ] )) @@ -420,6 +425,27 @@ struct MetricsWorkerContractTests { #expect(frame["address"] == .string("0x0000000100001234")) #expect(frame["offset_into_binary_text_segment"] == .string("4660")) } + + @Test("diagnostic limits reject invalid present values before network access") + func diagnosticLimitsRejectInvalidValues() async throws { + for (tool, idField) in [ + ("metrics_build_diagnostics", "build_id"), + ("metrics_get_diagnostic_logs", "signature_id") + ] { + for invalid in [Value.int(0), .int(201), .string("25")] { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeMetricsWorker(transport: transport) + let result = try await worker.handleTool(CallTool.Parameters( + name: tool, + arguments: [idField: .string("resource-1"), "limit": invalid] + )) + + #expect(result.isError == true) + #expect(metricsText(result).contains("limit must be an integer from 1 through 200")) + #expect(await transport.requestCount() == 0) + } + } + } } private func makeMetricsWorker(transport: TestHTTPTransport) async throws -> MetricsWorker { diff --git a/Tests/ASCMCPTests/Workers/PricingWorkerContractHardeningTests.swift b/Tests/ASCMCPTests/Workers/PricingWorkerContractHardeningTests.swift index f2b96df..b4d40f2 100644 --- a/Tests/ASCMCPTests/Workers/PricingWorkerContractHardeningTests.swift +++ b/Tests/ASCMCPTests/Workers/PricingWorkerContractHardeningTests.swift @@ -5,6 +5,95 @@ import Testing @Suite("Pricing Worker Contract Hardening Tests") struct PricingWorkerContractHardeningTests { + @Test("list schemas publish Apple limit bounds and local defaults") + func listLimitSchemas() async throws { + let worker = try await makePricingHardeningWorker( + transport: TestHTTPTransport(responses: []) + ) + let tools = await worker.getTools() + let expectedDefaults = [ + "pricing_list_territories": 200, + "pricing_list_price_points": 50, + "pricing_list_territory_availability": 50, + "pricing_list_territory_availabilities": 50 + ] + + for (toolName, expectedDefault) in expectedDefaults { + let tool = try #require(tools.first { $0.name == toolName }) + let schema = try pricingHardeningObject(tool.inputSchema) + let properties = try pricingHardeningObject(schema["properties"]) + let limit = try pricingHardeningObject(properties["limit"]) + #expect(limit["minimum"] == .int(1)) + #expect(limit["maximum"] == .int(200)) + #expect(limit["default"] == .int(expectedDefault)) + } + } + + @Test("list handlers reject out-of-range limits before network access") + func listLimitRuntimeValidation() async throws { + let calls = [ + CallTool.Parameters(name: "pricing_list_territories", arguments: ["limit": .int(0)]), + CallTool.Parameters(name: "pricing_list_price_points", arguments: ["app_id": .string("app-1"), "limit": .int(201)]), + CallTool.Parameters(name: "pricing_list_territory_availability", arguments: ["app_id": .string("app-1"), "limit": .int(0)]), + CallTool.Parameters(name: "pricing_list_territory_availabilities", arguments: ["availability_id": .string("availability-1"), "limit": .int(201)]) + ] + + for call in calls { + let transport = TestHTTPTransport(responses: []) + let worker = try await makePricingHardeningWorker(transport: transport) + let result = try await worker.handleTool(call) + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + + @Test("nested pricing limits reject invalid values before network access") + func nestedLimitRuntimeValidation() async throws { + let calls = [ + CallTool.Parameters( + name: "pricing_get_price_schedule", + arguments: ["app_id": .string("app-1"), "manual_prices_limit": .int(0)] + ), + CallTool.Parameters( + name: "pricing_get_price_schedule", + arguments: ["app_id": .string("app-1"), "automatic_prices_limit": .string("50")] + ), + CallTool.Parameters( + name: "pricing_get_availability_v2", + arguments: ["availability_id": .string("availability-1"), "territory_availabilities_limit": .int(51)] + ) + ] + + for call in calls { + let transport = TestHTTPTransport(responses: []) + let worker = try await makePricingHardeningWorker(transport: transport) + let result = try await worker.handleTool(call) + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + } + + @Test("pricing manifest declares formatter completeness fields") + func manifestOutputParity() throws { + let manifest = try ASCOperationManifestBundle.loadBundled() + let expectedAvailabilityFields: Set = [ + "availability.territory_availabilities_included_count", + "availability.territory_availabilities_total", + "availability.territory_availabilities_limit", + "availability.territory_availabilities_related_url", + "availability.territory_availabilities_relationship_url", + "availability.territory_availabilities_truncated" + ] + + for toolName in ["pricing_create_availability", "pricing_get_availability", "pricing_get_availability_v2"] { + let mapping = try #require(manifest.mapping(for: toolName)) + #expect(Set(mapping.response.fields.map(\.outputField)).isSuperset(of: expectedAvailabilityFields)) + } + + let schedule = try #require(manifest.mapping(for: "pricing_get_price_schedule")) + #expect(Set(schedule.response.fields.map(\.outputField)).contains("price_schedule.base_territory")) + } + @Test("published availability creation requires at least one territory source") func availabilityCreationSchemaPreservesSourceRequirement() async throws { let worker = try await makePricingHardeningWorker( @@ -151,6 +240,43 @@ struct PricingWorkerContractHardeningTests { #expect(await transport.requestCount() == 0) } + @Test("pricing creates reject invalid Apple resource identities") + func rejectsInvalidCreateResourceIdentities() async throws { + let scheduleTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"appPrices","id":"schedule-1"}}"#) + ]) + let scheduleWorker = try await makePricingHardeningWorker(transport: scheduleTransport) + let scheduleResult = try await scheduleWorker.handleTool(CallTool.Parameters( + name: "pricing_set_price_schedule", + arguments: [ + "app_id": .string("app-1"), + "base_territory_id": .string("USA"), + "price_point_id": .string("point-1") + ] + )) + + let availabilityTransport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"appAvailabilities","id":""}}"#) + ]) + let availabilityWorker = try await makePricingHardeningWorker(transport: availabilityTransport) + let availabilityResult = try await availabilityWorker.handleTool(CallTool.Parameters( + name: "pricing_create_availability", + arguments: [ + "app_id": .string("app-1"), + "available_in_new_territories": .bool(true), + "territory_ids": .array([.string("territory-availability-1")]) + ] + )) + + #expect(scheduleResult.isError == true) + #expect(availabilityResult.isError == true) + for result in [scheduleResult, availabilityResult] { + let payload = try pricingHardeningObject(result.structuredContent) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + } + @Test("price schedule rejects conflicting input modes before the network") func rejectsConflictingPriceScheduleModes() async throws { let transport = TestHTTPTransport(responses: []) diff --git a/Tests/ASCMCPTests/Workers/PromotedPurchaseImageDeprecationTests.swift b/Tests/ASCMCPTests/Workers/PromotedPurchaseImageDeprecationTests.swift index 9d97b46..1bb2062 100644 --- a/Tests/ASCMCPTests/Workers/PromotedPurchaseImageDeprecationTests.swift +++ b/Tests/ASCMCPTests/Workers/PromotedPurchaseImageDeprecationTests.swift @@ -32,8 +32,10 @@ struct PromotedPurchaseImageDeprecationTests { Issue.record("Expected replacement tool list") continue } - #expect(replacements.contains(.string("iap_upload_image"))) - #expect(replacements.contains(.string("subscriptions_upload_image"))) + #expect(replacements.contains(.string("iap_upload_version_image"))) + #expect(replacements.contains(.string("subscriptions_upload_version_image"))) + #expect(!replacements.contains(.string("iap_upload_image"))) + #expect(!replacements.contains(.string("subscriptions_upload_image"))) } #expect(await transport.requestCount() == 0) @@ -50,6 +52,23 @@ struct PromotedPurchaseImageDeprecationTests { for tool in imageTools { #expect(tool.description?.contains("Deprecated") == true) } + + for toolName in ["promoted_get_image", "promoted_delete_image"] { + let tool = try #require(imageTools.first { $0.name == toolName }) + let properties = try promotedDeprecationObject( + try promotedDeprecationObject(tool.inputSchema)["properties"] + ) + let imageID = try promotedDeprecationObject(properties["image_id"]) + #expect(imageID["pattern"] == .string(#"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"#)) + } + + let upload = try #require(imageTools.first { $0.name == "promoted_upload_image" }) + let uploadProperties = try promotedDeprecationObject( + try promotedDeprecationObject(upload.inputSchema)["properties"] + ) + let filePath = try promotedDeprecationObject(uploadProperties["file_path"]) + #expect(filePath["minLength"] == .int(1)) + #expect(filePath["pattern"] == .string(#"^\S(?:[\s\S]*\S)?$"#)) } } @@ -88,6 +107,14 @@ private func promotedDeprecationDetails( return details } +private func promotedDeprecationObject(_ value: Value?) throws -> [String: Value] { + guard case .object(let object) = value else { + Issue.record("Expected schema object") + throw PromotedPurchaseImageDeprecationTestError.missingDetails + } + return object +} + private enum PromotedPurchaseImageDeprecationTestError: Error { case missingDetails } diff --git a/Tests/ASCMCPTests/Workers/PromotedV319ContractTests.swift b/Tests/ASCMCPTests/Workers/PromotedV319ContractTests.swift index 886cb7c..d444534 100644 --- a/Tests/ASCMCPTests/Workers/PromotedV319ContractTests.swift +++ b/Tests/ASCMCPTests/Workers/PromotedV319ContractTests.swift @@ -26,7 +26,7 @@ struct PromotedV319ContractTests { let identifiers = try promotedV319Object(reorderProperties["promoted_purchase_ids"]) #expect(identifiers["type"] == .string("array")) #expect(identifiers["minItems"] == .int(1)) - #expect(identifiers["maxItems"] == .int(200)) + #expect(identifiers["maxItems"] == nil) #expect(identifiers["uniqueItems"] == .bool(true)) let canonicalIDPattern = #"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"# let identifierItems = try promotedV319Object(identifiers["items"]) @@ -468,6 +468,12 @@ struct PromotedV319ContractTests { let wrongStatusRoot = try promotedV319Object(wrongStatus.structuredContent) #expect(wrongStatusRoot["operationCommitState"] == .string("committed_unverified")) #expect(wrongStatusRoot["inspectionRequired"] == .bool(true)) + let wrongStatusDetails = try promotedV319Object(wrongStatusRoot["details"]) + let wrongStatusCause = try promotedV319Object(wrongStatusDetails["cause"]) + #expect(wrongStatusCause["type"] == .string("mutation_unverified")) + #expect(wrongStatusCause["method"] == .string("PATCH")) + #expect(wrongStatusCause["expectedStatusCode"] == .int(204)) + #expect(wrongStatusCause["statusCode"] == .int(200)) #expect(await wrongStatusTransport.requestCount() == 2) let mismatchTransport = TestHTTPTransport(responses: [ @@ -533,6 +539,11 @@ struct PromotedV319ContractTests { let createRoot = try promotedV319Object(create.structuredContent) #expect(createRoot["operationCommitState"] == .string("committed_unverified")) let createDetails = try promotedV319Object(createRoot["details"]) + let createCause = try promotedV319Object(createDetails["cause"]) + #expect(createCause["type"] == .string("mutation_unverified")) + #expect(createCause["method"] == .string("POST")) + #expect(createCause["expectedStatusCode"] == .int(201)) + #expect(createCause["statusCode"] == .int(200)) let createRecovery = try promotedV319Object(createDetails["recovery"]) #expect(try promotedV319Object(createRecovery["list_candidates"])["tool"] == .string("promoted_list")) let candidateScope = try promotedV319Object(createRecovery["candidate_scope"]) @@ -940,6 +951,10 @@ struct PromotedV319ContractTests { let unknownRoot = try promotedV319Object(unknown.structuredContent) #expect(unknownRoot["operationCommitState"] == .string("unknown")) #expect(unknownRoot["outcomeUnknown"] == .bool(true)) + let unknownDetails = try promotedV319Object(unknownRoot["details"]) + let unknownCause = try promotedV319Object(unknownDetails["cause"]) + #expect(unknownCause["type"] == .string("mutation_unknown")) + #expect(unknownCause["method"] == .string("PATCH")) let rejectedTransport = TestHTTPTransport(responses: [ .init( @@ -1066,15 +1081,6 @@ struct PromotedV319ContractTests { "promoted_purchase_ids": .array([]) ] ), - .init( - name: "promoted_reorder", - arguments: [ - "app_id": .string("app-1"), - "promoted_purchase_ids": .array((0...200).map { - .string("promoted-\($0)") - }) - ] - ), .init( name: "promoted_list", arguments: [ @@ -1091,6 +1097,48 @@ struct PromotedV319ContractTests { #expect(await transport.requestCount() == 0) } + @Test("reorder accepts more than 200 unique IDs and paginates membership preflight") + func reorderHasNoArtificialInputMaximum() async throws { + let identifiers = (0...200).map { "promoted-\($0)" } + let transport = TestHTTPTransport(responses: [ + .init( + statusCode: 200, + body: promotedV319LinkagePage( + ids: Array(identifiers.prefix(200)), + cursor: nil, + nextCursor: "page-2", + total: identifiers.count + ) + ), + .init( + statusCode: 200, + body: promotedV319LinkagePage( + ids: Array(identifiers.dropFirst(200)), + cursor: "page-2", + nextCursor: nil, + total: identifiers.count + ) + ) + ]) + let worker = PromotedPurchasesWorker( + httpClient: try await promotedV319Client(transport), + uploadService: UploadService() + ) + let result = try await worker.handleTool(.init( + name: "promoted_reorder", + arguments: [ + "app_id": .string("app-1"), + "promoted_purchase_ids": .array(identifiers.map(Value.string)) + ] + )) + + #expect(result.isError != true) + #expect(await transport.recordedRequests().map(\.httpMethod) == ["GET", "GET"]) + let payload = try promotedV319Object(result.structuredContent) + #expect(payload["operationCommitState"] == .string("not_attempted")) + #expect(payload["changed"] == .bool(false)) + } + @Test("operation manifest maps reorder and exact mutation statuses") func manifestMapsPinnedOperations() throws { let manifest = try ASCOperationManifestBundle.loadBundled() diff --git a/Tests/ASCMCPTests/Workers/ProvisioningWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/ProvisioningWorkerContractTests.swift index a212a7b..d824d32 100644 --- a/Tests/ASCMCPTests/Workers/ProvisioningWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ProvisioningWorkerContractTests.swift @@ -57,7 +57,8 @@ struct ProvisioningWorkerContractTests { "filter_platform": .string("IOS,UNIVERSAL"), "filter_identifier": .string("com.example.one,com.example.two"), "filter_seed_id": .string("SEED1,SEED2"), - "filter_id": .string("bundle-1,bundle-2") + "filter_id": .string("bundle-1,bundle-2"), + "sort": .array([.string("name"), .string("-id")]) ] )) _ = try await worker.handleTool(CallTool.Parameters( @@ -67,7 +68,8 @@ struct ProvisioningWorkerContractTests { "filter_platform": .string("IOS,UNIVERSAL"), "filter_udid": .string("UDID1,UDID2"), "filter_status": .string("ENABLED,DISABLED"), - "filter_id": .string("device-1,device-2") + "filter_id": .string("device-1,device-2"), + "sort": .array([.string("udid"), .string("-status")]) ] )) _ = try await worker.handleTool(CallTool.Parameters( @@ -76,7 +78,8 @@ struct ProvisioningWorkerContractTests { "filter_display_name": .string("Distribution One,Distribution Two"), "filter_type": .string("IOS_DISTRIBUTION,DISTRIBUTION"), "filter_serial_number": .string("SERIAL1,SERIAL2"), - "filter_id": .string("certificate-1,certificate-2") + "filter_id": .string("certificate-1,certificate-2"), + "sort": .array([.string("displayName"), .string("-id")]) ] )) _ = try await worker.handleTool(CallTool.Parameters( @@ -85,7 +88,8 @@ struct ProvisioningWorkerContractTests { "filter_name": .string("Profile One,Profile Two"), "filter_profile_type": .string("IOS_APP_STORE,MAC_APP_STORE"), "filter_profile_state": .string("ACTIVE,INVALID"), - "filter_id": .string("profile-1,profile-2") + "filter_id": .string("profile-1,profile-2"), + "sort": .array([.string("profileType"), .string("-profileState")]) ] )) @@ -98,6 +102,7 @@ struct ProvisioningWorkerContractTests { #expect(bundleQuery["filter[identifier]"] == "com.example.one,com.example.two") #expect(bundleQuery["filter[seedId]"] == "SEED1,SEED2") #expect(bundleQuery["filter[id]"] == "bundle-1,bundle-2") + #expect(bundleQuery["sort"] == "name,-id") let deviceQuery = provisioningQueryItems(try #require(requests[safe: 1])) #expect(deviceQuery["filter[name]"] == "Phone One,Phone Two") @@ -105,18 +110,21 @@ struct ProvisioningWorkerContractTests { #expect(deviceQuery["filter[udid]"] == "UDID1,UDID2") #expect(deviceQuery["filter[status]"] == "ENABLED,DISABLED") #expect(deviceQuery["filter[id]"] == "device-1,device-2") + #expect(deviceQuery["sort"] == "udid,-status") let certificateQuery = provisioningQueryItems(try #require(requests[safe: 2])) #expect(certificateQuery["filter[displayName]"] == "Distribution One,Distribution Two") #expect(certificateQuery["filter[certificateType]"] == "IOS_DISTRIBUTION,DISTRIBUTION") #expect(certificateQuery["filter[serialNumber]"] == "SERIAL1,SERIAL2") #expect(certificateQuery["filter[id]"] == "certificate-1,certificate-2") + #expect(certificateQuery["sort"] == "displayName,-id") let profileQuery = provisioningQueryItems(try #require(requests[safe: 3])) #expect(profileQuery["filter[name]"] == "Profile One,Profile Two") #expect(profileQuery["filter[profileType]"] == "IOS_APP_STORE,MAC_APP_STORE") #expect(profileQuery["filter[profileState]"] == "ACTIVE,INVALID") #expect(profileQuery["filter[id]"] == "profile-1,profile-2") + #expect(profileQuery["sort"] == "profileType,-profileState") } @Test("capability limit reaches Apple and current setting flags reach MCP output") @@ -286,6 +294,8 @@ struct ProvisioningWorkerContractTests { let devices = try provisioningDictionary(relationships["devices"])["data"] as? [[String: Any]] #expect(certificates?.compactMap { $0["id"] as? String } == ["certificate-1", "certificate-2"]) #expect(devices?.compactMap { $0["id"] as? String } == ["device-1", "device-2"]) + #expect(certificates?.allSatisfy { $0["type"] as? String == "certificates" } == true) + #expect(devices?.allSatisfy { $0["type"] as? String == "devices" } == true) let payload = try provisioningObject(result.structuredContent) let profile = try provisioningObject(payload["profile"]) @@ -306,21 +316,331 @@ struct ProvisioningWorkerContractTests { #expect(await transport.requestCount() == 0) } - @Test("capability creation rejects settings of the wrong MCP type before network") - func capabilitySettingsTypeIsValidated() async throws { + @Test("nullable provisioning writes preserve explicit null and accept sparse responses") + func nullableWritesAndSparseResponses() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"bundleIds","id":"bundle-1"}}"#), + .init(statusCode: 200, body: #"{"data":{"type":"devices","id":"device-1"}}"#) + ]) + let worker = try await makeProvisioningWorker(transport: transport) + + let bundleResult = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_create_bundle_id", + arguments: [ + "name": .string("Example"), + "identifier": .string("com.example.app"), + "platform": .string("IOS"), + "seed_id": .null + ] + )) + let deviceResult = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_update_device", + arguments: [ + "device_id": .string("device-1"), + "name": .null, + "status": .null + ] + )) + + #expect(bundleResult.isError != true) + #expect(deviceResult.isError != true) + let requests = await transport.recordedRequests() + #expect(requests.count == 2) + + let bundleBody = try provisioningJSONBody(try #require(requests[safe: 0])) + let bundleData = try provisioningDictionary(bundleBody["data"]) + let bundleAttributes = try provisioningDictionary(bundleData["attributes"]) + #expect(bundleAttributes["seedId"] is NSNull) + + let deviceBody = try provisioningJSONBody(try #require(requests[safe: 1])) + let deviceData = try provisioningDictionary(deviceBody["data"]) + let deviceAttributes = try provisioningDictionary(deviceData["attributes"]) + #expect(deviceAttributes["name"] is NSNull) + #expect(deviceAttributes["status"] is NSNull) + } + + @Test("provisioning POST preserves an unknown mutation outcome") + func postPreservesUnknownMutationOutcome() async throws { let transport = TestHTTPTransport(responses: []) let worker = try await makeProvisioningWorker(transport: transport) let result = try await worker.handleTool(CallTool.Parameters( - name: "provisioning_enable_capability", + name: "provisioning_create_bundle_id", arguments: [ - "bundle_id_resource_id": .string("bundle-1"), - "capability_type": .string("ICLOUD"), - "settings": .array([]) + "name": .string("Example"), + "identifier": .string("com.example.app"), + "platform": .string("IOS") ] )) #expect(result.isError == true) + let root = try provisioningObject(result.structuredContent) + #expect(root["operationCommitState"] == .string("unknown")) + #expect(root["outcomeUnknown"] == .bool(true)) + #expect(root["retrySafe"] == .bool(false)) + #expect(root["inspectionRequired"] == .bool(true)) + let details = try provisioningObject(root["details"]) + #expect(details["type"] == .string("mutation_unknown")) + #expect(details["method"] == .string("POST")) + #expect(await transport.requestCount() == 1) + } + + @Test("provisioning DELETE preserves a committed-unverified outcome") + func deletePreservesCommittedUnverifiedOutcome() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 202, body: "") + ]) + let worker = try await makeProvisioningWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_delete_bundle_id", + arguments: ["bundle_id_resource_id": .string("bundle-1")] + )) + + #expect(result.isError == true) + let root = try provisioningObject(result.structuredContent) + #expect(root["operationCommitState"] == .string("committed_unverified")) + #expect(root["operationCommitted"] == .bool(true)) + #expect(root["retrySafe"] == .bool(false)) + let details = try provisioningObject(root["details"]) + #expect(details["type"] == .string("delete_unverified")) + #expect(details["statusCode"] == .int(202)) + #expect(await transport.requestCount() == 1) + } + + @Test("provisioning mutations reject values outside Apple enums before network") + func mutationEnumsAreValidated() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeProvisioningWorker(transport: transport) + let calls = [ + CallTool.Parameters( + name: "provisioning_create_bundle_id", + arguments: [ + "name": .string("App"), + "identifier": .string("com.example.app"), + "platform": .string("TV_OS") + ] + ), + CallTool.Parameters( + name: "provisioning_register_device", + arguments: [ + "name": .string("Phone"), + "udid": .string("UDID"), + "platform": .string("TV_OS") + ] + ), + CallTool.Parameters( + name: "provisioning_update_device", + arguments: [ + "device_id": .string("device-1"), + "status": .string("UNKNOWN") + ] + ), + CallTool.Parameters( + name: "provisioning_create_profile", + arguments: [ + "name": .string("Profile"), + "profile_type": .string("UNKNOWN"), + "bundle_id_resource_id": .string("bundle-1"), + "certificate_ids": .array([.string("certificate-1")]) + ] + ), + CallTool.Parameters( + name: "provisioning_enable_capability", + arguments: [ + "bundle_id_resource_id": .string("bundle-1"), + "capability_type": .string("UNKNOWN") + ] + ) + ] + + for call in calls { + let result = try await worker.handleTool(call) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("provisioning collection limits reject wrong types and out-of-range values before network") + func collectionLimitsAreStrictlyValidated() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeProvisioningWorker(transport: transport) + let calls: [(String, [String: Value])] = [ + ("provisioning_list_bundle_ids", [:]), + ("provisioning_list_devices", [:]), + ("provisioning_list_certificates", [:]), + ("provisioning_list_profiles", [:]), + ("provisioning_list_capabilities", ["bundle_id_resource_id": .string("bundle-1")]) + ] + let invalidLimits: [Value] = [.null, .string("25"), .bool(true), .int(0), .int(201)] + + for (tool, baseArguments) in calls { + for limit in invalidLimits { + var arguments = baseArguments + arguments["limit"] = limit + let result = try await worker.handleTool(CallTool.Parameters( + name: tool, + arguments: arguments + )) + #expect(result.isError == true) + } + } + + #expect(await transport.requestCount() == 0) + } + + @Test("provisioning collection enums reject unsupported values before network") + func collectionEnumsAreStrictlyValidated() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeProvisioningWorker(transport: transport) + let cases: [(String, String)] = [ + ("provisioning_list_bundle_ids", "filter_platform"), + ("provisioning_list_bundle_ids", "sort"), + ("provisioning_list_devices", "filter_platform"), + ("provisioning_list_devices", "filter_status"), + ("provisioning_list_devices", "sort"), + ("provisioning_list_certificates", "filter_type"), + ("provisioning_list_certificates", "sort"), + ("provisioning_list_profiles", "filter_profile_type"), + ("provisioning_list_profiles", "filter_profile_state"), + ("provisioning_list_profiles", "sort") + ] + + for (tool, field) in cases { + let result = try await worker.handleTool(CallTool.Parameters( + name: tool, + arguments: [field: .string("UNKNOWN")] + )) + #expect(result.isError == true) + } + + #expect(await transport.requestCount() == 0) + } + + @Test("provisioning collections expose paging totals and use the default limit") + func collectionPagingTotalsArePreserved() async throws { + let responses = (1...5).map { total in + TestHTTPTransport.Response( + statusCode: 200, + body: #"{"data":[],"meta":{"paging":{"total":\#(total),"limit":25}}}"# + ) + } + let transport = TestHTTPTransport(responses: responses) + let worker = try await makeProvisioningWorker(transport: transport) + let calls: [(String, [String: Value])] = [ + ("provisioning_list_bundle_ids", [:]), + ("provisioning_list_devices", [:]), + ("provisioning_list_certificates", [:]), + ("provisioning_list_profiles", [:]), + ("provisioning_list_capabilities", ["bundle_id_resource_id": .string("bundle-1")]) + ] + + for (index, call) in calls.enumerated() { + let result = try await worker.handleTool(CallTool.Parameters( + name: call.0, + arguments: call.1 + )) + #expect(result.isError != true) + let payload = try provisioningObject(result.structuredContent) + #expect(payload["total"] == .int(index + 1)) + } + + let requests = await transport.recordedRequests() + #expect(requests.count == 5) + for request in requests { + #expect(provisioningQueryItems(request)["limit"] == "25") + } + } + + @Test("capability settings preserve omission, null, empty values, and empty response arrays") + func capabilitySettingsTriStateIsPreserved() async throws { + let response = #"{"data":{"type":"bundleIdCapabilities","id":"capability-1","attributes":{"capabilityType":"ICLOUD","settings":[]}}}"# + let transport = TestHTTPTransport(responses: Array( + repeating: .init(statusCode: 201, body: response), + count: 3 + )) + let worker = try await makeProvisioningWorker(transport: transport) + let baseArguments: [String: Value] = [ + "bundle_id_resource_id": .string("bundle-1"), + "capability_type": .string("ICLOUD") + ] + + let omitted = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_enable_capability", + arguments: baseArguments + )) + var nullArguments = baseArguments + nullArguments["settings"] = .null + let explicitNull = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_enable_capability", + arguments: nullArguments + )) + var emptyArguments = baseArguments + emptyArguments["settings"] = .array([]) + let empty = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_enable_capability", + arguments: emptyArguments + )) + + #expect(omitted.isError != true) + #expect(explicitNull.isError != true) + #expect(empty.isError != true) + let requests = await transport.recordedRequests() + #expect(requests.count == 3) + + let omittedAttributes = try provisioningDictionary( + try provisioningDictionary( + try provisioningJSONBody(try #require(requests[safe: 0]))["data"] + )["attributes"] + ) + #expect(omittedAttributes.keys.contains("settings") == false) + + let nullAttributes = try provisioningDictionary( + try provisioningDictionary( + try provisioningJSONBody(try #require(requests[safe: 1]))["data"] + )["attributes"] + ) + #expect(nullAttributes["settings"] is NSNull) + + let emptyAttributes = try provisioningDictionary( + try provisioningDictionary( + try provisioningJSONBody(try #require(requests[safe: 2]))["data"] + )["attributes"] + ) + #expect((emptyAttributes["settings"] as? [Any])?.isEmpty == true) + + let payload = try provisioningObject(empty.structuredContent) + let capability = try provisioningObject(payload["capability"]) + #expect(try provisioningArray(capability["settings"]).isEmpty) + } + + @Test("capability settings reject malformed nested values before network") + func capabilitySettingsAreStrictlyValidated() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeProvisioningWorker(transport: transport) + let invalidSettings: [Value] = [ + .string("[]"), + .array([.object(["unknown": .bool(true)])]), + .array([.object(["key": .string("UNKNOWN")])]), + .array([.object(["allowedInstances": .string("UNKNOWN")])]), + .array([.object(["name": .int(1)])]), + .array([.object(["options": .array([.object(["unknown": .bool(true)])])])]), + .array([.object(["options": .array([.object(["key": .string("UNKNOWN")])])])]), + .array([.object(["options": .array([.object(["enabled": .string("yes")])])])]) + ] + + for settings in invalidSettings { + let result = try await worker.handleTool(CallTool.Parameters( + name: "provisioning_enable_capability", + arguments: [ + "bundle_id_resource_id": .string("bundle-1"), + "capability_type": .string("ICLOUD"), + "settings": settings + ] + )) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) } @@ -329,6 +649,16 @@ struct ProvisioningWorkerContractTests { let worker = try await makeProvisioningWorker(transport: TestHTTPTransport(responses: [])) let tools = await worker.getTools() + func property(_ toolName: String, _ field: String) throws -> [String: Value] { + let tool = try #require(tools.first { $0.name == toolName }) + guard case .object(let root) = tool.inputSchema, + case .object(let properties)? = root["properties"], + case .object(let property)? = properties[field] else { + throw ProvisioningContractTestFailure.expectedObject + } + return property + } + let expected: [String: Set] = [ "provisioning_list_bundle_ids": ["filter_name", "filter_platform", "filter_identifier", "filter_seed_id", "filter_id", "limit", "sort", "next_url"], "provisioning_list_devices": ["filter_name", "filter_platform", "filter_udid", "filter_status", "filter_id", "limit", "sort", "next_url"], @@ -365,6 +695,73 @@ struct ProvisioningWorkerContractTests { } #expect(bundleProperties["seed_id"] != nil) #expect(!bundleRequired.contains(.string("seed_id"))) + + let createPlatform = try property("provisioning_create_bundle_id", "platform") + let registerPlatform = try property("provisioning_register_device", "platform") + let updateStatus = try property("provisioning_update_device", "status") + let profileType = try property("provisioning_create_profile", "profile_type") + let capabilityType = try property("provisioning_enable_capability", "capability_type") + #expect(createPlatform["enum"] == .array(ProvisioningWorker.bundleIdPlatforms.map(Value.string))) + #expect(registerPlatform["enum"] == .array(ProvisioningWorker.bundleIdPlatforms.map(Value.string))) + #expect(updateStatus["enum"] == .array(ProvisioningWorker.deviceStatuses.map(Value.string) + [.null])) + #expect(profileType["enum"] == .array(ProvisioningWorker.profileTypes.map(Value.string))) + #expect(capabilityType["enum"] == .array(ProvisioningWorker.capabilityTypes.map(Value.string))) + + let enumLists: [(String, String, [String])] = [ + ("provisioning_list_bundle_ids", "filter_platform", ProvisioningWorker.bundleIdPlatforms), + ("provisioning_list_bundle_ids", "sort", ProvisioningWorker.bundleIdSortValues), + ("provisioning_list_devices", "filter_platform", ProvisioningWorker.bundleIdPlatforms), + ("provisioning_list_devices", "filter_status", ProvisioningWorker.deviceStatuses), + ("provisioning_list_devices", "sort", ProvisioningWorker.deviceSortValues), + ("provisioning_list_certificates", "filter_type", ProvisioningWorker.certificateTypes), + ("provisioning_list_certificates", "sort", ProvisioningWorker.certificateSortValues), + ("provisioning_list_profiles", "filter_profile_type", ProvisioningWorker.profileTypes), + ("provisioning_list_profiles", "filter_profile_state", ProvisioningWorker.profileStates), + ("provisioning_list_profiles", "sort", ProvisioningWorker.profileSortValues) + ] + for (toolName, field, values) in enumLists { + let schema = try property(toolName, field) + guard case .array(let alternatives)? = schema["oneOf"], + case .object(let scalar)? = alternatives.first, + case .array(let scalarValues)? = scalar["enum"], + case .object(let array)? = alternatives.last, + case .object(let items)? = array["items"], + case .array(let itemValues)? = items["enum"] else { + throw ProvisioningContractTestFailure.expectedArray + } + #expect(scalarValues == values.map(Value.string)) + #expect(itemValues == values.map(Value.string)) + } + + let settings = try property("provisioning_enable_capability", "settings") + guard case .array(let settingsTypes)? = settings["type"], + case .object(let settingSchema)? = settings["items"], + case .object(let settingProperties)? = settingSchema["properties"], + case .object(let settingKey)? = settingProperties["key"], + case .object(let allowedInstances)? = settingProperties["allowedInstances"], + case .object(let options)? = settingProperties["options"], + case .object(let optionSchema)? = options["items"], + case .object(let optionProperties)? = optionSchema["properties"], + case .object(let optionKey)? = optionProperties["key"] else { + throw ProvisioningContractTestFailure.expectedObject + } + #expect(Set(settingsTypes.compactMap(\.stringValue)) == ["array", "null"]) + #expect(settingSchema["additionalProperties"] == .bool(false)) + #expect(settingKey["enum"] == .array(ProvisioningWorker.capabilitySettingKeys.map(Value.string))) + #expect(allowedInstances["enum"] == .array(ProvisioningWorker.capabilityAllowedInstances.map(Value.string))) + #expect(optionSchema["additionalProperties"] == .bool(false)) + #expect(optionKey["enum"] == .array(ProvisioningWorker.capabilityOptionKeys.map(Value.string))) + + let seedID = try property("provisioning_create_bundle_id", "seed_id") + let updateName = try property("provisioning_update_device", "name") + guard case .array(let seedTypes)? = seedID["type"], + case .array(let nameTypes)? = updateName["type"], + case .array(let statusTypes)? = updateStatus["type"] else { + throw ProvisioningContractTestFailure.expectedArray + } + #expect(Set(seedTypes.compactMap(\.stringValue)) == ["string", "null"]) + #expect(Set(nameTypes.compactMap(\.stringValue)) == ["string", "null"]) + #expect(Set(statusTypes.compactMap(\.stringValue)) == ["string", "null"]) } } diff --git a/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift index ddeab2c..da15c46 100644 --- a/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift @@ -108,6 +108,20 @@ struct ReviewSubmissionsWorkerContractTests { #expect(listItems["next_url"]?.objectValue?["format"] == .string("uri-reference")) } + @Test("direct calls enforce the canonical identifier pattern published by every schema") + func canonicalIdentifierRuntimeParity() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeReviewSubmissionsWorker(transport: transport) + for identifier in ["bad id", "идентификатор", "bad%2Fid"] { + let result = try await worker.handleTool(.init( + name: "review_submissions_get", + arguments: ["submission_id": .string(identifier)] + )) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + @Test("create preserves omitted explicit null and every Apple platform") func createPlatformContract() async throws { let cases: [(Value?, Any?)] = [ @@ -405,6 +419,74 @@ struct ReviewSubmissionsWorkerContractTests { } } + @Test("public collections bind Apple page counts and paging metadata to the requested limit") + func publicCollectionRequestedLimitBounds() async throws { + let listBody = """ + { + "data": [ + {"type":"reviewSubmissions","id":"sub-1","relationships":{"app":{"data":{"type":"apps","id":"app-1"}}}}, + {"type":"reviewSubmissions","id":"sub-2","relationships":{"app":{"data":{"type":"apps","id":"app-1"}}}} + ], + "links": {"self":"https://api.example.test/v1/reviewSubmissions"}, + "meta": {"paging":{"total":2,"limit":2}} + } + """ + let itemBody = """ + { + "data": [ + {"type":"reviewSubmissionItems","id":"item-1"}, + {"type":"reviewSubmissionItems","id":"item-2"} + ], + "links": {"self":"https://api.example.test/v1/reviewSubmissions/sub-1/items"}, + "meta": {"paging":{"total":2,"limit":2}} + } + """ + let cases: [(String, [String: Value], String)] = [ + ("review_submissions_list", ["app_id": .string("app-1"), "limit": .int(1)], listBody), + ("review_submissions_list_items", ["submission_id": .string("sub-1"), "limit": .int(1)], itemBody) + ] + for (tool, arguments, body) in cases { + let transport = TestHTTPTransport(responses: [.init(statusCode: 200, body: body)]) + let worker = try await makeReviewSubmissionsWorker(transport: transport) + let result = try await worker.handleTool(.init(name: tool, arguments: arguments)) + #expect(result.isError == true) + } + } + + @Test("public collections reject response next links outside the originating scope") + func publicCollectionResponseNextScope() async throws { + let listBody = """ + { + "data": [], + "links": { + "self":"https://api.example.test/v1/reviewSubmissions", + "next":"https://evil.example/v1/reviewSubmissions?cursor=next" + }, + "meta": {"paging":{"total":0,"limit":25,"nextCursor":"next"}} + } + """ + let itemBody = """ + { + "data": [], + "links": { + "self":"https://api.example.test/v1/reviewSubmissions/sub-1/items", + "next":"https://api.example.test/v1/reviewSubmissions/sub-2/items?cursor=next" + }, + "meta": {"paging":{"total":0,"limit":25,"nextCursor":"next"}} + } + """ + let cases: [(String, [String: Value], String)] = [ + ("review_submissions_list", ["app_id": .string("app-1")], listBody), + ("review_submissions_list_items", ["submission_id": .string("sub-1")], itemBody) + ] + for (tool, arguments, body) in cases { + let transport = TestHTTPTransport(responses: [.init(statusCode: 200, body: body)]) + let worker = try await makeReviewSubmissionsWorker(transport: transport) + let result = try await worker.handleTool(.init(name: tool, arguments: arguments)) + #expect(result.isError == true) + } + } + @Test("public collections reject missing-next and empty paging cursors") func publicCursorNextConsistency() async throws { let listBody = """ @@ -993,6 +1075,12 @@ struct ReviewSubmissionsWorkerContractTests { #expect(createPayload["operationCommitted"] == .bool(true)) #expect(createPayload["retrySafe"] == .bool(false)) #expect(createPayload["platform"] == platform) + let cause = try reviewSubmissionObject(createPayload["cause"]) + #expect(cause["type"] == .string("mutation_unverified")) + #expect(cause["method"] == .string("POST")) + #expect(cause["expectedStatusCode"] == .int(201)) + #expect(cause["statusCode"] == .int(201)) + #expect(try reviewSubmissionObject(cause["cause"])["type"] == .string("parsing")) let inspection = try reviewSubmissionObject(createPayload["inspection"]) let arguments = try reviewSubmissionObject(inspection["arguments"]) if platform == .string("IOS") { @@ -1014,6 +1102,10 @@ struct ReviewSubmissionsWorkerContractTests { ) #expect(networkDetails["write_outcome"] == .string("unknown")) #expect(networkDetails["platform"] == .string("MAC_OS")) + let networkCause = try reviewSubmissionObject(networkDetails["cause"]) + #expect(networkCause["type"] == .string("mutation_unknown")) + #expect(networkCause["method"] == .string("POST")) + #expect(networkCause["outcomeUnknown"] == .bool(true)) let networkInspection = try reviewSubmissionObject(networkDetails["inspection"]) let networkArguments = try reviewSubmissionObject(networkInspection["arguments"]) #expect(networkArguments["platforms"] == .string("MAC_OS")) @@ -1079,7 +1171,13 @@ struct ReviewSubmissionsWorkerContractTests { arguments: ["app_id": .string("app-1")] )) #expect(create.isError == true) - #expect(try reviewSubmissionObject(create.structuredContent)["operationCommitState"] == .string("committed_unverified")) + let createPayload = try reviewSubmissionObject(create.structuredContent) + #expect(createPayload["operationCommitState"] == .string("committed_unverified")) + let createCause = try reviewSubmissionObject(createPayload["cause"]) + #expect(createCause["type"] == .string("mutation_unverified")) + #expect(createCause["method"] == .string("POST")) + #expect(createCause["expectedStatusCode"] == .int(201)) + #expect(createCause["statusCode"] == .int(201)) let updateTransport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: reviewSubmissionMembershipBody(itemIDs: ["item-1"])), diff --git a/Tests/ASCMCPTests/Workers/ReviewsStatsHardeningTests.swift b/Tests/ASCMCPTests/Workers/ReviewsStatsHardeningTests.swift index 420fa3f..4109697 100644 --- a/Tests/ASCMCPTests/Workers/ReviewsStatsHardeningTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewsStatsHardeningTests.swift @@ -270,6 +270,30 @@ struct ReviewsStatsHardeningTests { #expect(result.isError == true) #expect(await transport.requestCount() == 2) } + + @Test("stats skip sparse reviews without failing and report incomplete aggregation") + func statsHandleSparseReviewAttributes() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: reviewsPage(reviews: [ + "{\"type\":\"customerReviews\",\"id\":\"review-sparse\"}", + reviewJSON(id: "review-1", rating: 5, date: "2026-07-18T10:00:00Z", territory: "USA") + ])) + ]) + let worker = try await makeReviewsWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "reviews_stats", + arguments: ["app_id": .string("app-1"), "period": .string("all_time")] + )) + + #expect(result.isError != true) + let root = try reviewsObject(result.structuredContent) + #expect(root["total_in_period"] == .int(1)) + #expect(root["reviews_scanned"] == .int(2)) + #expect(root["unique_reviews_scanned"] == .int(2)) + #expect(root["unaggregatable_reviews_skipped"] == .int(1)) + #expect(root["complete"] == .bool(false)) + } } private func makeReviewsWorker(transport: TestHTTPTransport) async throws -> ReviewsWorker { diff --git a/Tests/ASCMCPTests/Workers/ReviewsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/ReviewsWorkerContractTests.swift index 64debde..dfac48d 100644 --- a/Tests/ASCMCPTests/Workers/ReviewsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewsWorkerContractTests.swift @@ -17,6 +17,12 @@ struct ReviewsWorkerContractTests { #expect(properties["ratings"]?["type"] == .string("array")) #expect(properties["territories"]?["type"] == .string("array")) #expect(properties["has_published_response"]?["type"] == .string("boolean")) + let territoryPattern = try #require(properties["territory"]?["pattern"]?.stringValue) + #expect("USA".range(of: territoryPattern, options: .regularExpression) != nil) + #expect("usa".range(of: territoryPattern, options: .regularExpression) != nil) + #expect("ZZZ".range(of: territoryPattern, options: .regularExpression) == nil) + let territoryItems = try reviewsContractObject(properties["territories"]?["items"]) + #expect(territoryItems["pattern"]?.stringValue == territoryPattern) let sort = try #require(properties["sort"]) let sortVariants = try reviewsContractArray(sort["oneOf"]) #expect(sortVariants.count == 2) @@ -37,6 +43,11 @@ struct ReviewsWorkerContractTests { try #require(tools.first { $0.name == "reviews_stats" }) ) #expect(stats["has_published_response"]?["type"] == .string("boolean")) + let statsTerritoryPattern = try #require(stats["territory"]?["pattern"]?.stringValue) + #expect("all".range(of: statsTerritoryPattern, options: .regularExpression) != nil) + #expect("ALL".range(of: statsTerritoryPattern, options: .regularExpression) != nil) + #expect("usa".range(of: statsTerritoryPattern, options: .regularExpression) != nil) + #expect("ZZZ".range(of: statsTerritoryPattern, options: .regularExpression) == nil) let summarizations = try reviewsContractProperties( try #require(tools.first { $0.name == "reviews_summarizations" }) @@ -88,6 +99,35 @@ struct ReviewsWorkerContractTests { #expect(response["review_id"] == .string("review-1")) } + @Test("review pages accept optional totals and sparse customer review attributes") + func listAcceptsOptionalPagingTotalAndSparseAttributes() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: """ + { + "data": [{"type":"customerReviews","id":"review-sparse"}], + "links": {"self":"https://api.example.test/v1/apps/app-1/customerReviews"}, + "meta": {"paging":{"limit":50}} + } + """) + ]) + let worker = try await reviewsContractWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "reviews_list", + arguments: ["app_id": .string("app-1")] + )) + + #expect(result.isError != true) + let payload = try reviewsContractObject(result.structuredContent) + #expect(payload["total"] == nil) + let review = try reviewsContractObject(try #require(reviewsContractArray(payload["reviews"]).first)) + #expect(review["id"] == .string("review-sparse")) + #expect(review["rating"] == nil) + #expect(review["reviewer"] == nil) + #expect(review["created_date"] == nil) + #expect(review["has_response"] == .bool(false)) + } + @Test("version reviews use the version relationship endpoint and current query controls") func versionListUsesCurrentControls() async throws { let transport = TestHTTPTransport(responses: [ @@ -247,6 +287,85 @@ struct ReviewsWorkerContractTests { #expect(await transport.requestCount() == 0) } + @Test("review territory filters reject codes outside Apple's enum") + func territoryFiltersRejectUnsupportedCodes() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await reviewsContractWorker(transport: transport) + let calls = [ + CallTool.Parameters( + name: "reviews_list", + arguments: ["app_id": .string("app-1"), "territory": .string("ZZZ")] + ), + CallTool.Parameters( + name: "reviews_list_for_version", + arguments: [ + "version_id": .string("version-1"), + "territories": .array([.string("USA"), .string("ZZZ")]) + ] + ), + CallTool.Parameters( + name: "reviews_stats", + arguments: ["app_id": .string("app-1"), "territory": .string("ZZZ")] + ) + ] + + for call in calls { + let result = try await worker.handleTool(call) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("developer responses are not narrowed by an unsupported local length cap") + func createResponseDoesNotApplyLocalLengthCap() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: reviewsContractResponseResource()) + ]) + let worker = try await reviewsContractWorker(transport: transport) + let responseBody = String(repeating: "a", count: 5_001) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "reviews_create_response", + arguments: [ + "review_id": .string("review-1"), + "response_body": .string(responseBody) + ] + )) + + #expect(result.isError != true) + let request = try #require(await transport.recordedRequests().first) + let body = try #require(request.httpBody) + let root = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let data = try #require(root["data"] as? [String: Any]) + let attributes = try #require(data["attributes"] as? [String: Any]) + #expect(attributes["responseBody"] as? String == responseBody) + + let manifest = try ASCOperationManifestBundle.loadBundled() + let mapping = try #require(manifest.mapping(for: "reviews_create_response")) + #expect(mapping.note?.contains("5000") == false) + } + + @Test("accepted response create decode failure is committed unverified") + func createResponseDecodeFailurePreservesCommitState() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":"invalid"}"#) + ]) + let worker = try await reviewsContractWorker(transport: transport) + + let result = try await worker.handleTool(.init( + name: "reviews_create_response", + arguments: [ + "review_id": .string("review-1"), + "response_body": .string("Thank you") + ] + )) + + let payload = try reviewsContractObject(result.structuredContent) + #expect(result.isError == true) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(payload["retrySafe"] == .bool(false)) + } + @Test("reviews manifest records compound response lookup and multi-value sort") func manifestRecordsResponseFallbackAndSortCardinality() throws { let manifest = try ASCOperationManifestBundle.loadBundled() diff --git a/Tests/ASCMCPTests/Workers/SubscriptionLegacyDeprecationTests.swift b/Tests/ASCMCPTests/Workers/SubscriptionLegacyDeprecationTests.swift index b89e11e..1549643 100644 --- a/Tests/ASCMCPTests/Workers/SubscriptionLegacyDeprecationTests.swift +++ b/Tests/ASCMCPTests/Workers/SubscriptionLegacyDeprecationTests.swift @@ -223,8 +223,8 @@ private enum SubscriptionLegacyCase: String, CaseIterable, Sendable, CustomTestS var statusCode: Int { switch self { - case .createLocalization, .createGroupLocalization, .submitGroup: 201 - case .deleteLocalization, .deleteGroupLocalization, .deleteImage, .submit: 204 + case .createLocalization, .createGroupLocalization, .submit, .submitGroup: 201 + case .deleteLocalization, .deleteGroupLocalization, .deleteImage: 204 default: 200 } } @@ -237,7 +237,7 @@ private enum SubscriptionLegacyCase: String, CaseIterable, Sendable, CustomTestS #"{"data":{"type":"subscriptionLocalizations","id":"localization-1","attributes":{"locale":"en-US","name":"Premium","description":"Copy"}}}"# case .getLocalization: #"{"data":{"type":"subscriptionLocalizations","id":"localization-1","attributes":{"locale":"en-US","name":"Premium","description":"Copy"}}}"# - case .deleteLocalization, .deleteGroupLocalization, .deleteImage, .submit: + case .deleteLocalization, .deleteGroupLocalization, .deleteImage: "" case .listGroupLocalizations: #"{"data":[],"links":{"self":"https://api.example.test/v1/subscriptionGroups/group-1/subscriptionGroupLocalizations?limit=25"}}"# @@ -247,6 +247,8 @@ private enum SubscriptionLegacyCase: String, CaseIterable, Sendable, CustomTestS #"{"data":[],"links":{"self":"https://api.example.test/v1/subscriptions/subscription-1/images?limit=25"}}"# case .getImage: #"{"data":{"type":"subscriptionImages","id":"image-1","attributes":{"fileSize":5,"fileName":"image.png","state":"APPROVED"}}}"# + case .submit: + #"{"data":{"type":"subscriptionSubmissions","id":"submission-1"}}"# case .submitGroup: #"{"data":{"type":"subscriptionGroupSubmissions","id":"submission-1"}}"# } diff --git a/Tests/ASCMCPTests/Workers/SubscriptionWriteOptionalInputContractTests.swift b/Tests/ASCMCPTests/Workers/SubscriptionWriteOptionalInputContractTests.swift index fe3f3bf..363f7be 100644 --- a/Tests/ASCMCPTests/Workers/SubscriptionWriteOptionalInputContractTests.swift +++ b/Tests/ASCMCPTests/Workers/SubscriptionWriteOptionalInputContractTests.swift @@ -17,15 +17,31 @@ struct SubscriptionWriteOptionalInputContractTests { #expect(try subscriptionOptionalEnum(introProperties["target_subscription_plan_type"]) == [.null, .string("MONTHLY"), .string("UPFRONT")]) let price = try #require(tools.first { $0.name == "subscriptions_create_price" }) + let priceSchema = try subscriptionOptionalObject(price.inputSchema) let priceProperties = try subscriptionOptionalProperties(price) + #expect(Set(try subscriptionOptionalArray(priceSchema["required"]).compactMap(\.stringValue)) == ["subscription_id", "price_point_id"]) #expect(try subscriptionOptionalTypes(priceProperties["plan_type"]) == ["null", "string"]) #expect(try subscriptionOptionalEnum(priceProperties["plan_type"]) == [.null, .string("MONTHLY"), .string("UPFRONT")]) #expect(try subscriptionOptionalTypes(priceProperties["preserve_current_price"]) == ["boolean", "null"]) + let create = try #require(tools.first { $0.name == "subscriptions_create" }) + let createSchema = try subscriptionOptionalObject(create.inputSchema) + let createProperties = try subscriptionOptionalProperties(create) + #expect(Set(try subscriptionOptionalArray(createSchema["required"]).compactMap(\.stringValue)) == ["group_id", "name", "product_id"]) + #expect(try subscriptionOptionalTypes(createProperties["subscription_period"]) == ["null", "string"]) + #expect(try subscriptionOptionalTypes(createProperties["family_sharable"]) == ["boolean", "null"]) + #expect(try subscriptionOptionalTypes(createProperties["group_level"]) == ["integer", "null"]) + #expect(try subscriptionOptionalTypes(createProperties["review_note"]) == ["null", "string"]) + let update = try #require(tools.first { $0.name == "subscriptions_update" }) let updateSchema = try subscriptionOptionalObject(update.inputSchema) let updateProperties = try subscriptionOptionalProperties(update) #expect(updateSchema["minProperties"] == .int(2)) + #expect(updateSchema["additionalProperties"] == .bool(false)) + #expect(try subscriptionOptionalTypes(updateProperties["name"]) == ["null", "string"]) + #expect(try subscriptionOptionalTypes(updateProperties["family_sharable"]) == ["boolean", "null"]) + #expect(try subscriptionOptionalTypes(updateProperties["group_level"]) == ["integer", "null"]) + #expect(try subscriptionOptionalTypes(updateProperties["review_note"]) == ["null", "string"]) #expect(try subscriptionOptionalTypes(updateProperties["subscription_period"]) == ["null", "string"]) #expect(try subscriptionOptionalEnum(updateProperties["subscription_period"]) == [ .null, @@ -36,6 +52,18 @@ struct SubscriptionWriteOptionalInputContractTests { .string("THREE_MONTHS"), .string("TWO_MONTHS") ]) + + for (toolName, field) in [ + ("subscriptions_update_intro_offer", "end_date"), + ("subscriptions_update_offer_code", "active"), + ("subscriptions_update_custom_code", "active") + ] { + let tool = try #require(tools.first { $0.name == toolName }) + let root = try subscriptionOptionalObject(tool.inputSchema) + let properties = try subscriptionOptionalProperties(tool) + #expect(Set(try subscriptionOptionalArray(root["required"]).compactMap(\.stringValue)).contains(field)) + #expect(try subscriptionOptionalTypes(properties[field]).contains("null")) + } } @Test("introductory offer preserves concrete and null optional attributes") @@ -108,6 +136,63 @@ struct SubscriptionWriteOptionalInputContractTests { #expect(omittedAttributes["targetSubscriptionPlanType"] == nil) } + @Test("subscription creation preserves omitted and explicit null optional attributes") + func subscriptionCreationPreservesTriState() async throws { + let response = #"{"data":{"type":"subscriptions","id":"sub-1","attributes":{"name":"Premium","productId":"premium"}}}"# + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: response), + .init(statusCode: 201, body: response) + ]) + let worker = try await subscriptionOptionalWorker(transport) + + let omitted = try await worker.handleTool(.init( + name: "subscriptions_create", + arguments: [ + "group_id": .string("group-1"), + "name": .string("Premium"), + "product_id": .string("premium") + ] + )) + let cleared = try await worker.handleTool(.init( + name: "subscriptions_create", + arguments: [ + "group_id": .string("group-1"), + "name": .string("Premium"), + "product_id": .string("premium"), + "subscription_period": .null, + "family_sharable": .null, + "group_level": .null, + "review_note": .null + ] + )) + let invalid = try await worker.handleTool(.init( + name: "subscriptions_create", + arguments: [ + "group_id": .string("group-1"), + "name": .string("Premium"), + "product_id": .string("premium"), + "subscription_period": .string("DAILY") + ] + )) + + #expect(omitted.isError != true) + #expect(cleared.isError != true) + #expect(invalid.isError == true) + #expect(await transport.requestCount() == 2) + let bodyStrings = await transport.recordedBodyStrings() + let bodies = try bodyStrings.map(subscriptionOptionalBody) + let omittedAttributes = try subscriptionOptionalAttributes(bodies[0]) + #expect(omittedAttributes["subscriptionPeriod"] == nil) + #expect(omittedAttributes["familySharable"] == nil) + #expect(omittedAttributes["groupLevel"] == nil) + #expect(omittedAttributes["reviewNote"] == nil) + let clearedAttributes = try subscriptionOptionalAttributes(bodies[1]) + #expect(clearedAttributes["subscriptionPeriod"] is NSNull) + #expect(clearedAttributes["familySharable"] is NSNull) + #expect(clearedAttributes["groupLevel"] is NSNull) + #expect(clearedAttributes["reviewNote"] is NSNull) + } + @Test("subscription price preserves plan, preservation flag, and explicit null date") func subscriptionPricePreservesTriState() async throws { let transport = TestHTTPTransport(responses: [ @@ -159,14 +244,16 @@ struct SubscriptionWriteOptionalInputContractTests { name: "subscriptions_create_price", arguments: [ "subscription_id": .string("sub-1"), - "territory_id": .string("USA"), "price_point_id": .string("point-1") ] )) #expect(omitted.isError != true) let omittedBody = try #require(await transport.recordedBodyStrings().last) - let omittedAttributes = try subscriptionOptionalAttributes(subscriptionOptionalBody(omittedBody)) + let parsedOmittedBody = try subscriptionOptionalBody(omittedBody) + let omittedAttributes = try subscriptionOptionalAttributes(parsedOmittedBody) #expect(omittedAttributes.isEmpty) + let omittedRelationships = try subscriptionOptionalRelationships(parsedOmittedBody) + #expect(Set(omittedRelationships.keys) == ["subscription", "subscriptionPricePoint"]) } @Test("introductory offer list requests and projects the target plan type") @@ -286,11 +373,22 @@ struct SubscriptionWriteOptionalInputContractTests { let clear = try await worker.handleTool(.init( name: "subscriptions_update", - arguments: ["subscription_id": .string("sub-1"), "subscription_period": .null] + arguments: [ + "subscription_id": .string("sub-1"), + "name": .null, + "family_sharable": .null, + "group_level": .null, + "review_note": .null, + "subscription_period": .null + ] )) #expect(clear.isError != true) let body = try #require(await transport.recordedBodyStrings().first) let clearAttributes = try subscriptionOptionalAttributes(subscriptionOptionalBody(body)) + #expect(clearAttributes["name"] is NSNull) + #expect(clearAttributes["familySharable"] is NSNull) + #expect(clearAttributes["groupLevel"] is NSNull) + #expect(clearAttributes["reviewNote"] is NSNull) #expect(clearAttributes["subscriptionPeriod"] is NSNull) let concrete = try await worker.handleTool(.init( @@ -313,10 +411,69 @@ struct SubscriptionWriteOptionalInputContractTests { #expect(omittedAttributes["subscriptionPeriod"] == nil) } - @Test("manifest classifies all eleven optional write inputs") + @Test("introductory and code updates preserve explicit null and reject no-op") + func commerceUpdatesPreserveExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"subscriptionIntroductoryOffers","id":"intro-1","attributes":{}}}"#), + .init(statusCode: 200, body: #"{"data":{"type":"subscriptionOfferCodes","id":"offer-1","attributes":{}}}"#), + .init(statusCode: 200, body: #"{"data":{"type":"subscriptionOfferCodeCustomCodes","id":"custom-1","attributes":{}}}"#) + ]) + let worker = try await subscriptionOptionalWorker(transport) + + let intro = try await worker.handleTool(.init( + name: "subscriptions_update_intro_offer", + arguments: ["intro_offer_id": .string("intro-1"), "end_date": .null] + )) + let offer = try await worker.handleTool(.init( + name: "subscriptions_update_offer_code", + arguments: ["offer_code_id": .string("offer-1"), "active": .null] + )) + let custom = try await worker.handleTool(.init( + name: "subscriptions_update_custom_code", + arguments: ["custom_code_id": .string("custom-1"), "active": .null] + )) + let missingIntro = try await worker.handleTool(.init( + name: "subscriptions_update_intro_offer", + arguments: ["intro_offer_id": .string("intro-1")] + )) + let missingOffer = try await worker.handleTool(.init( + name: "subscriptions_update_offer_code", + arguments: ["offer_code_id": .string("offer-1")] + )) + let missingCustom = try await worker.handleTool(.init( + name: "subscriptions_update_custom_code", + arguments: ["custom_code_id": .string("custom-1")] + )) + + #expect(intro.isError != true) + #expect(offer.isError != true) + #expect(custom.isError != true) + #expect(missingIntro.isError == true) + #expect(missingOffer.isError == true) + #expect(missingCustom.isError == true) + #expect(await transport.requestCount() == 3) + let bodies = await transport.recordedBodyStrings() + let introAttributes = try subscriptionOptionalAttributes(subscriptionOptionalBody(bodies[0])) + let offerAttributes = try subscriptionOptionalAttributes(subscriptionOptionalBody(bodies[1])) + let customAttributes = try subscriptionOptionalAttributes(subscriptionOptionalBody(bodies[2])) + #expect(introAttributes["endDate"] is NSNull) + #expect(offerAttributes["active"] is NSNull) + #expect(customAttributes["active"] is NSNull) + } + + @Test("manifest classifies nullable subscription write inputs") func manifestClassifiesOptionalWriteInputs() throws { let manifest = try ASCOperationManifestBundle.loadBundled() let expected: [String: (bound: Set, omitted: Set)] = [ + "subscriptions_create": ( + [ + "/data/attributes/subscriptionPeriod", + "/data/attributes/familySharable", + "/data/attributes/groupLevel", + "/data/attributes/reviewNote" + ], + [] + ), "subscriptions_create_intro_offer": ( [ "/data/attributes/startDate", @@ -326,17 +483,39 @@ struct SubscriptionWriteOptionalInputContractTests { ["/included"] ), "subscriptions_create_price": ( - ["/data/attributes/planType", "/data/attributes/preserveCurrentPrice"], + [ + "/data/relationships/territory/data/id", + "/data/attributes/planType", + "/data/attributes/preserveCurrentPrice" + ], [] ), "subscriptions_update": ( - ["/data/attributes/subscriptionPeriod"], + [ + "/data/attributes/name", + "/data/attributes/familySharable", + "/data/attributes/groupLevel", + "/data/attributes/reviewNote", + "/data/attributes/subscriptionPeriod" + ], [ "/data/relationships/introductoryOffers", "/data/relationships/prices", "/data/relationships/promotionalOffers", "/included" ] + ), + "subscriptions_update_intro_offer": ( + ["/data/attributes/endDate"], + [] + ), + "subscriptions_update_offer_code": ( + ["/data/attributes/active"], + [] + ), + "subscriptions_update_custom_code": ( + ["/data/attributes/active"], + [] ) ] @@ -413,6 +592,11 @@ private func subscriptionOptionalAttributes(_ body: [String: Any]) throws -> [St return try #require(data["attributes"] as? [String: Any]) } +private func subscriptionOptionalRelationships(_ body: [String: Any]) throws -> [String: Any] { + let data = try #require(body["data"] as? [String: Any]) + return try #require(data["relationships"] as? [String: Any]) +} + private func subscriptionOptionalResultObject(_ result: CallTool.Result) throws -> [String: Any] { let text = result.content.compactMap { content in if case .text(let text, _, _) = content { return text } diff --git a/Tests/ASCMCPTests/Workers/SubscriptionsV3WorkerTests.swift b/Tests/ASCMCPTests/Workers/SubscriptionsV3WorkerTests.swift index e994cf1..73f12ae 100644 --- a/Tests/ASCMCPTests/Workers/SubscriptionsV3WorkerTests.swift +++ b/Tests/ASCMCPTests/Workers/SubscriptionsV3WorkerTests.swift @@ -60,6 +60,7 @@ struct SubscriptionsV3WorkerTests { arguments: [ "subscription_id": .string("sub-1"), "territory_id": .string("USA"), + "plan_types": .array([.string("MONTHLY"), .string("UPFRONT")]), "limit": .int(200) ] )) @@ -69,6 +70,7 @@ struct SubscriptionsV3WorkerTests { let query = queryItems(request) #expect(request.url?.path == "/v1/subscriptions/sub-1/prices") #expect(query["filter[territory]"] == "USA") + #expect(query["filter[planType]"] == "MONTHLY,UPFRONT") #expect(query["include"] == "territory,subscriptionPricePoint") #expect(query["fields[territories]"] == "currency") @@ -161,6 +163,8 @@ struct SubscriptionsV3WorkerTests { arguments: [ "subscription_id": .string("sub-1"), "territory_id": .string("USA"), + "upfront_price_point_ids": .array([.string("upfront-1"), .string("upfront-2")]), + "plan_types": .string("UPFRONT"), "limit": .int(8000) ] )) @@ -170,6 +174,8 @@ struct SubscriptionsV3WorkerTests { let query = queryItems(request) #expect(request.url?.path == "/v1/subscriptions/sub-1/pricePoints") #expect(query["filter[territory]"] == "USA") + #expect(query["filter[upfrontPricePointId]"] == "upfront-1,upfront-2") + #expect(query["filter[planType]"] == "UPFRONT") #expect(query["include"] == "territory") #expect(query["limit"] == "8000") @@ -194,6 +200,8 @@ struct SubscriptionsV3WorkerTests { "price_point_id": .string("pp-1"), "subscription_id": .string("sub-1"), "territory_id": .string("USA"), + "upfront_price_point_ids": .string("upfront-1"), + "plan_types": .array([.string("MONTHLY"), .string("UPFRONT")]), "limit": .int(8000) ] )) @@ -204,9 +212,66 @@ struct SubscriptionsV3WorkerTests { #expect(request.url?.path == "/v1/subscriptionPricePoints/pp-1/equalizations") #expect(query["filter[subscription]"] == "sub-1") #expect(query["filter[territory]"] == "USA") + #expect(query["filter[upfrontPricePointId]"] == "upfront-1") + #expect(query["filter[planType]"] == "MONTHLY,UPFRONT") #expect(query["limit"] == "8000") } + @Test("subscription plan filters reject unsupported values before network") + func planFiltersRejectUnsupportedValues() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeWorker(transport: transport) + + let prices = try await worker.handleTool(CallTool.Parameters( + name: "subscriptions_list_prices", + arguments: ["subscription_id": .string("sub-1"), "plan_types": .string("ANNUAL")] + )) + let points = try await worker.handleTool(CallTool.Parameters( + name: "subscriptions_list_price_points", + arguments: ["subscription_id": .string("sub-1"), "plan_types": .array([.string("MONTHLY"), .string("ANNUAL")])] + )) + let equalizations = try await worker.handleTool(CallTool.Parameters( + name: "subscriptions_list_price_point_equalizations", + arguments: ["price_point_id": .string("point-1"), "plan_types": .string("ANNUAL")] + )) + + #expect(prices.isError == true) + #expect(points.isError == true) + #expect(equalizations.isError == true) + #expect(await transport.requestCount() == 0) + } + + @Test("subscription pricing manifest binds Apple plan-aware filters") + func pricingManifestBindsPlanAwareFilters() throws { + let manifest = try ASCOperationManifestBundle.loadBundled() + let expected: [String: [String: String]] = [ + "subscriptions_list_prices": ["plan_types": "filter[planType]"], + "subscriptions_list_price_points": [ + "upfront_price_point_ids": "filter[upfrontPricePointId]", + "plan_types": "filter[planType]" + ], + "subscriptions_list_price_point_equalizations": [ + "upfront_price_point_ids": "filter[upfrontPricePointId]", + "plan_types": "filter[planType]" + ] + ] + + for (toolName, filters) in expected { + let mapping = try #require(manifest.mapping(for: toolName)) + for (toolField, appleName) in filters { + #expect(mapping.fields.contains { field in + field.toolField == toolField && + field.location == "query" && + field.appleName == appleName + }) + } + let omitted = mapping.operations.flatMap { operation in + operation.optionalParameterClassifications ?? [] + }.filter { $0.disposition == .intentionallyOmitted }.map(\.appleName) + #expect(Set(omitted).isDisjoint(with: Set(filters.values))) + } + } + @Test("get subscription availability includes available territories") func getAvailabilityIncludesTerritories() async throws { let transport = TestHTTPTransport(responses: [ @@ -1046,7 +1111,7 @@ struct SubscriptionsV3WorkerTests { @Test("introductory offer creation preserves territory_id for Apple relationship") func introductoryOfferCreateKeepsTerritoryID() async throws { let transport = TestHTTPTransport(responses: [ - .init(statusCode: 200, body: """ + .init(statusCode: 201, body: """ { "data": { "type": "subscriptionIntroductoryOffers", @@ -1334,6 +1399,65 @@ struct SubscriptionsV3WorkerTests { #expect(currentPrice["currency"] == .string("USD")) #expect(currentPrice["customer_price"] == .string("9.99")) } + + @Test("accepted subscription mutation with mismatched identity is committed unverified") + func acceptedMutationWithMismatchedIdentityIsUnverified() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 201, body: #"{"data":{"type":"subscriptionPrices","id":"bad/id"}}"#) + ]) + let worker = try await makeWorker(transport: transport) + + let result = try await worker.handleTool(.init( + name: "subscriptions_create_price", + arguments: [ + "subscription_id": .string("sub-1"), + "price_point_id": .string("point-1") + ] + )) + + #expect(result.isError == true) + let root = try object(result.structuredContent) + #expect(root["operationCommitState"] == .string("committed_unverified")) + #expect(root["operationCommitted"] == .bool(true)) + #expect(root["retrySafe"] == .bool(false)) + } + + @Test("subscription limits reject present invalid values before network") + func limitsRejectInvalidValuesBeforeNetwork() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await makeWorker(transport: transport) + let cases: [(String, [String: Value])] = [ + ("subscriptions_list", ["group_id": .string("group-1"), "limit": .int(0)]), + ("subscriptions_list_price_points", ["subscription_id": .string("sub-1"), "limit": .int(8001)]), + ("subscriptions_list_offer_codes", ["subscription_id": .string("sub-1"), "limit": .int(201)]), + ("subscriptions_list_images", ["subscription_id": .string("sub-1"), "limit": .string("25")]) + ] + + for (name, arguments) in cases { + let result = try await worker.handleTool(.init(name: name, arguments: arguments)) + #expect(result.isError == true) + } + #expect(await transport.requestCount() == 0) + } + + @Test("subscription limit schemas publish the runtime bounds") + func limitSchemasPublishRuntimeBounds() async throws { + let worker = try await makeWorker(transport: TestHTTPTransport(responses: [])) + let largeLimitTools: Set = [ + "subscriptions_list_price_points", + "subscriptions_list_price_point_equalizations", + "subscriptions_list_price_point_adjusted_equalizations" + ] + + for tool in await worker.getTools() { + let root = try object(tool.inputSchema) + let properties = try object(root["properties"]) + guard let limitValue = properties["limit"] else { continue } + let limit = try object(limitValue) + #expect(limit["minimum"] == .int(1)) + #expect(limit["maximum"] == .int(largeLimitTools.contains(tool.name) ? 8000 : 200)) + } + } } private func makeWorker(transport: TestHTTPTransport) async throws -> SubscriptionsWorker { diff --git a/Tests/ASCMCPTests/Workers/TestFlightCoreContractHardeningTests.swift b/Tests/ASCMCPTests/Workers/TestFlightCoreContractHardeningTests.swift index 5ec1a85..d350a32 100644 --- a/Tests/ASCMCPTests/Workers/TestFlightCoreContractHardeningTests.swift +++ b/Tests/ASCMCPTests/Workers/TestFlightCoreContractHardeningTests.swift @@ -231,6 +231,31 @@ struct TestFlightCoreContractHardeningTests { #expect(attributes["iosBuildsAvailableForAppleVision"] as? Bool == false) } + @Test("beta group update preserves explicit null") + func betaGroupUpdatePreservesExplicitNull() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"betaGroups","id":"group-1"}}"#) + ]) + let worker = try await testFlightBetaGroupsWorker(transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "beta_groups_update", + arguments: [ + "group_id": .string("group-1"), + "public_link_limit": .null, + "feedback_enabled": .null + ] + )) + + #expect(result.isError != true) + let body = try testFlightBody(try #require(await transport.recordedRequests().first)) + let data = try testFlightDictionary(body["data"]) + let attributes = try testFlightDictionary(data["attributes"]) + #expect(attributes["publicLinkLimit"] is NSNull) + #expect(attributes["feedbackEnabled"] is NSNull) + #expect(Set(attributes.keys) == ["publicLinkLimit", "feedbackEnabled"]) + } + @Test("beta tester can be created without a group") func betaTesterCreateWithoutGroup() async throws { let transport = TestHTTPTransport(responses: [ @@ -250,6 +275,252 @@ struct TestFlightCoreContractHardeningTests { #expect(data["relationships"] == nil) } + @Test("beta tester create rejects malformed email before network") + func betaTesterCreateRejectsMalformedEmail() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await testFlightBetaTestersWorker(transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "beta_testers_create", + arguments: ["email": .string("not-an-email")] + )) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 0) + } + + @Test("relationship ID arrays reject mixed values atomically") + func relationshipIDArraysRejectMixedValues() async throws { + let transport = TestHTTPTransport(responses: []) + let invalidIDs: Value = .array([.string("valid-id"), .int(7)]) + + let groupsWorker = try await testFlightBetaGroupsWorker(transport) + for (tool, field) in [ + ("beta_groups_add_testers", "tester_ids"), + ("beta_groups_remove_testers", "tester_ids"), + ("beta_groups_add_builds", "build_ids"), + ("beta_groups_remove_builds", "build_ids") + ] { + let result = try await groupsWorker.handleTool(.init( + name: tool, + arguments: ["group_id": .string("group-1"), field: invalidIDs] + )) + #expect(result.isError == true) + } + + let testersWorker = try await testFlightBetaTestersWorker(transport) + for (tool, field) in [ + ("beta_testers_add_to_groups", "group_ids"), + ("beta_testers_remove_from_groups", "group_ids"), + ("beta_testers_add_to_builds", "build_ids"), + ("beta_testers_remove_from_builds", "build_ids") + ] { + let result = try await testersWorker.handleTool(.init( + name: tool, + arguments: ["beta_tester_id": .string("tester-1"), field: invalidIDs] + )) + #expect(result.isError == true) + } + + let buildsWorker = BuildBetaDetailsWorker(httpClient: try await testFlightClient(transport)) + for (tool, field) in [ + ("builds_add_to_beta_groups", "group_ids"), + ("builds_add_individual_testers", "beta_tester_ids"), + ("builds_remove_individual_testers", "beta_tester_ids") + ] { + let result = try await buildsWorker.handleTool(.init( + name: tool, + arguments: ["build_id": .string("build-1"), field: invalidIDs] + )) + #expect(result.isError == true) + } + + #expect(await transport.requestCount() == 0) + } + + @Test("relationship creates require Apple's exact 204 status") + func relationshipCreatesRequireExact204() async throws { + let groupTesterTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let groupTesterWorker = try await testFlightBetaGroupsWorker(groupTesterTransport) + let groupTesterResult = try await groupTesterWorker.handleTool(.init( + name: "beta_groups_add_testers", + arguments: ["group_id": .string("group-1"), "tester_ids": .array([.string("tester-1")])] + )) + try testFlightExpectUnverified204(groupTesterResult) + + let groupBuildTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let groupBuildWorker = try await testFlightBetaGroupsWorker(groupBuildTransport) + let groupBuildResult = try await groupBuildWorker.handleTool(.init( + name: "beta_groups_add_builds", + arguments: ["group_id": .string("group-1"), "build_ids": .array([.string("build-1")])] + )) + try testFlightExpectUnverified204(groupBuildResult) + + let buildGroupTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let buildGroupWorker = BuildBetaDetailsWorker(httpClient: try await testFlightClient(buildGroupTransport)) + let buildGroupResult = try await buildGroupWorker.handleTool(.init( + name: "builds_add_to_beta_groups", + arguments: ["build_id": .string("build-1"), "group_ids": .array([.string("group-1")])] + )) + try testFlightExpectUnverified204(buildGroupResult) + + let buildTesterTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let buildTesterWorker = BuildBetaDetailsWorker(httpClient: try await testFlightClient(buildTesterTransport)) + let buildTesterResult = try await buildTesterWorker.handleTool(.init( + name: "builds_add_individual_testers", + arguments: ["build_id": .string("build-1"), "beta_tester_ids": .array([.string("tester-1")])] + )) + try testFlightExpectUnverified204(buildTesterResult) + + let testerGroupTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let testerGroupWorker = try await testFlightBetaTestersWorker(testerGroupTransport) + let testerGroupResult = try await testerGroupWorker.handleTool(.init( + name: "beta_testers_add_to_groups", + arguments: ["beta_tester_id": .string("tester-1"), "group_ids": .array([.string("group-1")])] + )) + try testFlightExpectUnverified204(testerGroupResult) + + let testerBuildTransport = TestHTTPTransport(responses: [.init(statusCode: 201, body: "")]) + let testerBuildWorker = try await testFlightBetaTestersWorker(testerBuildTransport) + let testerBuildResult = try await testerBuildWorker.handleTool(.init( + name: "beta_testers_add_to_builds", + arguments: ["beta_tester_id": .string("tester-1"), "build_ids": .array([.string("build-1")])] + )) + try testFlightExpectUnverified204(testerBuildResult) + } + + @Test("resource patch decode failures preserve committed-unverified state") + func resourcePatchDecodeFailuresPreserveMutationState() async throws { + let betaDetailTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":"#) + ]) + let betaDetailWorker = BuildBetaDetailsWorker(httpClient: try await testFlightClient(betaDetailTransport)) + let betaDetailResult = try await betaDetailWorker.handleTool(.init( + name: "builds_update_beta_detail", + arguments: ["beta_detail_id": .string("detail-1"), "auto_notify": .bool(true)] + )) + try testFlightExpectMutationUnverified( + betaDetailResult, + method: "PATCH", + expectedStatusCode: 200, + statusCode: 200 + ) + + let processingTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":"#) + ]) + let processingWorker = try await testFlightBuildProcessingWorker(processingTransport) + let processingResult = try await processingWorker.handleTool(.init( + name: "builds_update_encryption", + arguments: [ + "build_id": .string("build-1"), + "uses_non_exempt_encryption": .bool(false) + ] + )) + try testFlightExpectMutationUnverified( + processingResult, + method: "PATCH", + expectedStatusCode: 200, + statusCode: 200 + ) + + let licenseTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":"#) + ]) + let licenseWorker = BetaLicenseAgreementsWorker(httpClient: try await testFlightClient(licenseTransport)) + let licenseResult = try await licenseWorker.handleTool(.init( + name: "beta_license_update", + arguments: [ + "beta_license_agreement_id": .string("license-1"), + "agreement_text": .string("Terms") + ] + )) + try testFlightExpectMutationUnverified( + licenseResult, + method: "PATCH", + expectedStatusCode: 200, + statusCode: 200 + ) + } + + @Test("related collection limits fail closed outside Apple's range") + func relatedCollectionLimitsFailClosed() async throws { + let transport = TestHTTPTransport(responses: []) + let buildWorker = BuildBetaDetailsWorker(httpClient: try await testFlightClient(transport)) + for tool in [ + "builds_list_beta_localizations", + "builds_get_beta_groups", + "builds_get_beta_testers", + "builds_list_individual_testers" + ] { + let result = try await buildWorker.handleTool(.init( + name: tool, + arguments: ["build_id": .string("build-1"), "limit": .int(201)] + )) + #expect(result.isError == true) + } + + let groupWorker = try await testFlightBetaGroupsWorker(transport) + let groupResult = try await groupWorker.handleTool(.init( + name: "beta_groups_list_testers", + arguments: ["group_id": .string("group-1"), "limit": .int(0)] + )) + #expect(groupResult.isError == true) + + let groupListResult = try await groupWorker.handleTool(.init( + name: "beta_groups_list", + arguments: ["app_id": .string("app-1"), "limit": .string("25")] + )) + #expect(groupListResult.isError == true) + + let testerWorker = try await testFlightBetaTestersWorker(transport) + let testerResult = try await testerWorker.handleTool(.init( + name: "beta_testers_list_apps", + arguments: ["tester_id": .string("tester-1"), "limit": .string("25")] + )) + #expect(testerResult.isError == true) + + let testerListResult = try await testerWorker.handleTool(.init( + name: "beta_testers_list", + arguments: ["limit": .int(201)] + )) + #expect(testerListResult.isError == true) + + let testerSearchResult = try await testerWorker.handleTool(.init( + name: "beta_testers_search", + arguments: ["email": .string("person@example.com"), "limit": .int(0)] + )) + #expect(testerSearchResult.isError == true) + + let buildsWorker = try await testFlightBuildsWorker(transport) + let buildsResult = try await buildsWorker.handleTool(.init( + name: "builds_list", + arguments: ["app_id": .string("app-1"), "limit": .int(500)] + )) + #expect(buildsResult.isError == true) + + let preReleaseWorker = try await testFlightPreReleaseWorker(transport) + let preReleaseResult = try await preReleaseWorker.handleTool(.init( + name: "pre_release_list_builds", + arguments: ["pre_release_version_id": .string("pre-1"), "limit": .int(-1)] + )) + #expect(preReleaseResult.isError == true) + + let preReleaseListResult = try await preReleaseWorker.handleTool(.init( + name: "pre_release_list", + arguments: ["limit": .string("25")] + )) + #expect(preReleaseListResult.isError == true) + + let licenseWorker = BetaLicenseAgreementsWorker(httpClient: try await testFlightClient(transport)) + let licenseResult = try await licenseWorker.handleTool(.init( + name: "beta_license_list", + arguments: ["limit": .int(500)] + )) + #expect(licenseResult.isError == true) + #expect(await transport.requestCount() == 0) + } + @Test("beta tester create supports direct build assignment") func betaTesterCreateWithBuild() async throws { let transport = TestHTTPTransport(responses: [ @@ -478,9 +749,9 @@ struct TestFlightCoreContractHardeningTests { func currentModelFieldsDecode() throws { let groupData = #"{"type":"betaGroups","id":"group-1","attributes":{"iosBuildsAvailableForAppleSiliconMac":true,"iosBuildsAvailableForAppleVision":false,"publicLinkLimitEnabled":true},"relationships":{"betaRecruitmentCriteria":{"data":{"type":"betaRecruitmentCriteria","id":"criteria-1"}},"betaRecruitmentCriterionCompatibleBuildCheck":{"links":{"related":"https://api.example.test/v1/betaGroups/group-1/betaRecruitmentCriterionCompatibleBuildCheck"}}}}"#.data(using: .utf8)! let group = try JSONDecoder().decode(ASCBetaGroup.self, from: groupData) - #expect(group.attributes.iosBuildsAvailableForAppleSiliconMac == true) - #expect(group.attributes.iosBuildsAvailableForAppleVision == false) - #expect(group.attributes.publicLinkLimitEnabled == true) + #expect(group.attributes?.iosBuildsAvailableForAppleSiliconMac == true) + #expect(group.attributes?.iosBuildsAvailableForAppleVision == false) + #expect(group.attributes?.publicLinkLimitEnabled == true) #expect(group.relationships?.betaRecruitmentCriteria?.data?.id == "criteria-1") #expect(group.relationships?.betaRecruitmentCriterionCompatibleBuildCheck?.links?.related != nil) @@ -496,8 +767,11 @@ struct TestFlightCoreContractHardeningTests { let testerWorker = try await testFlightBetaTestersWorker(transport) let testerTools = await testerWorker.getTools() let createTester = try #require(testerTools.first { $0.name == "beta_testers_create" }) + let createTesterProperties = try testFlightProperties(createTester) #expect(try testFlightRequired(createTester) == ["email"]) - #expect(try testFlightProperties(createTester)["build_ids"] != nil) + #expect(createTesterProperties["build_ids"] != nil) + let emailSchema = try testFlightObject(try #require(createTesterProperties["email"])) + #expect(emailSchema["format"] == .string("email")) let buildsWorker = try await testFlightBuildsWorker(transport) let buildTools = await buildsWorker.getTools() @@ -508,7 +782,16 @@ struct TestFlightCoreContractHardeningTests { let groupsWorker = try await testFlightBetaGroupsWorker(transport) let groupTools = await groupsWorker.getTools() let updateGroup = try #require(groupTools.first { $0.name == "beta_groups_update" }) - #expect(try testFlightProperties(updateGroup)["ios_builds_available_for_apple_vision"] != nil) + let updateGroupProperties = try testFlightProperties(updateGroup) + #expect(updateGroupProperties["ios_builds_available_for_apple_vision"] != nil) + let listGroupTesters = try #require(groupTools.first { $0.name == "beta_groups_list_testers" }) + let listGroupTesterProperties = try testFlightProperties(listGroupTesters) + let limitSchema = try testFlightObject(try #require(listGroupTesterProperties["limit"])) + #expect(limitSchema["minimum"] == .int(1)) + #expect(limitSchema["maximum"] == .int(200)) + #expect(limitSchema["default"] == .int(25)) + let nullableLimit = try testFlightObject(try #require(updateGroupProperties["public_link_limit"])) + #expect(nullableLimit["type"] == .array([.string("integer"), .string("null")])) } } @@ -590,6 +873,31 @@ private func testFlightRequired(_ tool: Tool) throws -> [String] { return values.compactMap(\.stringValue) } +private func testFlightExpectUnverified204(_ result: CallTool.Result) throws { + try testFlightExpectMutationUnverified( + result, + method: "POST", + expectedStatusCode: 204, + statusCode: 201 + ) +} + +private func testFlightExpectMutationUnverified( + _ result: CallTool.Result, + method: String, + expectedStatusCode: Int, + statusCode: Int +) throws { + #expect(result.isError == true) + let payload = try testFlightObject(result.structuredContent) + let details = try testFlightObject(payload["details"]) + #expect(payload["operationCommitState"] == .string("committed_unverified")) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string(method)) + #expect(details["expectedStatusCode"] == .int(expectedStatusCode)) + #expect(details["statusCode"] == .int(statusCode)) +} + private enum TestFlightCoreContractFailure: Error { case expectedArray case expectedDictionary diff --git a/Tests/ASCMCPTests/Workers/ToolMetadataPolicyTests.swift b/Tests/ASCMCPTests/Workers/ToolMetadataPolicyTests.swift index 2f92dee..7c4bb6e 100644 --- a/Tests/ASCMCPTests/Workers/ToolMetadataPolicyTests.swift +++ b/Tests/ASCMCPTests/Workers/ToolMetadataPolicyTests.swift @@ -58,6 +58,21 @@ struct ToolMetadataPolicyTests { #expect(promotedReorder.annotations.idempotentHint == true) } + @Test("direct Apple DELETE mappings are destructive") + func directAppleDeletesAreDestructive() throws { + let manifest = try ASCOperationManifestBundle.loadBundled() + let directDeletes = manifest.tools.filter { mapping in + mapping.kind == .direct && + !mapping.operations.isEmpty && + mapping.operations.allSatisfy { $0.method.lowercased() == "delete" } + } + + #expect(!directDeletes.isEmpty) + for mapping in directDeletes { + #expect(mapping.effect == .destructive, "Expected destructive effect for \(mapping.tool)") + } + } + @Test("IAP version image collection remains read-only metadata") func iapVersionImageCollectionRemainsReadOnly() async throws { let client = try await TestFactory.makeHTTPClient() @@ -164,6 +179,7 @@ struct ToolMetadataPolicyTests { "custom_pages_remove_search_keywords", "iap_delete_version_image", "iap_delete_version_localization", + "provisioning_disable_capability", "review_submissions_cancel", "review_submissions_remove_item", "review_submissions_submit", diff --git a/Tests/ASCMCPTests/Workers/UsersAppleContractTests.swift b/Tests/ASCMCPTests/Workers/UsersAppleContractTests.swift index 22611aa..f26f270 100644 --- a/Tests/ASCMCPTests/Workers/UsersAppleContractTests.swift +++ b/Tests/ASCMCPTests/Workers/UsersAppleContractTests.swift @@ -40,6 +40,69 @@ struct UsersAppleContractTests { try #require(tools.first { $0.name == "users_invite" }) ) #expect(invite["provisioning_allowed"] != nil) + guard case .object(let email)? = invite["email"] else { + throw UsersAppleContractTestFailure.expectedObject + } + #expect(email["format"] == .string("email")) + for field in ["all_apps_visible", "provisioning_allowed"] { + guard case .object(let property)? = invite[field], + case .array(let types)? = property["type"] else { + Issue.record("Expected nullable invitation schema for \(field)") + continue + } + #expect(Set(types.compactMap(\.stringValue)) == ["boolean", "null"]) + } + + let visibleApps = try usersContractProperties( + try #require(tools.first { $0.name == "users_list_visible_apps" }) + ) + guard case .object(let visibleAppsLimit)? = visibleApps["limit"] else { + Issue.record("Expected visible-app limit schema") + return + } + #expect(visibleAppsLimit["minimum"] == .int(1)) + #expect(visibleAppsLimit["maximum"] == .int(200)) + } + + @Test("users_invite rejects malformed email addresses before network") + func invitationEmailIsValidated() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await usersContractWorker(transport: transport) + + for email in ["invalid", "@example.com", "user@"] { + var arguments = usersInviteArguments(roles: [.string("DEVELOPER")]) + arguments["email"] = .string(email) + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_invite", + arguments: arguments + )) + #expect(result.isError == true) + } + + #expect(await transport.requestCount() == 0) + } + + @Test("users_list_visible_apps exposes Apple's paging total") + func visibleAppsPagingTotalIsPreserved() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: """ + { + "data": [], + "links": {"self": "https://api.example.test/v1/users/user-1/visibleApps"}, + "meta": {"paging": {"total": 7, "limit": 25}} + } + """) + ]) + let worker = try await usersContractWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_list_visible_apps", + arguments: ["user_id": .string("user-1")] + )) + + #expect(result.isError != true) + let payload = try usersContractObject(result.structuredContent) + #expect(payload["total"] == .int(7)) } @Test("users_list sends all current controls and preserves included apps and relationship IDs") @@ -180,6 +243,7 @@ struct UsersAppleContractTests { let linkages = try #require(visibleApps["data"] as? [[String: Any]]) #expect(attributes["provisioningAllowed"] as? Bool == true) #expect(linkages.compactMap { $0["id"] as? String } == ["app-9"]) + #expect(linkages.allSatisfy { $0["type"] as? String == "apps" }) let payload = try usersContractObject(result.structuredContent) let invitation = try usersContractObject(payload["invitation"]) @@ -187,6 +251,122 @@ struct UsersAppleContractTests { #expect(try usersContractStrings(invitation["visibleAppIds"]) == ["app-9"]) } + @Test("users_invite preserves omitted and explicit-null access controls") + func invitationAccessControlTriState() async throws { + let transport = TestHTTPTransport(responses: [ + usersInviteContractResponse(), + usersInviteContractResponse() + ]) + let worker = try await usersContractWorker(transport: transport) + + let omitted = try await worker.handleTool(CallTool.Parameters( + name: "users_invite", + arguments: usersInviteArguments(roles: [.string("DEVELOPER")]) + )) + let explicitNull = try await worker.handleTool(CallTool.Parameters( + name: "users_invite", + arguments: [ + "email": .string("new@example.com"), + "first_name": .string("New"), + "last_name": .string("User"), + "roles": .array([.string("DEVELOPER")]), + "all_apps_visible": .null, + "provisioning_allowed": .null + ] + )) + + #expect(omitted.isError != true) + #expect(explicitNull.isError != true) + let requests = await transport.recordedRequests() + #expect(requests.count == 2) + + let omittedBody = try usersContractBody(try #require(requests.first)) + let omittedData = try usersContractDictionary(omittedBody["data"]) + let omittedAttributes = try usersContractDictionary(omittedData["attributes"]) + #expect(omittedAttributes.keys.contains("allAppsVisible") == false) + #expect(omittedAttributes.keys.contains("provisioningAllowed") == false) + + let nullBody = try usersContractBody(try #require(requests.last)) + let nullData = try usersContractDictionary(nullBody["data"]) + let nullAttributes = try usersContractDictionary(nullData["attributes"]) + #expect(nullAttributes["allAppsVisible"] is NSNull) + #expect(nullAttributes["provisioningAllowed"] is NSNull) + } + + @Test("users_list_visible_apps rejects out-of-range limit before network") + func visibleAppsLimitValidation() async throws { + let transport = TestHTTPTransport(responses: []) + let worker = try await usersContractWorker(transport: transport) + + for limit in [0, 201] { + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_list_visible_apps", + arguments: [ + "user_id": .string("user-1"), + "limit": .int(limit) + ] + )) + #expect(result.isError == true) + } + + #expect(await transport.requestCount() == 0) + } + + @Test("users_add_visible_apps accepts Apple's exact 204 relationship response") + func visibleAppsRelationshipStatus() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 204, body: "") + ]) + let worker = try await usersContractWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_add_visible_apps", + arguments: [ + "user_id": .string("user-1"), + "app_ids": .array([.string("app-1")]) + ] + )) + + #expect(result.isError != true) + let request = try #require(await transport.recordedRequests().first) + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/v1/users/user-1/relationships/visibleApps") + let body = try usersContractBody(request) + let linkages = try #require(body["data"] as? [[String: Any]]) + #expect(linkages.compactMap { $0["id"] as? String } == ["app-1"]) + #expect(linkages.allSatisfy { $0["type"] as? String == "apps" }) + } + + @Test("users_add_visible_apps preserves an unverified 204 relationship response") + func visibleAppsRelationshipUnverifiedStatus() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 204, body: #"{"unexpected":true}"#) + ]) + let worker = try await usersContractWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_add_visible_apps", + arguments: [ + "user_id": .string("user-1"), + "app_ids": .array([.string("app-1")]) + ] + )) + + #expect(result.isError == true) + let root = try usersContractObject(result.structuredContent) + #expect(root["operationCommitState"] == .string("committed_unverified")) + #expect(root["operationCommitted"] == .bool(true)) + #expect(root["outcomeUnknown"] == .bool(false)) + #expect(root["retrySafe"] == .bool(false)) + #expect(root["inspectionRequired"] == .bool(true)) + let details = try usersContractObject(root["details"]) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string("POST")) + #expect(details["expectedStatusCode"] == .int(204)) + #expect(details["statusCode"] == .int(204)) + #expect(await transport.requestCount() == 1) + } + @Test("User write tools reject malformed role and app ID arrays before the network") func rejectsMalformedArraysBeforeNetwork() async throws { let transport = TestHTTPTransport(responses: []) diff --git a/Tests/ASCMCPTests/Workers/UsersUpdateContractTests.swift b/Tests/ASCMCPTests/Workers/UsersUpdateContractTests.swift index a72d7e4..fd615a9 100644 --- a/Tests/ASCMCPTests/Workers/UsersUpdateContractTests.swift +++ b/Tests/ASCMCPTests/Workers/UsersUpdateContractTests.swift @@ -13,7 +13,12 @@ struct UsersUpdateContractTests { case .object(let properties)? = schema["properties"], case .object(let roles)? = properties["roles"], case .object(let roleItems)? = roles["items"], - case .array(let roleValues)? = roleItems["enum"] else { + case .array(let roleValues)? = roleItems["enum"], + case .array(let roleTypes)? = roles["type"], + case .object(let allAppsVisible)? = properties["all_apps_visible"], + case .array(let allAppsVisibleTypes)? = allAppsVisible["type"], + case .object(let provisioningAllowed)? = properties["provisioning_allowed"], + case .array(let provisioningAllowedTypes)? = provisioningAllowed["type"] else { Issue.record("Expected users_update schema properties") return } @@ -25,6 +30,64 @@ struct UsersUpdateContractTests { #expect(roleNames == Set(UsersWorker.assignableRoles)) #expect(roleNames.contains("ACCESS_TO_REPORTS")) #expect(roleNames.contains("TECHNICAL") == false) + #expect(Set(roleTypes.compactMap(\.stringValue)) == ["array", "null"]) + #expect(Set(allAppsVisibleTypes.compactMap(\.stringValue)) == ["boolean", "null"]) + #expect(Set(provisioningAllowedTypes.compactMap(\.stringValue)) == ["boolean", "null"]) + } + + @Test("users_update preserves explicit null for every nullable attribute") + func preservesExplicitNullAttributes() async throws { + let transport = TestHTTPTransport(responses: [usersUpdateResponse()]) + let worker = try await usersUpdateWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_update", + arguments: [ + "user_id": .string("user-1"), + "roles": .null, + "all_apps_visible": .null, + "provisioning_allowed": .null + ] + )) + + #expect(result.isError != true) + let request = try #require(await transport.recordedRequests().first) + let body = try usersUpdateBody(request) + let data = try usersUpdateDictionary(body["data"]) + let attributes = try usersUpdateDictionary(data["attributes"]) + #expect(attributes["roles"] is NSNull) + #expect(attributes["allAppsVisible"] is NSNull) + #expect(attributes["provisioningAllowed"] is NSNull) + } + + @Test("users_update PATCH preserves a committed-unverified response") + func patchPreservesCommittedUnverifiedResponse() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{}"#) + ]) + let worker = try await usersUpdateWorker(transport: transport) + + let result = try await worker.handleTool(CallTool.Parameters( + name: "users_update", + arguments: [ + "user_id": .string("user-1"), + "all_apps_visible": .bool(false) + ] + )) + + #expect(result.isError == true) + let root = try usersUpdateObject(result.structuredContent) + #expect(root["operationCommitState"] == .string("committed_unverified")) + #expect(root["operationCommitted"] == .bool(true)) + #expect(root["outcomeUnknown"] == .bool(false)) + #expect(root["retrySafe"] == .bool(false)) + #expect(root["inspectionRequired"] == .bool(true)) + let details = try usersUpdateObject(root["details"]) + #expect(details["type"] == .string("mutation_unverified")) + #expect(details["method"] == .string("PATCH")) + #expect(details["expectedStatusCode"] == .int(200)) + #expect(details["statusCode"] == .int(200)) + #expect(await transport.requestCount() == 1) } @Test("users_update sends all writable fields with a limited-app developer role") diff --git a/Tests/ASCMCPTests/Workers/WebhooksWorkerTests.swift b/Tests/ASCMCPTests/Workers/WebhooksWorkerTests.swift index 6b5935d..5e4acb9 100644 --- a/Tests/ASCMCPTests/Workers/WebhooksWorkerTests.swift +++ b/Tests/ASCMCPTests/Workers/WebhooksWorkerTests.swift @@ -287,6 +287,53 @@ struct WebhooksWorkerTests { } } + @Test("update schema exposes every nullable Apple patch attribute") + func updateSchemaExposesNullableAttributes() async throws { + let worker = WebhooksWorker(httpClient: try await TestFactory.makeHTTPClient()) + let tool = try #require(await worker.getTools().first { $0.name == "webhooks_update" }) + let root = try #require(tool.inputSchema.objectValue) + let properties = try #require(root["properties"]?.objectValue) + + for (field, concreteType) in [ + ("enabled", "boolean"), + ("event_types", "array"), + ("name", "string"), + ("secret", "string"), + ("url", "string") + ] { + let schema = try #require(properties[field]?.objectValue) + let types = try #require(schema["type"]?.arrayValue) + #expect(Set(types.compactMap(\.stringValue)) == Set([concreteType, "null"])) + } + } + + @Test("webhook collection limits reject invalid present values") + func collectionLimitsRejectInvalidValues() async throws { + for (tool, idField) in [ + ("webhooks_list", "app_id"), + ("webhooks_list_deliveries", "webhook_id") + ] { + for invalid in [Value.int(0), .int(201), .string("25")] { + let transport = TestHTTPTransport(responses: []) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 1 + ) + let worker = WebhooksWorker(httpClient: client) + let result = try await worker.handleTool(.init( + name: tool, + arguments: [idField: .string("resource-1"), "limit": invalid] + )) + + #expect(result.isError == true) + #expect(textContent(result).contains("limit must be an integer from 1 through 200")) + #expect(await transport.requestCount() == 0) + } + } + } + @Test("create accepts a strong hex-like webhook secret without returning it") func createAcceptsStrongSecretWithoutReturningIt() async throws { let transport = TestHTTPTransport(responses: [ @@ -336,6 +383,60 @@ struct WebhooksWorkerTests { #expect(!structuredText.contains(secret)) } + @Test("webhook mutations preserve machine-readable recovery state") + func webhookMutationsPreserveRecoveryState() async throws { + let secret = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + let createTransport = TestHTTPTransport(responses: [ + .init(statusCode: 500, body: #"{"errors":[{"status":"500","detail":"unavailable"}]}"#) + ]) + let createClient = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: createTransport, + maxRetries: 1 + ) + let createWorker = WebhooksWorker(httpClient: createClient) + let create = try await createWorker.handleTool(.init( + name: "webhooks_create", + arguments: [ + "app_id": .string("app-1"), + "name": .string("Release events"), + "url": .string("https://example.com/webhook"), + "secret": .string(secret), + "event_types": .array([.string("APP_STORE_VERSION_APP_VERSION_STATE_UPDATED")]) + ] + )) + let createPayload = try structuredObject(create) + #expect(create.isError == true) + #expect(createPayload["operationCommitState"] == .string("unknown")) + #expect(createPayload["outcomeUnknown"] == .bool(true)) + #expect(createPayload["retrySafe"] == .bool(false)) + + let updateTransport = TestHTTPTransport(responses: [ + .init(statusCode: 204, body: "") + ]) + let updateClient = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: updateTransport, + maxRetries: 1 + ) + let updateWorker = WebhooksWorker(httpClient: updateClient) + let update = try await updateWorker.handleTool(.init( + name: "webhooks_update", + arguments: [ + "webhook_id": .string("webhook-1"), + "enabled": .bool(false) + ] + )) + let updatePayload = try structuredObject(update) + #expect(update.isError == true) + #expect(updatePayload["operationCommitState"] == .string("committed_unverified")) + #expect(updatePayload["operationCommitted"] == .bool(true)) + #expect(updatePayload["outcomeUnknown"] == .bool(false)) + #expect(updatePayload["retrySafe"] == .bool(false)) + } + @Test("request models encode Apple OpenAPI JSON API shape") func requestModelsEncodeAppleShape() throws { let create = ASCWebhookCreateRequest( @@ -523,6 +624,44 @@ struct WebhooksWorkerTests { #expect(await transport.requestCount() == 0) } + @Test("update preserves explicit null for every nullable Apple attribute") + func updatePreservesExplicitNullAttributes() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"webhooks","id":"webhook-1"}}"#) + ]) + let client = await HTTPClient( + jwtService: try TestFactory.makeJWTService(), + baseURL: "https://api.example.test", + transport: transport, + maxRetries: 1 + ) + let worker = WebhooksWorker(httpClient: client) + + let result = try await worker.handleTool( + CallTool.Parameters( + name: "webhooks_update", + arguments: [ + "webhook_id": .string("webhook-1"), + "enabled": .null, + "event_types": .null, + "name": .null, + "secret": .null, + "url": .null + ] + ) + ) + + #expect(result.isError != true) + let request = try #require(await transport.recordedRequests().first) + let body = try #require(request.httpBody) + let root = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let data = try #require(root["data"] as? [String: Any]) + let attributes = try #require(data["attributes"] as? [String: Any]) + for field in ["enabled", "eventTypes", "name", "secret", "url"] { + #expect(attributes[field] is NSNull) + } + } + @Test("verify signature validates Apple x-apple-signature HMAC") func verifySignatureValidatesAppleHeader() async throws { let worker = WebhooksWorker(httpClient: try await TestFactory.makeHTTPClient()) diff --git a/Tests/ASCMCPTests/Workers/XcodeCloudInputValidationContractTests.swift b/Tests/ASCMCPTests/Workers/XcodeCloudInputValidationContractTests.swift index 6ca783c..9cbb641 100644 --- a/Tests/ASCMCPTests/Workers/XcodeCloudInputValidationContractTests.swift +++ b/Tests/ASCMCPTests/Workers/XcodeCloudInputValidationContractTests.swift @@ -19,6 +19,29 @@ struct XcodeCloudInputValidationContractTests { } } + @Test("every public resource identifier schema matches runtime canonical-ID validation") + func resourceIdentifierSchemasAreCanonical() async throws { + let worker = XcodeCloudWorker(httpClient: try await TestFactory.makeHTTPClient()) + let tools = await worker.getTools() + let expectedPattern = #"^(?!\.{1,2}$)[A-Za-z0-9._~-]+$"# + + for tool in tools { + guard case .object(let schema) = tool.inputSchema, + case .object(let properties)? = schema["properties"] else { + Issue.record("Expected object properties for \(tool.name)") + continue + } + for (field, value) in properties where field.hasSuffix("_id") || field.hasSuffix("_ids") { + for itemSchema in xcodeCloudIdentifierItemSchemas(value) { + #expect( + itemSchema["pattern"] == .string(expectedPattern), + "Schema/runtime canonical-ID drift for \(tool.name).\(field)" + ) + } + } + } + } + @Test("unknown arguments fail before transport") func unknownArgumentsFailBeforeTransport() async throws { for invocation in [ @@ -126,6 +149,30 @@ private struct XcodeCloudIDValidationInvocation { let arguments: [String: Value] } +private func xcodeCloudIdentifierItemSchemas(_ value: Value) -> [[String: Value]] { + guard case .object(let schema) = value else { + Issue.record("Expected identifier schema object") + return [] + } + guard case .array(let alternatives)? = schema["oneOf"] else { + return [schema] + } + return alternatives.compactMap { alternative in + guard case .object(let candidate) = alternative else { + Issue.record("Expected identifier schema alternative") + return nil + } + if candidate["type"] == .string("array") { + guard case .object(let items)? = candidate["items"] else { + Issue.record("Expected identifier array item schema") + return nil + } + return items + } + return candidate + } +} + private func xcodeCloudListValidationInvocations() -> [XcodeCloudValidationInvocation] { [ .init(tool: "xcode_cloud_products_list", arguments: [:]),