@yoonion/mimi-seed-mcp 0.21.2 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/googleads/config.d.ts +5 -2
  2. package/dist/googleads/config.js +22 -9
  3. package/dist/googleads/errors.js +25 -17
  4. package/dist/googleads/setup-cli.js +1 -1
  5. package/dist/googleads/tools.d.ts +8 -1
  6. package/dist/googleads/tools.js +19 -1
  7. package/dist/playstore/products.d.ts +1 -1
  8. package/dist/playstore/products.js +1 -1
  9. package/dist/registers/appstore/apps.d.ts +4 -0
  10. package/dist/registers/appstore/apps.js +43 -0
  11. package/dist/registers/appstore/customer-reviews.d.ts +4 -0
  12. package/dist/registers/appstore/customer-reviews.js +31 -0
  13. package/dist/registers/appstore/declarations.d.ts +4 -0
  14. package/dist/registers/appstore/declarations.js +163 -0
  15. package/dist/registers/appstore/media.d.ts +6 -0
  16. package/dist/registers/appstore/media.js +116 -0
  17. package/dist/registers/appstore/metadata.d.ts +11 -0
  18. package/dist/registers/appstore/metadata.js +120 -0
  19. package/dist/registers/appstore/products.d.ts +6 -0
  20. package/dist/registers/appstore/products.js +297 -0
  21. package/dist/registers/appstore/reports.d.ts +6 -0
  22. package/dist/registers/appstore/reports.js +77 -0
  23. package/dist/registers/appstore/review-submission.d.ts +6 -0
  24. package/dist/registers/appstore/review-submission.js +113 -0
  25. package/dist/registers/appstore/testflight.d.ts +6 -0
  26. package/dist/registers/appstore/testflight.js +193 -0
  27. package/dist/registers/appstore/versions.d.ts +8 -0
  28. package/dist/registers/appstore/versions.js +205 -0
  29. package/dist/registers/appstore.d.ts +8 -0
  30. package/dist/registers/appstore.js +37 -1274
  31. package/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import * as appstore from '../../appstore/tools.js';
3
+ import { validateAppStoreWhatsNew, formatIssuesForUser } from '../../lib/text-validators.js';
4
+ import { jsonResult, textResult, errorResult } from '../../lib/mcp-response.js';
5
+ /** appstore_get_metadata · appstore_update_localization */
6
+ export function registerVersionLocalizationTools(server) {
7
+ server.tool('appstore_get_metadata', 'App Store 버전 메타데이터 (설명문, 키워드, What\'s New)', { versionId: z.string().describe('버전 ID') }, async ({ versionId }) => {
8
+ const localizations = await appstore.getVersionLocalizations(versionId);
9
+ return jsonResult(localizations);
10
+ });
11
+ server.tool('appstore_update_localization', "App Store 버전 로컬라이제이션(메타데이터) 수정 — localizationId 직접 지정. 이 버전의 새로운 기능(whatsNew), 설명(description), 키워드, 프로모션 텍스트를 편집. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
12
+ localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)'),
13
+ whatsNew: z.string().optional().describe('이 버전의 새로운 기능 (4000자 이내)'),
14
+ description: z.string().optional().describe('앱 설명 (4000자 이내)'),
15
+ keywords: z.string().optional().describe('키워드 (쉼표 구분, 100자 이내)'),
16
+ promotionalText: z.string().optional().describe('프로모션 텍스트 (170자 이내)'),
17
+ supportUrl: z.string().url().optional().describe('지원 URL'),
18
+ marketingUrl: z.string().url().optional().describe('마케팅 URL'),
19
+ }, async ({ localizationId, ...fields }) => {
20
+ const cleaned = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
21
+ if (Object.keys(cleaned).length === 0) {
22
+ throw new Error('수정할 필드를 하나 이상 지정해줘 (whatsNew, description, keywords, promotionalText, supportUrl, marketingUrl).');
23
+ }
24
+ // whatsNew 가 포함될 때만 사전 lint — 다른 필드(description/keywords)는 별도 정책.
25
+ if (typeof cleaned.whatsNew === 'string') {
26
+ const validation = validateAppStoreWhatsNew(cleaned.whatsNew);
27
+ if (!validation.ok) {
28
+ return errorResult(`❌ whatsNew 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`);
29
+ }
30
+ }
31
+ const result = await appstore.updateVersionLocalization(localizationId, cleaned);
32
+ return jsonResult(result);
33
+ });
34
+ }
35
+ /** appstore_update_whats_new · appstore_update_review_notes · appstore_get_review_notes */
36
+ export function registerWhatsNewAndReviewNoteTools(server) {
37
+ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기능' 편집 — versionId + locale만 주면 자동으로 로컬라이제이션을 찾아 PATCH. 가장 흔한 사용 케이스. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
38
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
39
+ locale: z.string().describe('로캘 (예: ko, en-US, ja)'),
40
+ whatsNew: z.string().describe("'이 버전의 새로운 기능' 텍스트 (4000자 이내)"),
41
+ }, async ({ versionId, locale, whatsNew }) => {
42
+ // ── 사전 lint — Apple 409 INVALID_CHARACTERS 등 round-trip 낭비 차단.
43
+ const validation = validateAppStoreWhatsNew(whatsNew);
44
+ if (!validation.ok) {
45
+ return errorResult(`❌ What's New 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`);
46
+ }
47
+ const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
48
+ return textResult(`✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}`);
49
+ });
50
+ server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용. 4000자 권장 한도.", {
51
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
52
+ notes: z.string().min(1).max(4000).describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내.'),
53
+ }, async ({ versionId, notes }) => {
54
+ const result = await appstore.updateReviewNotes(versionId, notes);
55
+ const action = result.created ? 'created' : 'updated';
56
+ const summary = `✅ 리뷰어 노트 ${result.created ? '신규 등록' : '수정'} 완료 (reviewDetailId: ${result.reviewDetailId})`;
57
+ return textResult(`${summary}\n\n${JSON.stringify({ ok: true, action, ...result }, null, 2)}`);
58
+ });
59
+ server.tool('appstore_get_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 조회. 현재 등록된 notes, contactEmail 확인용.", {
60
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
61
+ }, async ({ versionId }) => {
62
+ const result = await appstore.getReviewNotes(versionId);
63
+ if (!result.reviewDetailId) {
64
+ return textResult(`이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.\n\n${JSON.stringify({ ok: true, exists: false }, null, 2)}`);
65
+ }
66
+ const summary = `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`;
67
+ return textResult(`${summary}\n\n${JSON.stringify({ ok: true, exists: true, ...result }, null, 2)}`);
68
+ });
69
+ }
70
+ /** appstore_get_app_info · appstore_{list,update,create}_app_info_localization(s) */
71
+ export function registerAppInfoTools(server) {
72
+ server.tool('appstore_get_app_info', 'App Store 앱 정보 (카테고리, 연령 등급, state). state=READY_FOR_DISTRIBUTION이 라이브, 그 외가 편집 가능 appInfo.', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
73
+ const info = await appstore.getAppInfo(appId);
74
+ return jsonResult(info);
75
+ });
76
+ server.tool('appstore_list_app_info_localizations', [
77
+ '편집 가능한 appInfo의 로컬라이제이션 목록 조회 — 앱 이름(name), 부제(subtitle), 개인정보 URL/텍스트.',
78
+ 'appInfo.relationships.appInfoLocalizations가 빈 배열로 오는 케이스를 우회하려고 /appInfos/{id}/appInfoLocalizations 직접 호출.',
79
+ 'locale을 주면 해당 언어만 반환 (예: "ko", "en-US").',
80
+ '※ versionLocalization(설명/키워드/whatsNew)과 다름 — 그건 appstore_get_metadata 사용.',
81
+ ].join(' '), {
82
+ appId: z.string().describe('앱 ID (appstore_list_apps 결과)'),
83
+ locale: z.string().optional().describe('언어 필터 (예: "ko", "en-US"). 생략 시 전체 반환.'),
84
+ }, async ({ appId, locale }) => {
85
+ const result = await appstore.listAppInfoLocalizations(appId, locale);
86
+ return jsonResult(result);
87
+ });
88
+ server.tool('appstore_update_app_info_localization', [
89
+ 'appInfo 로컬라이제이션(앱 이름/부제/개인정보 URL/텍스트) 수정 — PATCH /appInfoLocalizations/{id}.',
90
+ 'localizationId는 appstore_list_app_info_localizations 결과의 id.',
91
+ '편집 가능 상태(PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED 등)에서만 반영됨.',
92
+ '제한: name 30자, subtitle 30자.',
93
+ ].join(' '), {
94
+ localizationId: z.string().describe('appInfoLocalization ID'),
95
+ name: z.string().optional().describe('앱 이름 (30자 이내)'),
96
+ subtitle: z.string().optional().describe('부제 (30자 이내)'),
97
+ privacyPolicyUrl: z.string().url().optional().describe('개인정보 처리방침 URL'),
98
+ privacyPolicyText: z.string().optional().describe('개인정보 처리방침 텍스트'),
99
+ }, async ({ localizationId, ...fields }) => {
100
+ const result = await appstore.updateAppInfoLocalization(localizationId, fields);
101
+ return jsonResult(result);
102
+ });
103
+ server.tool('appstore_create_app_info_localization', [
104
+ 'appInfo 로컬라이제이션(스토어 언어) 추가 — POST /appInfoLocalizations.',
105
+ '편집 가능 appInfo를 자동으로 찾아 새 locale의 앱 이름/부제/개인정보 URL을 생성.',
106
+ '이미 존재하는 locale이면 409 DUPLICATE — 그땐 appstore_update_app_info_localization 사용.',
107
+ '생성하면 같은 locale의 버전 로컬라이제이션(설명/키워드/whatsNew)도 함께 생길 수 있음(2026-07 실측) — 내용 채우기는 appstore_update_localization.',
108
+ '제한: name 30자, subtitle 30자. locale 예: "en-US", "ja", "zh-Hans", "zh-Hant".',
109
+ ].join(' '), {
110
+ appId: z.string().describe('앱 ID (appstore_list_apps 결과)'),
111
+ locale: z.string().describe('추가할 언어 (예: "en-US", "ja", "zh-Hans", "zh-Hant")'),
112
+ name: z.string().optional().describe('앱 이름 (30자 이내)'),
113
+ subtitle: z.string().optional().describe('부제 (30자 이내)'),
114
+ privacyPolicyUrl: z.string().url().optional().describe('개인정보 처리방침 URL'),
115
+ privacyPolicyText: z.string().optional().describe('개인정보 처리방침 텍스트'),
116
+ }, async ({ appId, locale, ...fields }) => {
117
+ const result = await appstore.createAppInfoLocalization(appId, locale, fields);
118
+ return textResult(`✅ ${locale} 로컬라이제이션이 생성됐어.\n\n${JSON.stringify(result, null, 2)}`);
119
+ });
120
+ }
@@ -0,0 +1,6 @@
1
+ /** 인앱 구매 · 구독 상품 — 생성 · 조회 · 현지화 · 심사 메타데이터 · 단독 심사 제출 · 수정/삭제. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_create_inapp_purchase … appstore_add_product_to_review */
4
+ export declare function registerProductTools(server: ToolRegistrar): void;
5
+ /** appstore_update_product · appstore_delete_product */
6
+ export declare function registerProductEditTools(server: ToolRegistrar): void;
@@ -0,0 +1,297 @@
1
+ import { z } from 'zod';
2
+ import { iosBundleId } from '../../lib/package-name.js';
3
+ import * as appstore from '../../appstore/tools.js';
4
+ import * as appstoreProductReview from '../../appstore/product-review.js';
5
+ import * as appstoreProductLocalization from '../../appstore/product-localization.js';
6
+ import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
7
+ import { requireAppStoreCreds } from '../../helpers.js';
8
+ import { jsonResult, textResult } from '../../lib/mcp-response.js';
9
+ import { appleCreationResult } from '../../lib/store-create-result.js';
10
+ /** appstore_create_inapp_purchase … appstore_add_product_to_review */
11
+ export function registerProductTools(server) {
12
+ server.tool('appstore_create_inapp_purchase', [
13
+ 'App Store에 일회성 인앱 구매(IAP)를 생성 — CONSUMABLE (소비성) / NON_CONSUMABLE (비소비성).',
14
+ '생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
15
+ ].join(' '), {
16
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id, 숫자형)'),
17
+ productId: z
18
+ .string()
19
+ .describe('상품 ID (글로벌 unique 권장: 예 com.example.coins_100)'),
20
+ name: z.string().describe('상품 이름 (스토어 노출, 최대 30자)'),
21
+ price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $0.99 → 99, ₩1,100 → 1100)'),
22
+ currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
23
+ type: z
24
+ .enum(['consumable', 'non_consumable'])
25
+ .default('non_consumable')
26
+ .describe('IAP 유형 (소비성/비소비성)'),
27
+ extraRegions: z
28
+ .array(z.object({
29
+ currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
30
+ price: z.number().describe('가격 (최소 단위)'),
31
+ }))
32
+ .optional()
33
+ .describe('추가 지역별 명시 가격'),
34
+ bundleId: iosBundleId.optional().describe('번들 ID (appId 대신 사용 가능)'),
35
+ }, async (args) => {
36
+ const creds = requireAppStoreCreds();
37
+ const result = await createAppleOneTimePurchase({
38
+ appId: args.appId,
39
+ bundleId: args.bundleId,
40
+ productId: args.productId,
41
+ name: args.name,
42
+ price: args.price,
43
+ currency: args.currency,
44
+ type: args.type,
45
+ ...(args.extraRegions && { extraRegions: args.extraRegions }),
46
+ keyId: creds.keyId,
47
+ issuerId: creds.issuerId,
48
+ privateKey: creds.privateKey,
49
+ });
50
+ return appleCreationResult(result, args.appId, false);
51
+ });
52
+ server.tool('appstore_create_subscription', [
53
+ 'App Store에 자동 갱신 구독을 생성 — Subscription Group 자동 생성 포함.',
54
+ '생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
55
+ ].join(' '), {
56
+ appId: z.string().describe('App Store 앱 ID'),
57
+ productId: z.string().describe('구독 productId (예: com.example.premium.monthly)'),
58
+ name: z.string().describe('구독 이름 (스토어 노출)'),
59
+ price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $9.99 → 999, ₩9,900 → 9900)'),
60
+ currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
61
+ period: z
62
+ .enum(['monthly', 'yearly'])
63
+ .describe('구독 주기'),
64
+ extraRegions: z
65
+ .array(z.object({
66
+ currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
67
+ price: z.number().describe('가격 (최소 단위)'),
68
+ }))
69
+ .optional()
70
+ .describe('추가 지역별 명시 가격'),
71
+ bundleId: iosBundleId.optional().describe('번들 ID (appId 대신 사용 가능)'),
72
+ }, async (args) => {
73
+ const creds = requireAppStoreCreds();
74
+ const result = await createAppleSubscription({
75
+ appId: args.appId,
76
+ bundleId: args.bundleId,
77
+ productId: args.productId,
78
+ name: args.name,
79
+ price: args.price,
80
+ currency: args.currency,
81
+ period: args.period,
82
+ ...(args.extraRegions && { extraRegions: args.extraRegions }),
83
+ keyId: creds.keyId,
84
+ issuerId: creds.issuerId,
85
+ privateKey: creds.privateKey,
86
+ });
87
+ return appleCreationResult(result, args.appId, true);
88
+ });
89
+ server.tool('appstore_list_products', 'App Store의 모든 IAP 상품(구독 + 일회성) 통합 조회. productId / internalId / name / status / type 반환.', {
90
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
91
+ }, async ({ appId }) => {
92
+ const creds = requireAppStoreCreds();
93
+ const products = await listAppleProducts({
94
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
95
+ });
96
+ return jsonResult(products);
97
+ });
98
+ server.tool('appstore_update_product_review_note', '기존 App Store IAP/구독 상품의 App Review 노트를 수정. appstore_list_products의 productId/type을 사용.', {
99
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
100
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
101
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
102
+ reviewNote: z.string().max(4000).describe('Apple 심사용 노트 (4000자 이하, 빈 문자열은 초기화)'),
103
+ }, async ({ appId, productId, productType, reviewNote }) => {
104
+ const creds = requireAppStoreCreds();
105
+ const products = await listAppleProducts({
106
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
107
+ });
108
+ const product = products.find((item) => item.productId === productId && item.type === productType);
109
+ if (!product) {
110
+ return textResult(`상품을 찾을 수 없음: ${productId} (${productType})`);
111
+ }
112
+ const result = await appstoreProductReview.updateProductReviewNote({
113
+ internalId: product.internalId,
114
+ productType,
115
+ reviewNote,
116
+ });
117
+ return textResult([
118
+ '✓ App Review 노트 수정 완료',
119
+ `productId: ${productId}`,
120
+ `internalId: ${result.internalId}`,
121
+ result.state ? `state: ${result.state}` : '',
122
+ ].filter(Boolean));
123
+ });
124
+ server.tool('appstore_list_product_localizations', 'App Store IAP/구독 상품의 현지화(표시 이름·설명) 목록 조회. locale / name / description / state 반환.', {
125
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
126
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
127
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
128
+ }, async ({ appId, productId, productType }) => {
129
+ const creds = requireAppStoreCreds();
130
+ const products = await listAppleProducts({
131
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
132
+ });
133
+ const product = products.find((item) => item.productId === productId && item.type === productType);
134
+ if (!product) {
135
+ return textResult(`상품을 찾을 수 없음: ${productId} (${productType})`);
136
+ }
137
+ const localizations = await appstoreProductLocalization.listProductLocalizations({
138
+ internalId: product.internalId,
139
+ productType,
140
+ });
141
+ return jsonResult(localizations);
142
+ });
143
+ server.tool('appstore_update_product_localization', 'App Store IAP/구독 상품의 현지화(표시 이름·설명)를 로케일 단위로 upsert — 있으면 수정, 없으면 생성. ' +
144
+ '현지화가 비면 상품이 MISSING_METADATA 에서 안 풀려 심사에 넣을 수 없다 (리뷰 노트·스크린샷과는 별개 리소스). ' +
145
+ 'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. ' +
146
+ '길이 상한은 리소스마다 다르고 Apple 이 강제한다 — 구독은 실측으로 name 35 / description 55 다. ' +
147
+ '초과하면 Apple 이 "Max number of characters is (N)" 으로 실제 상한을 알려주므로 그 값에 맞춰 줄이면 된다. ' +
148
+ '⚠️ 심사 중인 상품은 현지화가 잠겨 UNMODIFIABLE 로 거부된다 — 결과를 기다리거나 철회 후 수정한다.', {
149
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
150
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
151
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
152
+ locale: z.string().describe('로케일 (예: ko, en-US, ja, zh-Hant)'),
153
+ // 실제 상한은 Apple 이 리소스별로 강제한다(구독 name 35 / description 55 실측).
154
+ // 여기서 좁게 잡으면 정상 문구를 클라이언트가 먼저 거부한다 — 실제로 45로 잡아
155
+ // 55자짜리 정상 설명을 막았다. 오타 수준만 걸러내는 넉넉한 상한만 둔다.
156
+ name: z.string().max(200).optional().describe('표시 이름. 새 로케일 생성 시 필수 (구독 실측 상한 35자)'),
157
+ description: z.string().max(500).optional().describe('설명. 생략하면 기존 값 유지 (구독 실측 상한 55자)'),
158
+ }, async ({ appId, productId, productType, locale, name, description }) => {
159
+ const creds = requireAppStoreCreds();
160
+ const products = await listAppleProducts({
161
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
162
+ });
163
+ const product = products.find((item) => item.productId === productId && item.type === productType);
164
+ if (!product) {
165
+ return textResult(`상품을 찾을 수 없음: ${productId} (${productType})`);
166
+ }
167
+ const result = await appstoreProductLocalization.upsertProductLocalization({
168
+ internalId: product.internalId,
169
+ productType,
170
+ locale,
171
+ name,
172
+ description,
173
+ });
174
+ return textResult([
175
+ `✓ 현지화 ${result.created ? '생성' : '수정'} 완료`,
176
+ `productId: ${productId}`,
177
+ `locale: ${result.locale}`,
178
+ result.name ? `name: ${result.name}` : '',
179
+ result.description ? `description: ${result.description}` : '',
180
+ result.state ? `state: ${result.state}` : '',
181
+ ].filter(Boolean));
182
+ });
183
+ server.tool('appstore_upload_product_review_screenshot', [
184
+ '기존 App Store IAP/구독 상품의 심사용 스크린샷을 reserve → upload → commit. 상품당 1장, 절대 파일 경로 필요.',
185
+ '이미 있으면 409 "Screenshot already exists" — 갈아끼우려면 replace: true (기존 것을 지우고 올린다).',
186
+ ].join(' '), {
187
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
188
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
189
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
190
+ filePath: z.string().describe('업로드할 PNG/JPG의 절대 파일 경로'),
191
+ replace: z.boolean().optional().describe('이미 스크린샷이 있으면 지우고 새로 올린다 (기본 false)'),
192
+ }, async ({ appId, productId, productType, filePath, replace }) => {
193
+ const creds = requireAppStoreCreds();
194
+ const products = await listAppleProducts({
195
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
196
+ });
197
+ const product = products.find((item) => item.productId === productId && item.type === productType);
198
+ if (!product) {
199
+ return textResult(`상품을 찾을 수 없음: ${productId} (${productType})`);
200
+ }
201
+ const result = await appstoreProductReview.uploadProductReviewScreenshot({
202
+ internalId: product.internalId,
203
+ productType,
204
+ filePath,
205
+ replace,
206
+ });
207
+ return textResult([
208
+ '✓ App Review 스크린샷 업로드 완료',
209
+ `productId: ${productId}`,
210
+ `internalId: ${result.internalId}`,
211
+ `screenshotId: ${result.id}`,
212
+ result.replacedId ? `교체됨 (이전 screenshotId: ${result.replacedId})` : '',
213
+ `file: ${result.fileName} (${result.fileSize} bytes)`,
214
+ result.state ? `state: ${result.state}` : '',
215
+ result.verified ? '✓ commit 후 조회 확인' : '⚠ commit은 성공했지만 후속 조회는 확인하지 못함',
216
+ ].filter(Boolean));
217
+ });
218
+ server.tool('appstore_add_product_to_review', 'App Store IAP/구독 상품을 **단독으로** 심사에 제출한다 — consumable/non_consumable 은 ' +
219
+ 'POST /v1/inAppPurchaseSubmissions, subscription 은 POST /v1/subscriptionSubmissions. ' +
220
+ '⚠️ 호출 즉시 제출된다 ("묶음에 담기"가 아니다 — 그런 공개 API 는 존재하지 않는다. ' +
221
+ 'reviewSubmissionItems 는 appStoreVersion 계열 관계만 받는다, 2026-07 실측). ' +
222
+ '이미 승인된 적 있는 상품의 변경분 제출용. **앱 첫 심사** 상품은 Apple 이 ' +
223
+ '"no pending version" 409 로 거부한다 — 그 경우 ASC 웹 버전 페이지의 ' +
224
+ '"앱 내 구입 및 구독" 섹션에서 담아 버전과 함께 제출해야 한다 (도구가 에러에 안내 첨부). ' +
225
+ '상품 상태가 READY_TO_SUBMIT 이어야 한다 (MISSING_METADATA 면 appstore_update_product_localization 먼저).', {
226
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
227
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
228
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
229
+ }, async ({ appId, productId, productType }) => {
230
+ const creds = requireAppStoreCreds();
231
+ const products = await listAppleProducts({
232
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
233
+ });
234
+ const product = products.find((item) => item.productId === productId && item.type === productType);
235
+ if (!product) {
236
+ return textResult(`상품을 찾을 수 없음: ${productId} (${productType})`);
237
+ }
238
+ const result = await appstore.addProductToReviewSubmission({
239
+ internalId: product.internalId,
240
+ productType,
241
+ });
242
+ return textResult([
243
+ '✓ 상품 심사 제출 완료 (Apple 심사 대기)',
244
+ `productId: ${productId}`,
245
+ `endpoint: ${result.endpoint}`,
246
+ result.submissionId ? `submissionId: ${result.submissionId}` : '',
247
+ '',
248
+ '앱 버전과는 별개의 단독 제출이다. 버전 제출은 appstore_submit_for_review.',
249
+ ].filter(Boolean));
250
+ });
251
+ }
252
+ /** appstore_update_product · appstore_delete_product */
253
+ export function registerProductEditTools(server) {
254
+ server.tool('appstore_update_product', 'App Store IAP 상품의 reference name 변경. productId / 유형은 변경 불가. ' +
255
+ '스토어에 보이는 표시 이름·설명은 appstore_update_product_localization 을 쓴다.', {
256
+ appId: z.string().optional().describe('App Store 앱 ID'),
257
+ bundleId: iosBundleId.optional().describe('번들 ID (appId 대신 사용 가능)'),
258
+ productId: z.string().describe('상품 ID'),
259
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
260
+ name: z.string().describe('새 reference name'),
261
+ }, async ({ appId, bundleId, productId, productType, name }) => {
262
+ if (!appId && !bundleId) {
263
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
264
+ }
265
+ const creds = requireAppStoreCreds();
266
+ const result = await updateAppleProduct({
267
+ appId, bundleId, productId, productType, name,
268
+ keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
269
+ });
270
+ if (!result.success) {
271
+ return textResult(`❌ 수정 실패: ${result.error}`);
272
+ }
273
+ return textResult(`✓ 수정 완료 (변경 필드: ${result.updated.join(', ') || 'none'})`);
274
+ });
275
+ server.tool('appstore_delete_product', '⚠️ 비가역. App Store IAP 상품 삭제. MISSING_METADATA / WAITING_FOR_REVIEW 상태만 가능 — 이미 승인(READY_FOR_SALE)된 상품은 Console에서 "Remove from sale" 해야 함.', {
276
+ appId: z.string().optional().describe('App Store 앱 ID'),
277
+ bundleId: iosBundleId.optional().describe('번들 ID (appId 대신 사용 가능)'),
278
+ productId: z.string().describe('상품 ID'),
279
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
280
+ }, async ({ appId, bundleId, productId, productType }) => {
281
+ if (!appId && !bundleId) {
282
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
283
+ }
284
+ const creds = requireAppStoreCreds();
285
+ const result = await deleteAppleProduct({
286
+ appId, bundleId, productId, productType,
287
+ keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
288
+ });
289
+ if (!result.success) {
290
+ const hint = result.errorType === 'CANNOT_DELETE'
291
+ ? '\n승인된 상품은 API 삭제 불가 — App Store Connect → 상품 → "Remove from sale"'
292
+ : '';
293
+ return textResult(`❌ 삭제 실패: ${result.error}${hint}`);
294
+ }
295
+ return textResult(`✓ ${productId} 삭제 완료`);
296
+ });
297
+ }
@@ -0,0 +1,6 @@
1
+ /** 분석 · 매출 — Analytics 주간 인사이트, Sales and Trends, 재무(정산) 리포트. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_get_weekly_insight */
4
+ export declare function registerWeeklyInsightTool(server: ToolRegistrar): void;
5
+ /** appstore_get_sales_report · appstore_get_finance_report */
6
+ export declare function registerSalesReportTools(server: ToolRegistrar): void;
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod';
2
+ import * as appstoreSales from '../../appstore/sales.js';
3
+ import { getWeeklyInsight } from '../../appstore/analytics.js';
4
+ import { jsonResult } from '../../lib/mcp-response.js';
5
+ /** appstore_get_weekly_insight */
6
+ export function registerWeeklyInsightTool(server) {
7
+ server.tool('appstore_get_weekly_insight', [
8
+ 'App Store Connect Analytics의 표준 주간 Engagement·Downloads·Purchases 리포트를 내려받아 지난 두 기간을 비교합니다.',
9
+ '제품 페이지·획득·수익화 중 가장 크게 악화된 한 영역과 근거 지표, 실행할 개선안 하나를 반환합니다.',
10
+ 'ONGOING report request가 없으면 기본은 생성하지 않습니다. createIfMissing=true와 confirmCreate=true를 함께 줘야 생성하며 최초 데이터는 24~48시간 뒤 제공됩니다.',
11
+ ].join(' '), {
12
+ appId: z.string().describe('App Store 앱 ID'),
13
+ createIfMissing: z.boolean().optional().describe('ONGOING Analytics report request가 없을 때 생성 준비'),
14
+ confirmCreate: z.boolean().optional().describe('지속 Analytics report request 생성 명시 확인'),
15
+ }, async (args) => jsonResult(await getWeeklyInsight(args)));
16
+ }
17
+ /** appstore_get_sales_report · appstore_get_finance_report */
18
+ export function registerSalesReportTools(server) {
19
+ server.tool('appstore_get_sales_report', [
20
+ 'Sales and Trends 리포트 = **실매출의 기준선** — GET /v1/salesReports (gzip TSV 를 파싱해 돌려준다).',
21
+ '분석 이벤트(GA4 in_app_purchase 등)로 매출을 세면 안 되는 이유가 여기 있다:',
22
+ 'TestFlight·Xcode 설치의 결제는 **sandbox 라 청구가 0원인데도** 클라이언트에는 성공한 결제로 보여',
23
+ '이벤트가 그대로 나간다. 이 리포트에는 sandbox 가 애초에 들어오지 않으므로, 둘을 비교하면',
24
+ '"진짜 돈이 들어온 건수"가 곧바로 갈린다.',
25
+ '⚠️ **Developer Proceeds 는 1개당 금액이다** — 총액은 units 를 곱해야 한다(이 도구는 곱해서 준다).',
26
+ '⚠️ 환불은 units 가 **음수**로 들어와 합계에서 상쇄된다. 즉 합계는 순매출이다.',
27
+ '⚠️ 데이터가 없는 날짜는 Apple 이 404 를 주므로 datesWithoutData 로 따로 돌려준다 —',
28
+ '"매출 0" 과 "리포트 미생성/설정 오류"를 섞지 말 것. 당일치는 보통 아직 없다.',
29
+ 'vendorNumber 는 ~/.mimi-seed/appstore.json 에 저장해두면 생략 가능.',
30
+ '⚠️ **리포트는 요구 롤이 다르다** — Admin/Finance/Sales and Reports 중 하나여야 하고,',
31
+ '배포에 흔히 쓰는 App Manager 키는 여기서만 403 이 난다. 발급된 키의 롤은 수정할 수 없으므로,',
32
+ '읽기 전용 Finance 키를 발급해 appstore.json 의 **reportsKey** 에 넣으면 배포 키를 건드리지 않아도 된다.',
33
+ ].join(' '), {
34
+ startDate: z.string().describe('시작일 YYYY-MM-DD (DAILY 가 아니면 이 값이 곧 reportDate)'),
35
+ endDate: z
36
+ .string()
37
+ .optional()
38
+ .describe('종료일 YYYY-MM-DD (포함). DAILY 에서만 의미가 있고 최대 62일'),
39
+ frequency: z
40
+ .enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'])
41
+ .optional()
42
+ .describe('집계 단위 (기본 DAILY)'),
43
+ reportType: z
44
+ .string()
45
+ .optional()
46
+ .describe('기본 SALES. 구독 분석은 SUBSCRIPTION / SUBSCRIPTION_EVENT / SUBSCRIBER'),
47
+ reportSubType: z.string().optional().describe('기본 SUMMARY. 상세는 DETAILED'),
48
+ version: z
49
+ .string()
50
+ .optional()
51
+ .describe('리포트 버전 (기본 1_0). SUBSCRIPTION 계열은 1_3 등 다른 버전을 요구할 수 있다'),
52
+ vendorNumber: z
53
+ .string()
54
+ .optional()
55
+ .describe('판매자 번호. 생략 시 ~/.mimi-seed/appstore.json 의 vendorNumber 사용'),
56
+ }, async (args) => jsonResult(await appstoreSales.getSalesReport(args)));
57
+ server.tool('appstore_get_finance_report', [
58
+ '재무(정산) 리포트 — GET /v1/financeReports. 실제로 지급되는 금액 기준이라',
59
+ 'Sales and Trends(판매 시점 집계)와 숫자가 다를 수 있다.',
60
+ '⚠️ **reportDate 는 Apple 회계월이다** — 달력월과 어긋난다(회계연도가 9월 말에 시작).',
61
+ '요청한 달과 다른 기간이 돌아오면 버그가 아니라 이것이다. 매출 건수를 세는 목적이면',
62
+ 'appstore_get_sales_report(달력 날짜 기준)를 쓰는 편이 낫다.',
63
+ 'regionCode 는 ZZ(전 지역 통합)가 기본이고, FINANCE_DETAIL 은 보통 Z1 을 쓴다.',
64
+ '컬럼 구성이 리포트마다 달라 파싱한 행을 그대로 돌려준다.',
65
+ ].join(' '), {
66
+ reportDate: z.string().describe('YYYY-MM (Apple 회계월)'),
67
+ regionCode: z.string().optional().describe('지역 코드 (기본 ZZ = 전 지역 통합)'),
68
+ reportType: z
69
+ .enum(['FINANCIAL', 'FINANCE_DETAIL'])
70
+ .optional()
71
+ .describe('기본 FINANCIAL'),
72
+ vendorNumber: z
73
+ .string()
74
+ .optional()
75
+ .describe('판매자 번호. 생략 시 ~/.mimi-seed/appstore.json 의 vendorNumber 사용'),
76
+ }, async (args) => jsonResult(await appstoreSales.getFinanceReport(args)));
77
+ }
@@ -0,0 +1,6 @@
1
+ /** 앱 버전 심사 — 제출 묶음(reviewSubmissions) 진단 · 항목 조작, 배포 플랜, 제출 · 철회. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_list_review_submissions · appstore_remove_review_submission_item · appstore_add_version_to_review_submission */
4
+ export declare function registerReviewSubmissionItemTools(server: ToolRegistrar): void;
5
+ /** appstore_plan_release · appstore_submit_for_review · appstore_cancel_review */
6
+ export declare function registerSubmitForReviewTools(server: ToolRegistrar): void;
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ import * as appstore from '../../appstore/tools.js';
3
+ import { buildAppStoreReleasePlan } from '../../checks/plan.js';
4
+ import { textResult } from '../../lib/mcp-response.js';
5
+ import { reviewSubmissionsText, submitForReviewDryRunText } from '../../appstore/messages.js';
6
+ /** appstore_list_review_submissions · appstore_remove_review_submission_item · appstore_add_version_to_review_submission */
7
+ export function registerReviewSubmissionItemTools(server) {
8
+ server.tool('appstore_list_review_submissions', 'App Store 심사 제출 묶음(reviewSubmissions) + 내부 항목 조회 — 읽기 전용. ' +
9
+ '각 묶음의 state(READY_FOR_REVIEW=초안 / WAITING_FOR_REVIEW=큐 / UNRESOLVED_ISSUES=거절 미해결 / COMPLETE) 와 ' +
10
+ '항목별 state·연결 리소스(appStoreVersion 이면 versionString 포함)를 보여준다. ' +
11
+ '재제출이 "appStoreVersions ... is not in valid state" 로 막힐 때 첫 번째로 볼 것 — ' +
12
+ '진범은 대개 UNRESOLVED_ISSUES 묶음이 버전을 REJECTED 항목으로 물고 있는 것이다 ' +
13
+ '(버전 자체는 PREPARE_FOR_SUBMISSION 으로 멀쩡해 보인다, 2026-07 실측). ' +
14
+ '해제는 appstore_remove_review_submission_item.', {
15
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
16
+ platform: z.enum(['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS']).default('IOS').optional()
17
+ .describe('플랫폼 (기본 IOS)'),
18
+ limit: z.number().int().min(1).max(20).optional().describe('조회할 묶음 수 (기본 5, 최신순)'),
19
+ }, async ({ appId, platform, limit }) => {
20
+ const result = await appstore.listReviewSubmissions({ appId, platform, limit });
21
+ return textResult(reviewSubmissionsText(result));
22
+ });
23
+ server.tool('appstore_remove_review_submission_item', '심사 제출 묶음에서 항목을 제거한다 (removed=true PATCH) — ASC 웹 "재제출" 버튼이 내부적으로 하는 동작. ' +
24
+ '거절된 옛 묶음(UNRESOLVED_ISSUES)이 버전을 물고 있어 재제출이 ENTITY_STATE_INVALID 로 막힐 때 해제용. ' +
25
+ '항목이 풀리면 옛 묶음은 COMPLETE 로 정리된다. itemId 는 appstore_list_review_submissions 결과. ' +
26
+ '(appstore_submit_for_review 는 이 해제를 자동으로 시도한다 — 수동 개입이 필요할 때만 직접 호출.)', {
27
+ itemId: z.string().describe('reviewSubmissionItem ID (appstore_list_review_submissions 결과)'),
28
+ }, async ({ itemId }) => {
29
+ const result = await appstore.removeReviewSubmissionItem(itemId);
30
+ return textResult([
31
+ '✓ 묶음에서 항목 제거됨',
32
+ `itemId: ${result.itemId}`,
33
+ result.state ? `state: ${result.state}` : '',
34
+ '항목이 버전이었다면 이제 다른 묶음에 붙일 수 있다 (appstore_submit_for_review).',
35
+ ].filter(Boolean));
36
+ });
37
+ server.tool('appstore_add_version_to_review_submission', '이미 존재하는 심사 묶음에 앱 버전을 항목으로 추가한다 (POST /reviewSubmissionItems). ' +
38
+ '⚠️ 앱 첫 심사 필수 절차 — 웹에서 IAP/구독을 담으면 "상품만 든 묶음"이 새로 생기는데, ' +
39
+ '그대로 제출하면 409 "an appStoreVersions must be included in this review submission" 로 막힌다. ' +
40
+ 'reviewSubmissionItems 는 상품 관계는 거부하지만 appStoreVersion 관계는 받으므로, 버전을 이 묶음으로 옮기면 된다. ' +
41
+ '버전이 다른 묶음에 물려 있으면 먼저 풀 것: 미제출 묶음이면 appstore_remove_review_submission_item, ' +
42
+ '제출된 묶음이면 항목 제거가 막히므로 appstore_cancel_review 로 묶음째 취소. ' +
43
+ '추가 후 appstore_submit_for_review 로 제출하면 버전+상품이 한 묶음으로 나간다.', {
44
+ submissionId: z.string().describe('대상 reviewSubmission ID (appstore_list_review_submissions 결과)'),
45
+ versionId: z.string().describe('추가할 App Store 버전 ID (appstore_list_versions 결과)'),
46
+ }, async ({ submissionId, versionId }) => {
47
+ const result = await appstore.addVersionToReviewSubmission({ submissionId, versionId });
48
+ return textResult([
49
+ '✓ 묶음에 앱 버전 추가됨',
50
+ `submissionId: ${result.submissionId}`,
51
+ `versionId: ${result.versionId}`,
52
+ result.itemId ? `itemId: ${result.itemId}` : '',
53
+ `현재 묶음 항목 수: ${result.itemCount}개`,
54
+ '제출 전 항목 수를 확인할 것 — 첫 심사라면 상품들이 함께 들어 있어야 한다.',
55
+ ].filter(Boolean));
56
+ });
57
+ }
58
+ /** appstore_plan_release · appstore_submit_for_review · appstore_cancel_review */
59
+ export function registerSubmitForReviewTools(server) {
60
+ server.tool('appstore_plan_release', [
61
+ 'App Store 배포 플랜 — 편집 가능한 버전/빌드 attach/메타/스크린샷/정책 위험을 한 번에 점검하고',
62
+ '체크리스트로 반환합니다 (read-only).',
63
+ '⚠️ AI 호출자 지시: 응답의 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
64
+ '사용자 동의 후 단계별로 기존 도구(appstore_update_localization, appstore_update_whats_new,',
65
+ 'appstore_submit_for_review 등)를 호출하세요. submit_for_review는 비가역이므로 반드시 명시 동의 필요.',
66
+ ].join(' '), {
67
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id)'),
68
+ versionString: z.string().optional().describe('대상 버전 (예: 1.3.0). 미지정 시 가장 최근 편집 가능 버전'),
69
+ }, async ({ appId, versionString }) => {
70
+ const text = await buildAppStoreReleasePlan({ appId, versionString });
71
+ return textResult(text);
72
+ });
73
+ server.tool('appstore_submit_for_review', [
74
+ 'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
75
+ '내부 흐름: POST /reviewSubmissions(또는 CREATED 상태 재사용) → POST /reviewSubmissionItems(version attach) → PATCH submitted=true.',
76
+ 'appId와 platform은 versionId에서 자동 조회 — 별도 입력 불필요.',
77
+ '⚠️ 비가역 작업: 제출 후엔 Apple 심사가 시작되며, 메타데이터/스크린샷/빌드를 더 못 바꿈 (REJECTED/METADATA_REJECTED 시 다시 편집 가능).',
78
+ '안전 가드: confirm 생략/false 시 dry-run preview 만 반환 (versionString·빌드·whatsNew 발췌). 실제 제출은 confirm: true 로 재호출.',
79
+ '사전 조건: 버전이 PREPARE_FOR_SUBMISSION 또는 DEVELOPER_REJECTED 상태, 빌드 attached, 모든 필수 메타데이터 채워짐.',
80
+ '거절된 옛 묶음(UNRESOLVED_ISSUES)이 버전을 물고 있어 attach 가 막히면 자동으로 항목을 해제(removed=true)하고 재시도한다 — 진단은 appstore_list_review_submissions.',
81
+ 'appstore_check_submission_risks로 사전 점검 권장.',
82
+ ].join(' '), {
83
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
84
+ confirm: z.boolean().optional().describe('true 명시 시에만 실제 심사 제출. 생략/false 면 dry-run preview 만 반환 (비가역 사고 차단).'),
85
+ }, async ({ versionId, confirm }) => {
86
+ if (!confirm) {
87
+ // ── dry-run preview — versionString·빌드·whatsNew 발췌를 사용자에게 보여주고 재호출 유도.
88
+ const preview = await appstore.buildSubmitForReviewPreview(versionId);
89
+ return textResult(submitForReviewDryRunText(preview));
90
+ }
91
+ const result = await appstore.submitVersionForReview(versionId);
92
+ return textResult(`✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}`);
93
+ });
94
+ server.tool('appstore_cancel_review', [
95
+ 'App Store 심사 제출을 철회 — WAITING_FOR_REVIEW 상태에서만 가능.',
96
+ 'PATCH attributes.canceled=true → state 가 CANCELING 으로 바뀌고 수십 초 뒤 COMPLETE(항목 REMOVED), 버전은 편집 가능 상태로 복귀해 메타데이터/빌드 수정 가능.',
97
+ 'CANCELING 은 비동기라 호출 직후엔 아직 COMPLETE 가 아니다 — 이어서 작업하려면 상태를 폴링할 것.',
98
+ '⚠️ IN_REVIEW 이상이면 Apple API가 409로 거부함 — 이 경우 App Store Connect 웹에서 직접 처리하거나 심사 결과를 기다려야 함.',
99
+ '제출된 묶음에서 항목만 빼내는 것(removed)은 막히므로, 버전을 다른 묶음으로 옮기려면 이 도구로 묶음째 취소한다.',
100
+ '철회 후 수정 완료 시 appstore_submit_for_review로 재제출 가능.',
101
+ ].join(' '), {
102
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
103
+ }, async ({ versionId }) => {
104
+ const result = await appstore.cancelVersionReview(versionId);
105
+ return textResult([
106
+ `✅ 심사 철회 완료`,
107
+ ` submissionId: ${result.submissionId}`,
108
+ ` ${result.previousState} → ${result.newState}`,
109
+ ` 버전 ${result.versionId}이(가) PREPARE_FOR_SUBMISSION 상태로 복귀됨.`,
110
+ ` 메타데이터/빌드 수정 후 appstore_submit_for_review로 재제출 가능.`,
111
+ ]);
112
+ });
113
+ }