From 44a0314bbd2bf5babc1fd98989c5eafef69f6d3b Mon Sep 17 00:00:00 2001 From: vlad Date: Mon, 20 Apr 2026 17:08:05 +0300 Subject: [PATCH 1/3] feat: subscription pricing + intro offers workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscriptions_set_availability: enable subscription in all 175 territories (POST /v1/subscriptionAvailabilities) - subscriptions_set_price: set equalized prices for all 175 territories in one PATCH request - GET /v1/subscriptionPricePoints/{id}/equalizations + prepend base price point = 175 total - Uses JSONAPI local ${N} IDs in included array - intro_offers_set_all_territories: set FREE_TRIAL intro offer for all 175 territories in one PATCH request - PATCH /v1/subscriptions/{id} with introductoryOffers relationship + included array - Single request replaces all existing intro offers - Fixed HTTPClient.patch() generic constraint: Codable → Encodable - Fixed SetAllSubscriptionPricesRequest model (replaced non-existent subscriptionPriceSchedules) - Fixed _meta maxResultSizeChars annotations for large result sets Co-Authored-By: Claude Sonnet 4.6 --- Package.swift | 2 +- Sources/asc-mcp/Core/Application.swift | 25 ++ .../IntroductoryOfferModels.swift | 49 ++++ .../Subscriptions/SubscriptionModels.swift | 127 ++++++++++ Sources/asc-mcp/Services/HTTPClient.swift | 2 +- .../AppsWorker/AppsWorker+Handlers.swift | 227 +++++++++++++++--- .../AppsWorker+ToolDefinitions.swift | 10 +- .../IntroductoryOffersWorker+Handlers.swift | 84 +++++++ ...oductoryOffersWorker+ToolDefinitions.swift | 30 +++ .../IntroductoryOffersWorker.swift | 3 + .../Workers/MainWorker/WorkerManager.swift | 26 +- .../SubscriptionsWorker+Handlers.swift | 149 ++++++++++++ .../SubscriptionsWorker+ToolDefinitions.swift | 46 ++++ .../SubscriptionsWorker.swift | 8 +- .../Workers/WorkerToolDefinitionsTests.swift | 6 +- 15 files changed, 747 insertions(+), 47 deletions(-) diff --git a/Package.swift b/Package.swift index c0d8c56..7081794 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .executable(name: "asc-mcp", targets: ["asc-mcp"]) ], dependencies: [ - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.3.0") + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0") ], targets: [ .executableTarget( diff --git a/Sources/asc-mcp/Core/Application.swift b/Sources/asc-mcp/Core/Application.swift index 563f4dc..138bd81 100644 --- a/Sources/asc-mcp/Core/Application.swift +++ b/Sources/asc-mcp/Core/Application.swift @@ -69,6 +69,31 @@ public func runApplication(enabledWorkers: Set? = nil) async throws { - promoted_* -- promoted in-app purchases - metrics_* -- performance metrics and diagnostics - review_attachments_* -- app store review attachments (upload, get, delete, list) + + ## Subscription Setup Workflow (FULL) + When asked to set up subscriptions for an app, always follow this exact order: + + 1. Find app ID: apps_list or apps_search + 2. Check existing groups: iap_list_subscriptions (app_id) + 3. Create group if needed: subscriptions_create_group (app_id, reference_name) + 4. Create group localization: subscriptions_create_group_localization (group_id, locale, name) + 5. For each subscription: + a. subscriptions_create (group_id, name, product_id, period, group_level, review_note) + b. subscriptions_create_localization (sub_id, locale, display_name, description ≤55 chars) + c. Set territory availability via bash curl POST /v1/subscriptionAvailabilities + with all 175 territories (GET /v1/territories first to get all IDs) + d. subscriptions_list_price_points (sub_id, territory=USA) — find price point for desired price + e. subscriptions_set_price (sub_id, price_point_id) + f. If free trial needed: intro_offers_create (sub_id, duration, number_of_periods, offer_type=FREE_TRIAL) + 6. Review screenshot: subscriptions_upload_review_screenshot (sub_id, image_path) + — ONLY if screenshot file path is provided; otherwise notify user it's still needed + + Notes: + - Availability step MUST happen before set_price, otherwise price API returns 409 + - Description max length is 55 characters + - group_level: 1 = highest tier, 2 = mid, 3 = lowest (affects upgrade/downgrade logic) + - After all steps, subscriptions move from MISSING_METADATA → READY_TO_SUBMIT once screenshot is uploaded + - Do NOT submit for App Store review — user does that manually """, capabilities: Server.Capabilities( tools: Server.Capabilities.Tools(listChanged: true) diff --git a/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift b/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift index 469d0d0..b20adb5 100644 --- a/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift +++ b/Sources/asc-mcp/Models/Subscriptions/IntroductoryOfferModels.swift @@ -68,6 +68,55 @@ public struct CreateIntroductoryOfferRequest: Codable, Sendable { } } +// MARK: - Set All Territories Introductory Offer (PATCH /v1/subscriptions/{id}) + +/// Request to set FREE_TRIAL intro offer for all territories in one PATCH. +/// Uses relationships.introductoryOffers + included array with ${N} local IDs. +public struct SetAllIntroductoryOffersRequest: Encodable, Sendable { + public let data: UpdateData + public let included: [InlineOffer] + + public struct UpdateData: Encodable, Sendable { + public let type: String = "subscriptions" + public let id: String + public let relationships: Relationships + } + + public struct Relationships: Encodable, Sendable { + public let introductoryOffers: OffersData + } + + public struct OffersData: Encodable, Sendable { + public let data: [OfferRef] + } + + public struct OfferRef: Encodable, Sendable { + public let id: String + public let type: String = "subscriptionIntroductoryOffers" + } + + public struct InlineOffer: Encodable, Sendable { + public let id: String + public let type: String = "subscriptionIntroductoryOffers" + public let attributes: OfferAttrs + public let relationships: InlineOfferRels + } + + public struct OfferAttrs: Encodable, Sendable { + public let duration: String + public let offerMode: String + public let numberOfPeriods: Int + } + + public struct InlineOfferRels: Encodable, Sendable { + public let territory: TerritoryRef + } + + public struct TerritoryRef: Encodable, Sendable { + public let data: ASCResourceIdentifier + } +} + /// Update introductory offer request public struct UpdateIntroductoryOfferRequest: Codable, Sendable { public let data: UpdateData diff --git a/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift b/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift index cddabe0..b4ec7b7 100644 --- a/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift +++ b/Sources/asc-mcp/Models/Subscriptions/SubscriptionModels.swift @@ -58,6 +58,54 @@ public struct SubscriptionLocalizationAttributes: Codable, Sendable { public let description: String? } +// MARK: - Set All Subscription Prices (PATCH /v1/subscriptions/{id}) + +/// Request to set prices for all territories via PATCH subscription. +/// Uses GET /v1/subscriptionPricePoints/{id}/equalizations to get all territory price points, +/// then PATCHes the subscription with all of them in a single request. +public struct SetAllSubscriptionPricesRequest: Encodable, Sendable { + public let data: UpdateData + public let included: [InlinePrice] + + public struct UpdateData: Encodable, Sendable { + public let type: String = "subscriptions" + public let id: String + public let relationships: Relationships + } + + public struct Relationships: Encodable, Sendable { + public let prices: PricesData + } + + public struct PricesData: Encodable, Sendable { + public let data: [PriceRef] + } + + public struct PriceRef: Encodable, Sendable { + public let id: String + public let type: String = "subscriptionPrices" + } + + public struct InlinePrice: Encodable, Sendable { + public let id: String + public let type: String = "subscriptionPrices" + public let attributes: PriceAttrs + public let relationships: InlinePriceRels + } + + public struct PriceAttrs: Encodable, Sendable { + public let preserveCurrentPrice: Bool + } + + public struct InlinePriceRels: Encodable, Sendable { + public let subscriptionPricePoint: PointRef + } + + public struct PointRef: Encodable, Sendable { + public let data: ASCResourceIdentifier + } +} + // MARK: - Subscription Price Models /// Subscription prices list response @@ -67,6 +115,34 @@ public struct ASCSubscriptionPricesResponse: Codable, Sendable { public let links: ASCPagedDocumentLinks? } +/// Single subscription price response +public struct ASCSubscriptionPriceResponse: Codable, Sendable { + public let data: ASCSubscriptionPrice +} + +/// Create subscription price request +public struct CreateSubscriptionPriceRequest: Codable, Sendable { + public let data: CreateData + + public struct CreateData: Codable, Sendable { + public let type: String = "subscriptionPrices" + public let relationships: Relationships + } + + public struct Relationships: Codable, Sendable { + public let subscription: SubscriptionRelationship + public let subscriptionPricePoint: SubscriptionPricePointRelationship + } + + public struct SubscriptionRelationship: Codable, Sendable { + public let data: ASCResourceIdentifier + } + + public struct SubscriptionPricePointRelationship: Codable, Sendable { + public let data: ASCResourceIdentifier + } +} + /// Subscription price resource public struct ASCSubscriptionPrice: Codable, Sendable { public let type: String @@ -256,3 +332,54 @@ public struct CreateSubscriptionSubmissionRequest: Codable, Sendable { public let data: ASCResourceIdentifier } } + +// MARK: - Subscription Availability Models + +/// Create subscription availability request (enables subscription in all territories) +public struct CreateSubscriptionAvailabilityRequest: Codable, Sendable { + public let data: CreateData + public let included: [TerritoryResource]? + + public struct CreateData: Codable, Sendable { + public let type: String = "subscriptionAvailabilities" + public let attributes: Attributes + public let relationships: Relationships + } + + public struct Attributes: Codable, Sendable { + public let availableInNewTerritories: Bool + } + + public struct Relationships: Codable, Sendable { + public let subscription: SubscriptionRelationship2 + public let availableTerritories: TerritoriesRelationship + } + + public struct SubscriptionRelationship2: Codable, Sendable { + public let data: ASCResourceIdentifier + } + + public struct TerritoriesRelationship: Codable, Sendable { + public let data: [ASCResourceIdentifier] + } + + public struct TerritoryResource: Codable, Sendable { + public let type: String + public let id: String + } +} + +/// Subscription availability response +public struct ASCSubscriptionAvailabilityResponse: Codable, Sendable { + public let data: ASCSubscriptionAvailability +} + +public struct ASCSubscriptionAvailability: Codable, Sendable { + public let type: String + public let id: String + public let attributes: AvailabilityAttributes? + + public struct AvailabilityAttributes: Codable, Sendable { + public let availableInNewTerritories: Bool? + } +} diff --git a/Sources/asc-mcp/Services/HTTPClient.swift b/Sources/asc-mcp/Services/HTTPClient.swift index ece12a4..2090015 100644 --- a/Sources/asc-mcp/Services/HTTPClient.swift +++ b/Sources/asc-mcp/Services/HTTPClient.swift @@ -243,7 +243,7 @@ extension HTTPClient { } /// PATCH request for updating resources - public func patch(_ endpoint: String, body: T, as responseType: R.Type) async throws -> R { + public func patch(_ endpoint: String, body: T, as responseType: R.Type) async throws -> R { let bodyData: Data do { bodyData = try JSONEncoder().encode(body) diff --git a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift index 9aad847..99d42b5 100644 --- a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift @@ -387,7 +387,7 @@ extension AppsWorker { ) } - // Step 2: Fetch localizations + // Step 2: Fetch version localizations (description, keywords, whatsNew) var localizationParams: [String: String] = [ "fields[appStoreVersionLocalizations]": "description,locale,keywords,marketingUrl,promotionalText,supportUrl,whatsNew", "limit": "200" @@ -402,6 +402,39 @@ extension AppsWorker { as: ASCAppStoreVersionLocalizationsResponse.self ) + // Step 2b: Fetch appInfo localizations (name, subtitle) — these live on appInfo, not version + let appInfosResponse: ASCAppInfosResponse = try await httpClient.get( + "/v1/apps/\(appId)/appInfos", + parameters: ["fields[appInfos]": "appStoreState"], + as: ASCAppInfosResponse.self + ) + + // Match appInfo by state: prefer same state as resolved version, fallback to first + let matchingAppInfo = appInfosResponse.data.first(where: { + $0.attributes?.appStoreState == resolvedVersion.state + }) ?? appInfosResponse.data.first + + var appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] = [:] + if let appInfoId = matchingAppInfo?.id { + var infoLocParams: [String: String] = [ + "fields[appInfoLocalizations]": "locale,name,subtitle", + "limit": "200" + ] + if let locale = locale { + infoLocParams["filter[locale]"] = locale + } + let infoLocsResponse: ASCAppInfoLocalizationsResponse = try await httpClient.get( + "/v1/appInfos/\(appInfoId)/appInfoLocalizations", + parameters: infoLocParams, + as: ASCAppInfoLocalizationsResponse.self + ) + for infoLoc in infoLocsResponse.data { + if let loc = infoLoc.attributes?.locale { + appInfoLocalizationsByLocale[loc] = infoLoc + } + } + } + // Check if locale filter returned empty results if let locale = locale, localizationsResponse.data.isEmpty { return CallTool.Result( @@ -419,18 +452,90 @@ extension AppsWorker { "appStoreState": resolvedVersion.state ] - // Helper to format a single localization + // Step 2c: Fetch IAP and subscription display names by locale (indexed by Apple for search) + var iapNamesByLocale: [String: [[String: String]]] = [:] + + // Fetch subscriptions via subscription groups + if let subGroupsData = try? await httpClient.get( + "/v1/apps/\(appId)/subscriptionGroups", + parameters: ["limit": "10"] + ) { + if let subGroupsResponse = try? JSONDecoder().decode(ASCSubscriptionGroupsResponse.self, from: subGroupsData) { + for group in subGroupsResponse.data { + if let subsData = try? await httpClient.get( + "/v1/subscriptionGroups/\(group.id)/subscriptions", + parameters: ["limit": "20"] + ) { + if let subsResponse = try? JSONDecoder().decode(ASCSubscriptionsResponse.self, from: subsData) { + for sub in subsResponse.data { + if let locsData = try? await httpClient.get( + "/v1/subscriptions/\(sub.id)/subscriptionLocalizations", + parameters: ["fields[subscriptionLocalizations]": "locale,name", "limit": "200"] + ) { + if let locsResponse = try? JSONDecoder().decode(ASCSubscriptionLocalizationsResponse.self, from: locsData) { + for subLoc in locsResponse.data { + if let localeStr = subLoc.attributes.locale, let name = subLoc.attributes.name { + var arr = iapNamesByLocale[localeStr, default: []] + arr.append(["type": "subscription", "productId": sub.attributes.productId ?? sub.id, "name": name]) + iapNamesByLocale[localeStr] = arr + } + } + } + } + } + } + } + } + } + } + + // Fetch non-subscription IAPs + if let iapsData = try? await httpClient.get( + "/v1/apps/\(appId)/inAppPurchasesV2", + parameters: ["limit": "20"] + ) { + if let iapsResponse = try? JSONDecoder().decode(ASCInAppPurchasesV2Response.self, from: iapsData) { + for iap in iapsResponse.data { + if let locsData = try? await httpClient.get( + "/v1/inAppPurchases/\(iap.id)/inAppPurchaseLocalizations", + parameters: ["fields[inAppPurchaseLocalizations]": "locale,name", "limit": "200"] + ) { + if let locsResponse = try? JSONDecoder().decode(ASCInAppPurchaseLocalizationsResponse.self, from: locsData) { + for iapLoc in locsResponse.data { + if let localeStr = iapLoc.attributes.locale, let name = iapLoc.attributes.name { + var arr = iapNamesByLocale[localeStr, default: []] + arr.append(["type": "iap", "productId": iap.attributes.productId ?? iap.id, "name": name]) + iapNamesByLocale[localeStr] = arr + } + } + } + } + } + } + } + + // Helper to format a single localization, merging version + appInfo data func formatLocalization(_ loc: ASCAppStoreVersionLocalization) -> [String: Any] { var data: [String: Any] = [ "id": loc.id, "locale": loc.locale ] + // From appStoreVersionLocalizations if let v = loc.attributes?.description { data["description"] = v } if let v = loc.attributes?.whatsNew { data["whatsNew"] = v } if let v = loc.attributes?.keywords { data["keywords"] = v } if let v = loc.attributes?.promotionalText { data["promotionalText"] = v } if let v = loc.attributes?.supportUrl { data["supportUrl"] = v } if let v = loc.attributes?.marketingUrl { data["marketingUrl"] = v } + // From appInfoLocalizations (name, subtitle) + if let infoLoc = appInfoLocalizationsByLocale[loc.locale] { + if let name = infoLoc.attributes?.name { data["name"] = name } + if let subtitle = infoLoc.attributes?.subtitle { data["subtitle"] = subtitle } + } + // From IAP/subscription localizations (indexed by Apple for search) + if let iapNames = iapNamesByLocale[loc.locale], !iapNames.isEmpty { + data["iapNames"] = iapNames + } return data } @@ -584,9 +689,10 @@ extension AppsWorker { ) let version = versionResponse.data - guard version.attributes?.appStoreState == "PREPARE_FOR_SUBMISSION" else { + let editableStates = ["PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED"] + guard let state = version.attributes?.appStoreState, editableStates.contains(state) else { return CallTool.Result( - content: [.text("Error: Version must be in PREPARE_FOR_SUBMISSION state for editing.\nCurrent state: \(version.attributes?.appStoreState ?? "Unknown")")], + content: [.text("Error: Version must be in PREPARE_FOR_SUBMISSION or DEVELOPER_REJECTED state for editing.\nCurrent state: \(version.attributes?.appStoreState ?? "Unknown")")], isError: true ) } @@ -615,58 +721,101 @@ extension AppsWorker { marketingUrl: arguments["marketing_url"]?.stringValue ) - // Check that at least one field is provided - let hasUpdates = attributes.description != nil || + // Check what needs updating + let nameParam = arguments["name"]?.stringValue + let subtitleParam = arguments["subtitle"]?.stringValue + + let hasVersionUpdates = attributes.description != nil || attributes.whatsNew != nil || attributes.keywords != nil || attributes.promotionalText != nil || attributes.supportUrl != nil || attributes.marketingUrl != nil - - guard hasUpdates else { + let hasAppInfoUpdates = nameParam != nil || subtitleParam != nil + + guard hasVersionUpdates || hasAppInfoUpdates else { return CallTool.Result( content: [.text("Warning: No fields specified for update")], isError: true ) } - - // 4. Send PATCH request - let updateRequest = ASCAppStoreVersionLocalizationUpdateRequest( - id: localization.id, - attributes: attributes - ) - - let _: ASCAppStoreVersionLocalizationUpdateResponse = try await httpClient.patch( - "/v1/appStoreVersionLocalizations/\(localization.id)", - body: updateRequest, - as: ASCAppStoreVersionLocalizationUpdateResponse.self - ) - - // 5. Format result + var result = "**Metadata updated successfully**\n\n" result += "Version: \(version.version)\n" result += "Locale: \(locale)\n\n" result += "**Updated fields:**\n" - if attributes.description != nil { - result += "- Description\n" - } - if attributes.whatsNew != nil { - result += "- What's New\n" - } - if attributes.keywords != nil { - result += "- Keywords\n" - } - if attributes.promotionalText != nil { - result += "- Promotional text\n" - } - if attributes.supportUrl != nil { - result += "- Support URL\n" + // 4a. Update version-level fields (description, keywords, whatsNew, etc.) + if hasVersionUpdates { + let updateRequest = ASCAppStoreVersionLocalizationUpdateRequest( + id: localization.id, + attributes: attributes + ) + + let _: ASCAppStoreVersionLocalizationUpdateResponse = try await httpClient.patch( + "/v1/appStoreVersionLocalizations/\(localization.id)", + body: updateRequest, + as: ASCAppStoreVersionLocalizationUpdateResponse.self + ) + + if attributes.description != nil { result += "- Description\n" } + if attributes.whatsNew != nil { result += "- What's New\n" } + if attributes.keywords != nil { result += "- Keywords\n" } + if attributes.promotionalText != nil { result += "- Promotional text\n" } + if attributes.supportUrl != nil { result += "- Support URL\n" } + if attributes.marketingUrl != nil { result += "- Marketing URL\n" } } - if attributes.marketingUrl != nil { - result += "- Marketing URL\n" + + // 4b. Update app-info-level fields (name, subtitle) if provided + if hasAppInfoUpdates { + guard let appId = appIdValue.stringValue else { + return CallTool.Result(content: [.text("Error: app_id required for name/subtitle update")], isError: true) + } + // Find matching appInfo by version state + let appInfosResponse: ASCAppInfosResponse = try await httpClient.get( + "/v1/apps/\(appId)/appInfos", + parameters: ["fields[appInfos]": "appStoreState"], + as: ASCAppInfosResponse.self + ) + let matchingAppInfo = appInfosResponse.data.first(where: { + $0.attributes?.appStoreState == state + }) ?? appInfosResponse.data.first + + guard let appInfoId = matchingAppInfo?.id else { + return CallTool.Result(content: [.text("Error: Could not find appInfo for state \(state)")], isError: true) + } + + // Find appInfoLocalization for this locale + let infoLocsResponse: ASCAppInfoLocalizationsResponse = try await httpClient.get( + "/v1/appInfos/\(appInfoId)/appInfoLocalizations", + parameters: ["filter[locale]": locale], + as: ASCAppInfoLocalizationsResponse.self + ) + + guard let infoLoc = infoLocsResponse.data.first else { + return CallTool.Result(content: [.text("Error: appInfoLocalization not found for locale \(locale)")], isError: true) + } + + // Build PATCH body for appInfoLocalization + var infoAttrs: [String: Any] = [:] + if let name = nameParam { infoAttrs["name"] = name } + if let subtitle = subtitleParam { infoAttrs["subtitle"] = subtitle } + + let patchBody: [String: Any] = [ + "data": [ + "type": "appInfoLocalizations", + "id": infoLoc.id, + "attributes": infoAttrs + ] as [String: Any] + ] + + let bodyData = try JSONSerialization.data(withJSONObject: patchBody) + _ = try await httpClient.patch("/v1/appInfoLocalizations/\(infoLoc.id)", body: bodyData) + + if nameParam != nil { result += "- Name (title)\n" } + if subtitleParam != nil { result += "- Subtitle\n" } } - + return CallTool.Result(content: [.text(result)]) } catch { diff --git a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift index 32951a6..28f3fb1 100644 --- a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+ToolDefinitions.swift @@ -139,7 +139,7 @@ extension AppsWorker { func updateMetadataTool() -> Tool { return Tool( name: "apps_update_metadata", - description: "Update app version metadata for a specific localization (version must be in PREPARE_FOR_SUBMISSION state)", + description: "Update ALL app metadata for a locale in one call: name, subtitle (from appInfo) + keywords, description, whatsNew (from version). Version must be editable (PREPARE_FOR_SUBMISSION or DEVELOPER_REJECTED).", inputSchema: .object([ "type": .string("object"), "properties": .object([ @@ -155,6 +155,14 @@ extension AppsWorker { "type": .string("string"), "description": .string("Locale code (e.g. 'en-US', 'ru-RU', 'de-DE', 'fr-FR', 'ja', 'zh-Hans')") ]), + "name": .object([ + "type": .string("string"), + "description": .string("App name/title for this locale (max 30 characters). Stored in appInfoLocalizations.") + ]), + "subtitle": .object([ + "type": .string("string"), + "description": .string("App subtitle for this locale (max 30 characters). Stored in appInfoLocalizations.") + ]), "description": .object([ "type": .string("string"), "description": .string("App description (up to 4000 characters)") diff --git a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift index 4a832f0..d4a85c0 100644 --- a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+Handlers.swift @@ -218,6 +218,90 @@ extension IntroductoryOffersWorker { } } + /// Sets a FREE_TRIAL introductory offer for all territories in one PATCH request. + /// Uses PATCH /v1/subscriptions/{id} with introductoryOffers relationship + included array. + /// - Returns: JSON with subscription state and territories count + func createIntroductoryOffersAllTerritories(_ params: CallTool.Parameters) async throws -> CallTool.Result { + guard let arguments = params.arguments, + let subscriptionId = arguments["subscription_id"]?.stringValue, + let duration = arguments["duration"]?.stringValue else { + return CallTool.Result( + content: [.text("Error: Required parameters: subscription_id, duration")], + isError: true + ) + } + + let offerMode = arguments["offer_mode"]?.stringValue ?? "FREE_TRIAL" + let numberOfPeriods = arguments["number_of_periods"]?.intValue ?? 1 + + do { + // Step 1: Get all territories + let territoriesResponse: ASCTerritoriesResponse = try await httpClient.get( + "/v1/territories", + parameters: ["limit": "200"], + as: ASCTerritoriesResponse.self + ) + let territories = territoriesResponse.data + + // Step 2: Build PATCH body with ${N} local IDs + let refs = territories.enumerated().map { (i, _) in + SetAllIntroductoryOffersRequest.OfferRef(id: "${\(i)}") + } + let included = territories.enumerated().map { (i, territory) in + SetAllIntroductoryOffersRequest.InlineOffer( + id: "${\(i)}", + attributes: SetAllIntroductoryOffersRequest.OfferAttrs( + duration: duration, + offerMode: offerMode, + numberOfPeriods: numberOfPeriods + ), + relationships: SetAllIntroductoryOffersRequest.InlineOfferRels( + territory: SetAllIntroductoryOffersRequest.TerritoryRef( + data: ASCResourceIdentifier(type: "territories", id: territory.id) + ) + ) + ) + } + + let request = SetAllIntroductoryOffersRequest( + data: SetAllIntroductoryOffersRequest.UpdateData( + id: subscriptionId, + relationships: SetAllIntroductoryOffersRequest.Relationships( + introductoryOffers: SetAllIntroductoryOffersRequest.OffersData(data: refs) + ) + ), + included: included + ) + + // Step 3: Single PATCH sets all territories at once + let response: ASCSubscriptionResponse = try await httpClient.patch( + "/v1/subscriptions/\(subscriptionId)", + body: request, + as: ASCSubscriptionResponse.self + ) + + let result: [String: Any] = [ + "success": true, + "subscription": [ + "id": response.data.id, + "state": response.data.attributes.state as Any, + "name": response.data.attributes.name as Any + ], + "offer_mode": offerMode, + "duration": duration, + "territories_set": territories.count + ] + + return CallTool.Result(content: [.text(JSONFormatter.formatJSON(result))]) + + } catch { + return CallTool.Result( + content: [.text("Error: Failed to set introductory offers: \(error.localizedDescription)")], + isError: true + ) + } + } + // MARK: - Formatting private func formatIntroductoryOffer(_ offer: ASCIntroductoryOffer) -> [String: Any] { diff --git a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift index c6caff5..729128d 100644 --- a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker+ToolDefinitions.swift @@ -103,6 +103,36 @@ extension IntroductoryOffersWorker { ) } + func createIntroductoryOffersAllTerritoresTool() -> Tool { + return Tool( + name: "intro_offers_set_all_territories", + description: "Set a FREE_TRIAL introductory offer for all 175 territories in a single PATCH request. Uses PATCH /v1/subscriptions/{id} with introductoryOffers in included array. Replaces any existing intro offers.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "subscription_id": .object([ + "type": .string("string"), + "description": .string("Subscription ID") + ]), + "duration": .object([ + "type": .string("string"), + "description": .string("Offer duration: THREE_DAYS, ONE_WEEK, TWO_WEEKS, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR") + ]), + "offer_mode": .object([ + "type": .string("string"), + "description": .string("Offer mode (default: FREE_TRIAL)"), + "enum": .array([.string("FREE_TRIAL"), .string("PAY_AS_YOU_GO"), .string("PAY_UP_FRONT")]) + ]), + "number_of_periods": .object([ + "type": .string("integer"), + "description": .string("Number of periods (default: 1)") + ]) + ]), + "required": .array([.string("subscription_id"), .string("duration")]) + ]) + ) + } + func deleteIntroductoryOfferTool() -> Tool { return Tool( name: "intro_offers_delete", diff --git a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker.swift b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker.swift index 1bdf72a..3e10e7c 100644 --- a/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker.swift +++ b/Sources/asc-mcp/Workers/IntroductoryOffersWorker/IntroductoryOffersWorker.swift @@ -15,6 +15,7 @@ public final class IntroductoryOffersWorker: Sendable { return [ listIntroductoryOffersTool(), createIntroductoryOfferTool(), + createIntroductoryOffersAllTerritoresTool(), updateIntroductoryOfferTool(), deleteIntroductoryOfferTool() ] @@ -27,6 +28,8 @@ public final class IntroductoryOffersWorker: Sendable { return try await listIntroductoryOffers(params) case "intro_offers_create": return try await createIntroductoryOffer(params) + case "intro_offers_set_all_territories": + return try await createIntroductoryOffersAllTerritories(params) case "intro_offers_update": return try await updateIntroductoryOffer(params) case "intro_offers_delete": diff --git a/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift b/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift index 31e1f75..43c0970 100644 --- a/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift +++ b/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift @@ -258,9 +258,31 @@ public actor WorkerManager { allTools += await self.getReviewAttachmentsTools() } - return ListTools.Result(tools: allTools) + // Annotate tools with maxResultSizeChars for Claude Code + let analyticsTools: Set = [ + "analytics_sales_report", "analytics_financial_report", + "analytics_get_report", "analytics_app_summary" + ] + + let annotatedTools = allTools.map { tool -> Tool in + var modified = tool + let maxChars: Int + if analyticsTools.contains(tool.name) { + maxChars = 500_000 + } else if tool.name.contains("_list") || tool.name.contains("_search") { + maxChars = 200_000 + } else { + maxChars = 100_000 + } + modified._meta = Metadata(additionalFields: [ + "anthropic/maxResultSizeChars": .int(maxChars) + ]) + return modified + } + + return ListTools.Result(tools: annotatedTools) } - + // Handler for all tool calls await server.withMethodHandler(CallTool.self) { params in do { diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift index 26aeff2..17bb7f8 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+Handlers.swift @@ -487,6 +487,10 @@ extension SubscriptionsWorker { queryParams["limit"] = "25" } + if let territory = arguments["territory"]?.stringValue { + queryParams["filter[territory]"] = territory + } + response = try await httpClient.get( "/v1/subscriptions/\(subscriptionId)/pricePoints", parameters: queryParams, @@ -710,6 +714,151 @@ extension SubscriptionsWorker { } } + /// Sets prices for all 175 territories at once via PATCH /v1/subscriptions/{id}. + /// Gets equalized price points for all territories from the base USD price point, + /// then PATCHes the subscription with all of them in a single request. + /// - Returns: JSON with subscription state after update + func setSubscriptionPriceSchedule(_ params: CallTool.Parameters) async throws -> CallTool.Result { + guard let arguments = params.arguments, + let subscriptionId = arguments["subscription_id"]?.stringValue, + let pricePointId = arguments["price_point_id"]?.stringValue else { + return CallTool.Result( + content: [.text("Error: Required parameters: subscription_id, price_point_id")], + isError: true + ) + } + + do { + // Step 1: Get all equalized price points for all territories (excludes base/USA territory) + let equalizations: ASCSubscriptionPricePointsResponse = try await httpClient.get( + "/v1/subscriptionPricePoints/\(pricePointId)/equalizations", + parameters: ["limit": "200"], + as: ASCSubscriptionPricePointsResponse.self + ) + + // Step 2: Build combined list — base price point (USA) first, then all equalized territories + // equalizations returns ~174 non-base territories; prepend base to cover all 175 + let allPricePointIds = [pricePointId] + equalizations.data.map { $0.id } + + let priceRefs = allPricePointIds.enumerated().map { (i, _) in + SetAllSubscriptionPricesRequest.PriceRef(id: "${\(i)}") + } + let inlinePrices = allPricePointIds.enumerated().map { (i, ppId) in + SetAllSubscriptionPricesRequest.InlinePrice( + id: "${\(i)}", + attributes: SetAllSubscriptionPricesRequest.PriceAttrs(preserveCurrentPrice: false), + relationships: SetAllSubscriptionPricesRequest.InlinePriceRels( + subscriptionPricePoint: SetAllSubscriptionPricesRequest.PointRef( + data: ASCResourceIdentifier(type: "subscriptionPricePoints", id: ppId) + ) + ) + ) + } + + let request = SetAllSubscriptionPricesRequest( + data: SetAllSubscriptionPricesRequest.UpdateData( + id: subscriptionId, + relationships: SetAllSubscriptionPricesRequest.Relationships( + prices: SetAllSubscriptionPricesRequest.PricesData(data: priceRefs) + ) + ), + included: inlinePrices + ) + + // Step 3: PATCH subscription with all territory prices + let response: ASCSubscriptionResponse = try await httpClient.patch( + "/v1/subscriptions/\(subscriptionId)", + body: request, + as: ASCSubscriptionResponse.self + ) + + let result: [String: Any] = [ + "success": true, + "subscription": [ + "id": response.data.id, + "state": response.data.attributes.state as Any, + "name": response.data.attributes.name as Any + ], + "territories_set": allPricePointIds.count + ] + + return CallTool.Result(content: [.text(JSONFormatter.formatJSON(result))]) + + } catch { + return CallTool.Result( + content: [.text("Error: Failed to set subscription prices: \(error.localizedDescription)")], + isError: true + ) + } + } + + // MARK: - Subscription Availability + + /// Enables a subscription in all 175 App Store territories. + /// Must be called before subscriptions_set_price. + func setSubscriptionAvailability(_ params: CallTool.Parameters) async throws -> CallTool.Result { + guard let arguments = params.arguments, + let subscriptionId = arguments["subscription_id"]?.stringValue else { + return CallTool.Result( + content: [.text("Error: Required parameter 'subscription_id' is missing")], + isError: true + ) + } + + do { + // Fetch all territories + let territoriesResponse: ASCTerritoriesResponse = try await httpClient.get( + "/v1/territories", + parameters: ["limit": "200"], + as: ASCTerritoriesResponse.self + ) + + let territoryIdentifiers = territoriesResponse.data.map { + ASCResourceIdentifier(type: "territories", id: $0.id) + } + + let request = CreateSubscriptionAvailabilityRequest( + data: CreateSubscriptionAvailabilityRequest.CreateData( + attributes: CreateSubscriptionAvailabilityRequest.Attributes( + availableInNewTerritories: true + ), + relationships: CreateSubscriptionAvailabilityRequest.Relationships( + subscription: CreateSubscriptionAvailabilityRequest.SubscriptionRelationship2( + data: ASCResourceIdentifier(type: "subscriptions", id: subscriptionId) + ), + availableTerritories: CreateSubscriptionAvailabilityRequest.TerritoriesRelationship( + data: territoryIdentifiers + ) + ) + ), + included: nil + ) + + let response: ASCSubscriptionAvailabilityResponse = try await httpClient.post( + "/v1/subscriptionAvailabilities", + body: request, + as: ASCSubscriptionAvailabilityResponse.self + ) + + let result: [String: Any] = [ + "success": true, + "availability": [ + "id": response.data.id, + "availableInNewTerritories": response.data.attributes?.availableInNewTerritories as Any, + "territories_count": territoryIdentifiers.count + ] + ] + + return CallTool.Result(content: [.text(JSONFormatter.formatJSON(result))]) + + } catch { + return CallTool.Result( + content: [.text("Error: Failed to set subscription availability: \(error.localizedDescription)")], + isError: true + ) + } + } + // MARK: - Subscription Group Localizations /// Lists localizations for a subscription group diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift index 0298436..1ffeedf 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker+ToolDefinitions.swift @@ -265,6 +265,10 @@ extension SubscriptionsWorker { "type": .string("string"), "description": .string("Subscription ID") ]), + "territory": .object([ + "type": .string("string"), + "description": .string("Filter by territory code (e.g. USA, GBR, DEU). Returns price points for that territory only.") + ]), "limit": .object([ "type": .string("integer"), "description": .string("Max results (default: 25, max: 200)") @@ -642,4 +646,46 @@ extension SubscriptionsWorker { ]) ) } + + func setSubscriptionAvailabilityTool() -> Tool { + return Tool( + name: "subscriptions_set_availability", + description: "Enable a subscription in all 175 App Store territories. Must be called before subscriptions_set_price. Creates the subscriptionAvailability resource with availableInNewTerritories=true.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "subscription_id": .object([ + "type": .string("string"), + "description": .string("Subscription ID") + ]) + ]), + "required": .array([.string("subscription_id")]) + ]) + ) + } + + func setSubscriptionPriceScheduleTool() -> Tool { + return Tool( + name: "subscriptions_set_price", + description: "Set price for a subscription in all territories at once. Gets equalized price points for all ~175 territories from the base USD price point, then updates subscription in a single PATCH request. Use subscriptions_list_price_points with territory=USA to find price_point_id. Prerequisite: call subscriptions_set_availability first.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "subscription_id": .object([ + "type": .string("string"), + "description": .string("Subscription ID") + ]), + "price_point_id": .object([ + "type": .string("string"), + "description": .string("Subscription price point ID from subscriptions_list_price_points (use territory=USA filter)") + ]), + "base_territory_id": .object([ + "type": .string("string"), + "description": .string("Base territory ISO code for price calculation (default: USA). Apple propagates prices to all other territories from this base.") + ]) + ]), + "required": .array([.string("subscription_id"), .string("price_point_id")]) + ]) + ) + } } diff --git a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker.swift b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker.swift index b6539f1..2329b7b 100644 --- a/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker.swift +++ b/Sources/asc-mcp/Workers/SubscriptionsWorker/SubscriptionsWorker.swift @@ -36,6 +36,7 @@ public final class SubscriptionsWorker: Sendable { updateSubscriptionGroupLocalizationTool(), deleteSubscriptionGroupLocalizationTool(), deleteSubscriptionPriceTool(), + setSubscriptionPriceScheduleTool(), uploadSubscriptionImageTool(), getSubscriptionImageTool(), deleteSubscriptionImageTool(), @@ -43,7 +44,8 @@ public final class SubscriptionsWorker: Sendable { getSubscriptionReviewScreenshotTool(), deleteSubscriptionReviewScreenshotTool(), listSubscriptionImagesTool(), - getSubscriptionReviewScreenshotForSubscriptionTool() + getSubscriptionReviewScreenshotForSubscriptionTool(), + setSubscriptionAvailabilityTool() ] } @@ -92,6 +94,8 @@ public final class SubscriptionsWorker: Sendable { return try await deleteSubscriptionGroupLocalization(params) case "subscriptions_delete_price": return try await deleteSubscriptionPrice(params) + case "subscriptions_set_price": + return try await setSubscriptionPriceSchedule(params) case "subscriptions_upload_image": return try await uploadSubscriptionImage(params) case "subscriptions_get_image": @@ -108,6 +112,8 @@ public final class SubscriptionsWorker: Sendable { return try await listSubscriptionImages(params) case "subscriptions_get_review_screenshot_for_subscription": return try await getSubscriptionReviewScreenshotForSubscription(params) + case "subscriptions_set_availability": + return try await setSubscriptionAvailability(params) default: throw MCPError.methodNotFound("Unknown tool: \(params.name)") } diff --git a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift index 1b7c640..091a39a 100644 --- a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift +++ b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift @@ -309,12 +309,12 @@ struct WorkerToolDefinitionsTests { // MARK: - SubscriptionsWorker (27 tools) - @Test("SubscriptionsWorker returns 29 tools with correct names") + @Test("SubscriptionsWorker returns 31 tools with correct names") func subscriptionsWorkerTools() async throws { let client = try await TestFactory.makeHTTPClient() let worker = SubscriptionsWorker(httpClient: client, uploadService: UploadService()) let tools = await worker.getTools() - #expect(tools.count == 29) + #expect(tools.count == 31) let names = Set(tools.map(\.name)) #expect(names.contains("subscriptions_list")) #expect(names.contains("subscriptions_get")) @@ -337,6 +337,8 @@ struct WorkerToolDefinitionsTests { #expect(names.contains("subscriptions_update_group_localization")) #expect(names.contains("subscriptions_delete_group_localization")) #expect(names.contains("subscriptions_delete_price")) + #expect(names.contains("subscriptions_set_price")) + #expect(names.contains("subscriptions_set_availability")) #expect(names.contains("subscriptions_upload_image")) #expect(names.contains("subscriptions_get_image")) #expect(names.contains("subscriptions_delete_image")) From 40a5bd7de1cd73f9dd92cd5eb3623abccd819744 Mon Sep 17 00:00:00 2001 From: vlad Date: Mon, 20 Apr 2026 17:10:58 +0300 Subject: [PATCH 2/3] test: update IntroductoryOffersWorker tool count to 5 (add set_all_territories) --- Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift index 091a39a..85e4682 100644 --- a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift +++ b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift @@ -383,17 +383,18 @@ struct WorkerToolDefinitionsTests { #expect(names.contains("winback_list_prices")) } - // MARK: - IntroductoryOffersWorker (4 tools) + // MARK: - IntroductoryOffersWorker (5 tools) - @Test("IntroductoryOffersWorker returns 4 tools with correct names") + @Test("IntroductoryOffersWorker returns 5 tools with correct names") func introductoryOffersWorkerTools() async throws { let client = try await TestFactory.makeHTTPClient() let worker = IntroductoryOffersWorker(httpClient: client) let tools = await worker.getTools() - #expect(tools.count == 4) + #expect(tools.count == 5) let names = Set(tools.map(\.name)) #expect(names.contains("intro_offers_list")) #expect(names.contains("intro_offers_create")) + #expect(names.contains("intro_offers_set_all_territories")) #expect(names.contains("intro_offers_update")) #expect(names.contains("intro_offers_delete")) } From 9c05b7bbbfe6fc98cf5b0f08aa66aa162c9a2a89 Mon Sep 17 00:00:00 2001 From: vlad Date: Mon, 20 Apr 2026 19:29:24 +0300 Subject: [PATCH 3/3] feat: add screenshots_update_preview tool for setting preview frame timecode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PATCH /v1/appPreviews/{id} support via screenshots_update_preview tool. After uploading an app preview, use this to set the thumbnail frame (previewFrameTimeCode in HH:MM:SS:FF format, e.g. "00:00:02:00"). - UpdatePreviewRequest model in ScreenshotModels.swift - updatePreview() handler in ScreenshotsWorker+Handlers.swift - updatePreviewTool() definition with preview_id + preview_frame_timecode params - Registered in ScreenshotsWorker.swift (17 tools total) - Tests updated: count 16→17, validation test for missing params Co-Authored-By: Claude Sonnet 4.6 --- .../Models/Marketing/ScreenshotModels.swift | 13 ++++++++++ .../ScreenshotsWorker+Handlers.swift | 24 +++++++++++++++++++ .../ScreenshotsWorker+ToolDefinitions.swift | 21 ++++++++++++++++ .../ScreenshotsWorker/ScreenshotsWorker.swift | 5 +++- .../Workers/ParameterValidationTests.swift | 9 +++++++ .../Workers/WorkerToolDefinitionsTests.swift | 7 +++--- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/Sources/asc-mcp/Models/Marketing/ScreenshotModels.swift b/Sources/asc-mcp/Models/Marketing/ScreenshotModels.swift index a4f2631..573babc 100644 --- a/Sources/asc-mcp/Models/Marketing/ScreenshotModels.swift +++ b/Sources/asc-mcp/Models/Marketing/ScreenshotModels.swift @@ -222,6 +222,19 @@ public struct CommitPreviewRequest: Codable, Sendable { } } +/// Update preview timecode request +public struct UpdatePreviewRequest: Codable, Sendable { + public let data: UpdateData + public struct UpdateData: Codable, Sendable { + public let type: String = "appPreviews" + public let id: String + public let attributes: Attributes + } + public struct Attributes: Codable, Sendable { + public let previewFrameTimeCode: String + } +} + /// Create preview reservation request public struct CreatePreviewRequest: Codable, Sendable { public let data: CreateData diff --git a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+Handlers.swift b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+Handlers.swift index fe768d5..9b23840 100644 --- a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+Handlers.swift @@ -849,6 +849,30 @@ extension ScreenshotsWorker { return result } + func updatePreview(_ params: CallTool.Parameters) async throws -> CallTool.Result { + guard let previewId = params.arguments?["preview_id"]?.stringValue else { + return .init(content: [.text("Missing required parameter: preview_id")], isError: true) + } + guard let timecode = params.arguments?["preview_frame_timecode"]?.stringValue else { + return .init(content: [.text("Missing required parameter: preview_frame_timecode")], isError: true) + } + + let request = UpdatePreviewRequest( + data: .init( + id: previewId, + attributes: .init(previewFrameTimeCode: timecode) + ) + ) + + let encoder = JSONEncoder() + let bodyData = try encoder.encode(request) + let responseData = try await httpClient.patch("/v1/appPreviews/\(previewId)", body: bodyData) + let response = try JSONDecoder().decode(ASCPreviewResponse.self, from: responseData) + + let result = formatPreview(response.data) + return .init(content: [.text(JSONFormatter.formatJSON(result))]) + } + private func formatPreviewSet(_ set: ASCPreviewSet) -> [String: Any] { return [ "id": set.id, diff --git a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+ToolDefinitions.swift index 316d57e..7f72a02 100644 --- a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker+ToolDefinitions.swift @@ -322,6 +322,27 @@ extension ScreenshotsWorker { ) } + func updatePreviewTool() -> Tool { + return Tool( + name: "screenshots_update_preview", + description: "Update the preview frame timecode of an app preview. Use this after uploading a preview to set the thumbnail frame (e.g. '00:00:02:00' for the 2-second frame).", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "preview_id": .object([ + "type": .string("string"), + "description": .string("Preview ID to update") + ]), + "preview_frame_timecode": .object([ + "type": .string("string"), + "description": .string("Timecode for the thumbnail frame in HH:MM:SS:FF format, e.g. '00:00:02:00'") + ]) + ]), + "required": .array([.string("preview_id"), .string("preview_frame_timecode")]) + ]) + ) + } + func deletePreviewTool() -> Tool { return Tool( name: "screenshots_delete_preview", diff --git a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker.swift b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker.swift index 0a92b2d..3b97157 100644 --- a/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker.swift +++ b/Sources/asc-mcp/Workers/ScreenshotsWorker/ScreenshotsWorker.swift @@ -29,7 +29,8 @@ public final class ScreenshotsWorker: Sendable { getPreviewTool(), listPreviewsTool(), deletePreviewTool(), - uploadScreenshotBatchTool() + uploadScreenshotBatchTool(), + updatePreviewTool() ] } @@ -68,6 +69,8 @@ public final class ScreenshotsWorker: Sendable { return try await deletePreview(params) case "screenshots_upload_batch": return try await uploadScreenshotBatch(params) + case "screenshots_update_preview": + return try await updatePreview(params) default: throw MCPError.methodNotFound("Unknown tool: \(params.name)") } diff --git a/Tests/ASCMCPTests/Workers/ParameterValidationTests.swift b/Tests/ASCMCPTests/Workers/ParameterValidationTests.swift index b31176b..0d30dc4 100644 --- a/Tests/ASCMCPTests/Workers/ParameterValidationTests.swift +++ b/Tests/ASCMCPTests/Workers/ParameterValidationTests.swift @@ -953,6 +953,15 @@ struct ParameterValidationTests { #expect(result.isError == true) } + @Test("screenshots_update_preview without required params returns isError") + func screenshotsUpdatePreviewMissing() async throws { + let client = try await TestFactory.makeHTTPClient() + let worker = ScreenshotsWorker(httpClient: client, uploadService: UploadService()) + let params = CallTool.Parameters(name: "screenshots_update_preview", arguments: nil) + let result = try await worker.handleTool(params) + #expect(result.isError == true) + } + // MARK: - CustomProductPagesWorker @Test("custom_pages_list without app_id returns isError") diff --git a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift index 85e4682..3090a5c 100644 --- a/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift +++ b/Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift @@ -479,14 +479,14 @@ struct WorkerToolDefinitionsTests { #expect(names.contains("beta_license_update")) } - // MARK: - ScreenshotsWorker (15 tools) + // MARK: - ScreenshotsWorker (17 tools) - @Test("ScreenshotsWorker returns 15 tools with correct names") + @Test("ScreenshotsWorker returns 17 tools with correct names") func screenshotsWorkerTools() async throws { let client = try await TestFactory.makeHTTPClient() let worker = ScreenshotsWorker(httpClient: client, uploadService: UploadService()) let tools = await worker.getTools() - #expect(tools.count == 16) + #expect(tools.count == 17) let names = Set(tools.map(\.name)) #expect(names.contains("screenshots_list_sets")) #expect(names.contains("screenshots_create_set")) @@ -504,6 +504,7 @@ struct WorkerToolDefinitionsTests { #expect(names.contains("screenshots_list_previews")) #expect(names.contains("screenshots_delete_preview")) #expect(names.contains("screenshots_upload_batch")) + #expect(names.contains("screenshots_update_preview")) } // MARK: - CustomProductPagesWorker (10 tools)