@yoonion/mimi-seed-mcp 0.13.17 → 0.13.18

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.
package/README.md CHANGED
@@ -78,8 +78,8 @@ export ANTHROPIC_API_KEY=sk-ant-...
78
78
 
79
79
  | 영역 | 도구 수 | 주요 도구 |
80
80
  |------|---------|-----------|
81
- | App Store Connect | 58 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
82
- | Google Play | 33 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
81
+ | App Store Connect | 61 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
82
+ | Google Play | 37 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
83
83
  | Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
84
84
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
85
85
  | CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
@@ -58,6 +58,8 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
58
58
  | Pre-submission declarations (both stores) | `select:appstore_get_age_rating,appstore_update_age_rating,appstore_declare_encryption,appstore_get_availability,appstore_set_territory_availability,playstore_upload_data_safety` |
59
59
  | TestFlight external testing | `select:appstore_beta_status,appstore_update_beta_review_detail,appstore_update_beta_test_info,appstore_update_whats_to_test,appstore_submit_beta_review,appstore_set_beta_group_build,appstore_add_beta_testers,appstore_notify_beta_testers,appstore_list_beta_groups,appstore_list_builds` |
60
60
  | App Store review submission (the bundle) | `select:appstore_list_review_submissions,appstore_add_version_to_review_submission,appstore_remove_review_submission_item,appstore_update_version_string,appstore_cancel_review` |
61
+ | App Store preview videos | `select:appstore_list_previews,appstore_upload_preview,appstore_delete_preview,appstore_get_metadata` |
62
+ | Play post-release recovery | `select:playstore_list_recovery_actions,playstore_create_recovery_action,playstore_deploy_recovery_action,playstore_cancel_recovery_action,playstore_list_tracks` |
61
63
  | App Store screenshots | `select:appstore_list_app_info_localizations,appstore_get_metadata,appstore_list_screenshots,appstore_upload_screenshot,appstore_delete_screenshot,appstore_delete_screenshot_set,screenshot_validate` |
62
64
  | App Store app info + review notes | `select:appstore_get_app_info,appstore_update_app_info_localization,appstore_create_app_info_localization,appstore_update_localization,appstore_get_review_notes,appstore_update_review_notes` |
63
65
  | App Store reviews | `select:appstore_list_reviews,appstore_reply_review,generate_review_reply` |
@@ -0,0 +1,41 @@
1
+ export interface PreviewRow {
2
+ id: string;
3
+ fileName?: string;
4
+ fileSize?: number;
5
+ state?: string;
6
+ previewFrameTimeCode?: string;
7
+ videoUrl?: string;
8
+ }
9
+ export interface PreviewSetRow {
10
+ id: string;
11
+ previewType?: string;
12
+ previews: PreviewRow[];
13
+ }
14
+ /** 로케일의 미리보기 세트 + 각 동영상 상태. 업로드 후 인코딩 확인도 여기서 한다. */
15
+ export declare function listPreviewSets(localizationId: string): Promise<PreviewSetRow[]>;
16
+ /**
17
+ * 미리보기 동영상 업로드. `previewFrameTimeCode` 는 포스터 프레임 시각 (HH:MM:SS:FF).
18
+ *
19
+ * Apple 제약(업로드 후 인코딩 단계에서 거부될 수 있는 것들):
20
+ * 길이 15~30초 · previewType 별 해상도 고정 · 로케일·타입당 최대 3개.
21
+ */
22
+ export declare function uploadPreview(args: {
23
+ localizationId: string;
24
+ previewType: string;
25
+ filePath: string;
26
+ previewFrameTimeCode?: string;
27
+ }): Promise<{
28
+ id: string;
29
+ fileName: string;
30
+ fileSize: number;
31
+ previewType: string;
32
+ state?: string;
33
+ }>;
34
+ export declare function deletePreview(previewId: string): Promise<{
35
+ ok: true;
36
+ id: string;
37
+ }>;
38
+ export declare function deletePreviewSet(setId: string): Promise<{
39
+ ok: true;
40
+ id: string;
41
+ }>;
@@ -0,0 +1,190 @@
1
+ // App Store 제품 페이지 미리보기 **동영상** (appPreviewSets / appPreviews).
2
+ //
3
+ // 업로드 절차는 스크린샷과 같은 4단계다 (screenshots.ts 와 같은 기계):
4
+ // 1. set 확보 POST /appPreviewSets (previewType 별로 하나)
5
+ // 2. 예약 POST /appPreviews → uploadOperations[]
6
+ // 3. 업로드 PUT 각 operation URL (offset/length 로 조각내서)
7
+ // 4. 커밋 PATCH /appPreviews/{id} { uploaded, sourceFileChecksum }
8
+ //
9
+ // 스크린샷과 다른 점: 커밋 뒤 Apple 이 **인코딩**을 돌린다. 그래서 업로드 성공 ≠ 노출 가능이고,
10
+ // assetDeliveryState 를 따로 확인해야 한다. previewFrameTimeCode 로 포스터 프레임도 고른다.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { getAuthHeaders } from './auth.js';
15
+ const BASE = 'https://api.appstoreconnect.apple.com/v1';
16
+ async function req(pathOrUrl, init = {}) {
17
+ const headers = await getAuthHeaders();
18
+ if (!headers) {
19
+ throw new Error([
20
+ '❌ App Store Connect 인증이 필요해.',
21
+ '',
22
+ '터미널에서 실행:',
23
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
24
+ ].join('\n'));
25
+ }
26
+ const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${BASE}${pathOrUrl}`;
27
+ const res = await fetch(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } });
28
+ if (!res.ok) {
29
+ const body = await res.text();
30
+ throw new Error(`App Store API ${res.status} ${init.method ?? 'GET'} ${pathOrUrl}: ${body}`);
31
+ }
32
+ const text = await res.text();
33
+ return (text ? JSON.parse(text) : null);
34
+ }
35
+ /** 로케일의 미리보기 세트 + 각 동영상 상태. 업로드 후 인코딩 확인도 여기서 한다. */
36
+ export async function listPreviewSets(localizationId) {
37
+ const data = await req(`/appStoreVersionLocalizations/${localizationId}/appPreviewSets` +
38
+ `?include=appPreviews` +
39
+ `&fields[appPreviewSets]=previewType,appPreviews` +
40
+ `&fields[appPreviews]=fileName,fileSize,assetDeliveryState,previewFrameTimeCode,videoUrl`);
41
+ const included = data?.included ?? [];
42
+ return (data?.data ?? []).map((s) => {
43
+ const ids = (s.relationships?.appPreviews?.data ?? []).map((p) => p.id);
44
+ return {
45
+ id: s.id,
46
+ previewType: s.attributes?.previewType,
47
+ previews: included
48
+ .filter((i) => i.type === 'appPreviews' && ids.includes(i.id))
49
+ .map((i) => ({
50
+ id: i.id,
51
+ fileName: i.attributes?.fileName,
52
+ fileSize: i.attributes?.fileSize,
53
+ state: i.attributes?.assetDeliveryState?.state,
54
+ previewFrameTimeCode: i.attributes?.previewFrameTimeCode,
55
+ videoUrl: i.attributes?.videoUrl,
56
+ })),
57
+ };
58
+ });
59
+ }
60
+ async function ensurePreviewSet(localizationId, previewType) {
61
+ const sets = await listPreviewSets(localizationId);
62
+ const hit = sets.find((s) => s.previewType === previewType);
63
+ if (hit)
64
+ return hit.id;
65
+ const created = await req('/appPreviewSets', {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({
69
+ data: {
70
+ type: 'appPreviewSets',
71
+ attributes: { previewType },
72
+ relationships: {
73
+ appStoreVersionLocalization: {
74
+ data: { type: 'appStoreVersionLocalizations', id: localizationId },
75
+ },
76
+ },
77
+ },
78
+ }),
79
+ });
80
+ return created.data.id;
81
+ }
82
+ async function uploadChunks(absPath, ops) {
83
+ const fd = fs.openSync(absPath, 'r');
84
+ try {
85
+ for (const op of ops) {
86
+ // 파일 전체를 메모리에 올리지 않는다 — 동영상은 수백 MB 가 될 수 있다.
87
+ const chunk = Buffer.alloc(op.length);
88
+ fs.readSync(fd, chunk, 0, op.length, op.offset);
89
+ const headers = {};
90
+ for (const h of op.requestHeaders ?? [])
91
+ headers[h.name] = h.value;
92
+ const res = await fetch(op.url, { method: op.method ?? 'PUT', headers, body: chunk });
93
+ if (!res.ok) {
94
+ throw new Error(`미리보기 업로드 실패 (offset ${op.offset}): ${res.status} ${await res.text()}`);
95
+ }
96
+ }
97
+ }
98
+ finally {
99
+ fs.closeSync(fd);
100
+ }
101
+ }
102
+ /** 확장자로 추정한 mimeType. Apple 은 없어도 받지만 있으면 처리 실패가 줄어든다. */
103
+ function guessMimeType(fileName) {
104
+ const ext = path.extname(fileName).toLowerCase();
105
+ if (ext === '.mp4')
106
+ return 'video/mp4';
107
+ if (ext === '.mov')
108
+ return 'video/quicktime';
109
+ if (ext === '.m4v')
110
+ return 'video/x-m4v';
111
+ return undefined;
112
+ }
113
+ /**
114
+ * 미리보기 동영상 업로드. `previewFrameTimeCode` 는 포스터 프레임 시각 (HH:MM:SS:FF).
115
+ *
116
+ * Apple 제약(업로드 후 인코딩 단계에서 거부될 수 있는 것들):
117
+ * 길이 15~30초 · previewType 별 해상도 고정 · 로케일·타입당 최대 3개.
118
+ */
119
+ export async function uploadPreview(args) {
120
+ const { localizationId, previewType, filePath, previewFrameTimeCode } = args;
121
+ if (!path.isAbsolute(filePath))
122
+ throw new Error(`filePath 는 절대경로여야 한다: ${filePath}`);
123
+ if (!fs.existsSync(filePath))
124
+ throw new Error(`파일이 존재하지 않는다: ${filePath}`);
125
+ const fileName = path.basename(filePath);
126
+ const fileSize = fs.statSync(filePath).size;
127
+ if (fileSize === 0)
128
+ throw new Error('빈 파일이다.');
129
+ const setId = await ensurePreviewSet(localizationId, previewType);
130
+ const mimeType = guessMimeType(fileName);
131
+ const reserved = await req('/appPreviews', {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({
135
+ data: {
136
+ type: 'appPreviews',
137
+ attributes: {
138
+ fileName,
139
+ fileSize,
140
+ ...(mimeType ? { mimeType } : {}),
141
+ ...(previewFrameTimeCode ? { previewFrameTimeCode } : {}),
142
+ },
143
+ relationships: { appPreviewSet: { data: { type: 'appPreviewSets', id: setId } } },
144
+ },
145
+ }),
146
+ });
147
+ const previewId = reserved.data.id;
148
+ const ops = reserved.data.attributes?.uploadOperations ?? [];
149
+ if (ops.length === 0)
150
+ throw new Error('uploadOperations 가 비어 있다 — Apple 응답 형식 확인 필요.');
151
+ await uploadChunks(filePath, ops);
152
+ // 체크섬은 전체 파일 기준이라 스트리밍으로 계산한다 (메모리에 다 올리지 않는다).
153
+ const md5 = await new Promise((resolve, reject) => {
154
+ const hash = crypto.createHash('md5');
155
+ fs.createReadStream(filePath)
156
+ .on('data', (c) => hash.update(c))
157
+ .on('end', () => resolve(hash.digest('hex')))
158
+ .on('error', reject);
159
+ });
160
+ const committed = await req(`/appPreviews/${previewId}`, {
161
+ method: 'PATCH',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({
164
+ data: {
165
+ type: 'appPreviews',
166
+ id: previewId,
167
+ attributes: {
168
+ uploaded: true,
169
+ sourceFileChecksum: md5,
170
+ ...(previewFrameTimeCode ? { previewFrameTimeCode } : {}),
171
+ },
172
+ },
173
+ }),
174
+ });
175
+ return {
176
+ id: previewId,
177
+ fileName,
178
+ fileSize,
179
+ previewType,
180
+ state: committed?.data?.attributes?.assetDeliveryState?.state,
181
+ };
182
+ }
183
+ export async function deletePreview(previewId) {
184
+ await req(`/appPreviews/${previewId}`, { method: 'DELETE' });
185
+ return { ok: true, id: previewId };
186
+ }
187
+ export async function deletePreviewSet(setId) {
188
+ await req(`/appPreviewSets/${setId}`, { method: 'DELETE' });
189
+ return { ok: true, id: setId };
190
+ }
@@ -168,6 +168,44 @@ export declare function updatePurchaseOptionState(auth: OAuth2Client | JWT, pack
168
168
  state: string | undefined;
169
169
  }[];
170
170
  }>;
171
+ export interface RecoveryTargeting {
172
+ /** 전체 사용자 대상. versionCodes / 범위와 함께 쓸 수 없다. */
173
+ allUsers?: boolean;
174
+ /** 특정 버전 코드들 */
175
+ versionCodes?: string[];
176
+ /** 버전 코드 범위 (start~end) */
177
+ versionRange?: {
178
+ start: string;
179
+ end: string;
180
+ };
181
+ /** 지역 코드 (예: KR, US) */
182
+ regions?: string[];
183
+ /** Android SDK 레벨 */
184
+ sdkLevels?: string[];
185
+ }
186
+ export declare function buildTargeting(t: RecoveryTargeting): Record<string, unknown>;
187
+ export declare function listRecoveryActions(auth: OAuth2Client | JWT, packageName: string, versionCode?: string): Promise<{
188
+ id: string | null | undefined;
189
+ status: string | null | undefined;
190
+ createTime: string | null | undefined;
191
+ deployTime: string | null | undefined;
192
+ cancelTime: string | null | undefined;
193
+ targeting: import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$Targeting | undefined;
194
+ }[]>;
195
+ /** DRAFT 상태로만 만든다 — 실제 배포는 deployRecoveryAction 이 별도로 한다. */
196
+ export declare function createRecoveryAction(auth: OAuth2Client | JWT, packageName: string, targeting: RecoveryTargeting): Promise<{
197
+ id: string | null | undefined;
198
+ status: string | null | undefined;
199
+ targeting: import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$Targeting | undefined;
200
+ }>;
201
+ export declare function deployRecoveryAction(auth: OAuth2Client | JWT, packageName: string, appRecoveryId: string): Promise<{
202
+ appRecoveryId: string;
203
+ deployed: boolean;
204
+ }>;
205
+ export declare function cancelRecoveryAction(auth: OAuth2Client | JWT, packageName: string, appRecoveryId: string): Promise<{
206
+ appRecoveryId: string;
207
+ cancelled: boolean;
208
+ }>;
171
209
  export declare function uploadDataSafety(auth: OAuth2Client | JWT, packageName: string, safetyLabelsCsv: string): Promise<{
172
210
  packageName: string;
173
211
  bytes: number;
@@ -650,9 +650,79 @@ export async function updatePurchaseOptionState(auth, packageName, productId, pu
650
650
  })));
651
651
  return { productId, purchaseOptionId, action, states };
652
652
  }
653
- // ─── 인앱 상품 / 구독 생성은 @onesub/providers로 위임 ───
654
- // IAP·구독 CRUD는 onesub의 도메인 (결제 영역). 이 파일에는 메타·이미지·릴리스·리뷰만 남김.
655
- // 옛 createOnetimeProduct / createSubscription 구현은 onesub 위임으로 대체됐어 (index.ts 참고).
653
+ export function buildTargeting(t) {
654
+ const targeting = {};
655
+ if (t.allUsers)
656
+ targeting.allUsers = { isAllUsersRequested: true };
657
+ if (t.versionCodes?.length)
658
+ targeting.versionList = { versionCodes: t.versionCodes };
659
+ if (t.versionRange) {
660
+ targeting.versionRange = {
661
+ versionCodeStart: t.versionRange.start,
662
+ versionCodeEnd: t.versionRange.end,
663
+ };
664
+ }
665
+ if (t.regions?.length)
666
+ targeting.regions = { regionCode: t.regions };
667
+ if (t.sdkLevels?.length)
668
+ targeting.androidSdks = { sdkLevels: t.sdkLevels };
669
+ if (Object.keys(targeting).length === 0) {
670
+ throw new Error('대상(targeting)이 비어 있다 — allUsers / versionCodes / versionRange 중 하나는 필요하다.');
671
+ }
672
+ if (t.allUsers && (t.versionCodes?.length || t.versionRange)) {
673
+ throw new Error('allUsers 와 versionCodes/versionRange 는 함께 쓸 수 없다.');
674
+ }
675
+ return targeting;
676
+ }
677
+ export async function listRecoveryActions(auth, packageName, versionCode) {
678
+ const res = await publisher().apprecovery.list({
679
+ auth,
680
+ packageName,
681
+ ...(versionCode ? { versionCode } : {}),
682
+ });
683
+ return (res.data.recoveryActions ?? []).map((a) => ({
684
+ id: a.appRecoveryId,
685
+ status: a.status,
686
+ createTime: a.createTime,
687
+ deployTime: a.deployTime,
688
+ cancelTime: a.cancelTime,
689
+ targeting: a.targeting,
690
+ }));
691
+ }
692
+ /** DRAFT 상태로만 만든다 — 실제 배포는 deployRecoveryAction 이 별도로 한다. */
693
+ export async function createRecoveryAction(auth, packageName, targeting) {
694
+ const res = await publisher().apprecovery.create({
695
+ auth,
696
+ packageName,
697
+ requestBody: {
698
+ remoteInAppUpdate: { isRemoteInAppUpdateRequested: true },
699
+ targeting: buildTargeting(targeting),
700
+ },
701
+ });
702
+ return {
703
+ id: res.data.appRecoveryId,
704
+ status: res.data.status,
705
+ targeting: res.data.targeting,
706
+ };
707
+ }
708
+ export async function deployRecoveryAction(auth, packageName, appRecoveryId) {
709
+ await publisher().apprecovery.deploy({
710
+ auth,
711
+ packageName,
712
+ appRecoveryId,
713
+ requestBody: {},
714
+ });
715
+ return { appRecoveryId, deployed: true };
716
+ }
717
+ export async function cancelRecoveryAction(auth, packageName, appRecoveryId) {
718
+ await publisher().apprecovery.cancel({
719
+ auth,
720
+ packageName,
721
+ appRecoveryId,
722
+ requestBody: {},
723
+ });
724
+ return { appRecoveryId, cancelled: true };
725
+ }
656
726
  // ─── 데이터 안전(Safety Labels) 선언 ───
657
727
  //
658
728
  // 오랫동안 Console 전용이라고 알려졌지만 API 가 생겼다 (POST applications/{pkg}/dataSafety).
@@ -6,6 +6,7 @@ import * as appstoreProductLocalization from '../appstore/product-localization.j
6
6
  import * as appstoreRelease from '../appstore/release.js';
7
7
  import * as appstoreDeclarations from '../appstore/declarations.js';
8
8
  import * as testflight from '../appstore/testflight.js';
9
+ import * as previews from '../appstore/previews.js';
9
10
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
10
11
  import { requireAppStoreCreds } from '../helpers.js';
11
12
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
@@ -1437,4 +1438,91 @@ export function registerAppstoreTools(server) {
1437
1438
  const r = await testflight.notifyBetaTesters(buildId);
1438
1439
  return { content: [{ type: 'text', text: `✅ 알림 발송 (${r.notificationId})` }] };
1439
1440
  });
1441
+ // ─── 제품 페이지 미리보기 동영상 ───
1442
+ // 스크린샷과 같은 4단계 업로드지만, 커밋 후 Apple 인코딩이 남는다 — 업로드 성공 ≠ 노출 가능.
1443
+ const PREVIEW_TYPES = [
1444
+ 'IPHONE_67', 'IPHONE_61', 'IPHONE_65', 'IPHONE_58', 'IPHONE_55', 'IPHONE_47', 'IPHONE_40', 'IPHONE_35',
1445
+ 'IPAD_PRO_3GEN_129', 'IPAD_PRO_3GEN_11', 'IPAD_PRO_129', 'IPAD_105', 'IPAD_97',
1446
+ 'DESKTOP', 'APPLE_TV', 'APPLE_VISION_PRO',
1447
+ ];
1448
+ server.tool('appstore_list_previews', [
1449
+ '버전 로케일의 미리보기 동영상 세트를 조회한다 — 읽기 전용.',
1450
+ '세트별 previewType 과 각 동영상의 처리 상태(assetDeliveryState)·포스터 프레임·videoUrl 을 보여준다.',
1451
+ '업로드 직후 인코딩이 끝났는지 확인할 때도 이걸 쓴다.',
1452
+ ].join(' '), {
1453
+ localizationId: z
1454
+ .string()
1455
+ .describe('appStoreVersionLocalization ID (appstore_get_metadata 결과의 로케일별 id)'),
1456
+ }, async ({ localizationId }) => {
1457
+ const sets = await previews.listPreviewSets(localizationId);
1458
+ if (sets.length === 0) {
1459
+ return { content: [{ type: 'text', text: '미리보기 세트 없음.' }] };
1460
+ }
1461
+ const lines = [];
1462
+ for (const s of sets) {
1463
+ lines.push(`${s.previewType ?? '?'} (set ${s.id}) — ${s.previews.length}개`);
1464
+ for (const p of s.previews) {
1465
+ lines.push(` ${p.id}: ${p.fileName ?? '?'} · ${p.state ?? '상태 미상'}` +
1466
+ (p.previewFrameTimeCode ? ` · 포스터 ${p.previewFrameTimeCode}` : '') +
1467
+ (p.videoUrl ? ' · 재생 가능' : ''));
1468
+ }
1469
+ }
1470
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1471
+ });
1472
+ server.tool('appstore_upload_preview', [
1473
+ '제품 페이지 미리보기 **동영상**을 업로드한다 — appPreviewSets/appPreviews 4단계 업로드.',
1474
+ 'previewType 세트가 없으면 만들고, 예약 → 조각 업로드 → 커밋까지 한 번에 처리한다.',
1475
+ '⚠️ 커밋 후 Apple 인코딩이 남는다 — 성공 응답이 곧 노출 가능은 아니다. appstore_list_previews 로 확인할 것.',
1476
+ 'Apple 제약: 길이 15~30초, previewType 별 해상도 고정, 로케일·타입당 최대 3개. 어기면 인코딩 단계에서 거부된다.',
1477
+ 'previewFrameTimeCode(HH:MM:SS:FF)로 포스터 프레임을 지정할 수 있다.',
1478
+ '파일은 절대경로로 넘긴다 (대용량이라 조각 업로드하며, 동영상 바이트는 대화에 싣지 않는다).',
1479
+ ].join(' '), {
1480
+ localizationId: z.string().describe('appStoreVersionLocalization ID'),
1481
+ previewType: z.enum(PREVIEW_TYPES).describe('미리보기 타입 (기기군)'),
1482
+ filePath: z.string().describe('동영상 파일 절대경로 (.mp4 / .mov / .m4v)'),
1483
+ previewFrameTimeCode: z
1484
+ .string()
1485
+ .optional()
1486
+ .describe('포스터 프레임 시각 HH:MM:SS:FF (예: 00:00:03:00)'),
1487
+ }, async ({ localizationId, previewType, filePath, previewFrameTimeCode }) => {
1488
+ const r = await previews.uploadPreview({ localizationId, previewType, filePath, previewFrameTimeCode });
1489
+ return {
1490
+ content: [{
1491
+ type: 'text',
1492
+ text: [
1493
+ '✅ 미리보기 업로드 완료 (인코딩 대기)',
1494
+ ` id: ${r.id}`,
1495
+ ` 파일: ${r.fileName} (${(r.fileSize / 1024 / 1024).toFixed(1)} MB)`,
1496
+ ` 타입: ${r.previewType}`,
1497
+ ` 상태: ${r.state ?? '처리 중'}`,
1498
+ 'Apple 인코딩이 끝나야 노출된다 — appstore_list_previews 로 상태를 확인할 것.',
1499
+ ].join('\n'),
1500
+ }],
1501
+ };
1502
+ });
1503
+ server.tool('appstore_delete_preview', [
1504
+ '미리보기 동영상 또는 세트를 삭제한다 — DELETE /v1/appPreviews/{id} 또는 /appPreviewSets/{id}.',
1505
+ '⚠️ 되돌릴 수 없다. 세트를 지우면 그 안의 동영상이 모두 사라진다. confirm 필요.',
1506
+ ].join(' '), {
1507
+ previewId: z.string().optional().describe('삭제할 미리보기 ID (set 과 둘 중 하나)'),
1508
+ setId: z.string().optional().describe('삭제할 미리보기 세트 ID (안의 동영상 전부 삭제)'),
1509
+ confirm: z.boolean().optional().describe('true 명시 시에만 삭제'),
1510
+ }, async ({ previewId, setId, confirm }) => {
1511
+ if (!previewId && !setId)
1512
+ throw new Error('previewId 또는 setId 중 하나는 필요하다.');
1513
+ if (!confirm) {
1514
+ return {
1515
+ content: [{
1516
+ type: 'text',
1517
+ text: previewId
1518
+ ? `🛑 dry-run — 미리보기 ${previewId} 를 삭제할 참이다. confirm: true 로 다시 호출.`
1519
+ : `🛑 dry-run — 세트 ${setId} 와 그 안의 동영상 전부를 삭제할 참이다. confirm: true 로 다시 호출.`,
1520
+ }],
1521
+ };
1522
+ }
1523
+ const r = previewId
1524
+ ? await previews.deletePreview(previewId)
1525
+ : await previews.deletePreviewSet(setId);
1526
+ return { content: [{ type: 'text', text: `✅ 삭제 완료 — ${r.id}` }] };
1527
+ });
1440
1528
  }
@@ -809,4 +809,127 @@ export function registerPlaystoreTools(server) {
809
809
  }],
810
810
  };
811
811
  });
812
+ // ─── 앱 복구 (출시 후 긴급 대응) ───
813
+ server.tool('playstore_list_recovery_actions', [
814
+ '앱 복구 액션 목록을 조회한다 — 읽기 전용 (apprecovery.list).',
815
+ '이미 배포된 앱이 치명적으로 망가졌을 때 특정 버전 사용자에게 원격 인앱 업데이트를 밀어 넣는 장치다.',
816
+ '상태(DRAFT/ACTIVE/CANCELED)·생성·배포·취소 시각과 대상을 보여준다.',
817
+ ].join(' '), {
818
+ packageName: z.string().describe('패키지명'),
819
+ versionCode: z.string().optional().describe('특정 버전 코드로 필터'),
820
+ }, async ({ packageName, versionCode }) => {
821
+ const auth = await requirePlayStoreAuth(packageName);
822
+ const rows = await playstore.listRecoveryActions(auth, packageName, versionCode);
823
+ if (rows.length === 0)
824
+ return { content: [{ type: 'text', text: '복구 액션 없음.' }] };
825
+ return {
826
+ content: [{
827
+ type: 'text',
828
+ text: rows
829
+ .map((r) => [
830
+ `${r.id}: ${r.status}`,
831
+ r.createTime ? ` 생성 ${r.createTime}` : '',
832
+ r.deployTime ? ` 배포 ${r.deployTime}` : '',
833
+ r.cancelTime ? ` 취소 ${r.cancelTime}` : '',
834
+ ` 대상: ${JSON.stringify(r.targeting ?? {})}`,
835
+ ].filter(Boolean).join('\n'))
836
+ .join('\n'),
837
+ }],
838
+ };
839
+ });
840
+ server.tool('playstore_create_recovery_action', [
841
+ '앱 복구 액션을 DRAFT 상태로 만든다 — apprecovery.create.',
842
+ '만들기만 하고 배포하지 않는다 (배포는 playstore_deploy_recovery_action).',
843
+ '대상은 allUsers 또는 versionCodes / versionRange 중 하나로 정한다 — allUsers 와 버전 지정은 함께 못 쓴다.',
844
+ '⚠️ 배포 후에는 대상을 만들 때 고른 기준 안에서만 넓힐 수 있다. 범위를 신중히 잡을 것.',
845
+ '이건 롤백이 아니라 "고친 버전으로 강제 업데이트"다 — 먼저 정상 빌드를 올려 둬야 의미가 있다.',
846
+ ].join(' '), {
847
+ packageName: z.string().describe('패키지명'),
848
+ allUsers: z.boolean().optional().describe('전체 사용자 대상 (버전 지정과 함께 쓸 수 없음)'),
849
+ versionCodes: z.array(z.string()).optional().describe('대상 버전 코드 목록'),
850
+ versionRangeStart: z.string().optional().describe('대상 버전 범위 시작'),
851
+ versionRangeEnd: z.string().optional().describe('대상 버전 범위 끝'),
852
+ regions: z.array(z.string()).optional().describe('지역 코드 (예: KR, US)'),
853
+ sdkLevels: z.array(z.string()).optional().describe('Android SDK 레벨'),
854
+ confirm: z.boolean().optional().describe('true 명시 시에만 생성. 생략/false 면 계획만 반환.'),
855
+ }, async ({ packageName, allUsers, versionCodes, versionRangeStart, versionRangeEnd, regions, sdkLevels, confirm }) => {
856
+ const targeting = {
857
+ allUsers,
858
+ versionCodes,
859
+ versionRange: versionRangeStart && versionRangeEnd
860
+ ? { start: versionRangeStart, end: versionRangeEnd }
861
+ : undefined,
862
+ regions,
863
+ sdkLevels,
864
+ };
865
+ if (!confirm) {
866
+ return {
867
+ content: [{
868
+ type: 'text',
869
+ text: [
870
+ '🛑 dry-run — 아직 만들지 않았다.',
871
+ ` 패키지: ${packageName}`,
872
+ ` 대상: ${JSON.stringify(targeting)}`,
873
+ '',
874
+ 'DRAFT 로 만들려면 confirm: true. 만든 뒤에도 배포는 별도 단계다.',
875
+ ].join('\n'),
876
+ }],
877
+ };
878
+ }
879
+ const auth = await requirePlayStoreAuth(packageName);
880
+ const r = await playstore.createRecoveryAction(auth, packageName, targeting);
881
+ return {
882
+ content: [{
883
+ type: 'text',
884
+ text: [
885
+ '✅ 복구 액션 생성 (DRAFT — 아직 사용자에게 나가지 않았다)',
886
+ ` id: ${r.id}`,
887
+ ` 상태: ${r.status}`,
888
+ '배포하려면 playstore_deploy_recovery_action.',
889
+ ].join('\n'),
890
+ }],
891
+ };
892
+ });
893
+ server.tool('playstore_deploy_recovery_action', [
894
+ 'DRAFT 복구 액션을 실제로 배포한다 — apprecovery.deploy.',
895
+ '⚠️ 대상 사용자 기기에 원격 인앱 업데이트가 나간다. 되돌리려면 취소해야 하고, 이미 적용된 기기는 되돌아가지 않는다.',
896
+ '배포 전 playstore_list_recovery_actions 로 대상 범위를 반드시 재확인할 것. confirm 필요.',
897
+ ].join(' '), {
898
+ packageName: z.string().describe('패키지명'),
899
+ appRecoveryId: z.string().describe('복구 액션 ID'),
900
+ confirm: z.boolean().optional().describe('true 명시 시에만 배포'),
901
+ }, async ({ packageName, appRecoveryId, confirm }) => {
902
+ if (!confirm) {
903
+ return {
904
+ content: [{
905
+ type: 'text',
906
+ text: `🛑 dry-run — 복구 액션 ${appRecoveryId} 를 배포할 참이다. 대상 사용자에게 원격 업데이트가 나간다. confirm: true 로 다시 호출.`,
907
+ }],
908
+ };
909
+ }
910
+ const auth = await requirePlayStoreAuth(packageName);
911
+ const r = await playstore.deployRecoveryAction(auth, packageName, appRecoveryId);
912
+ return { content: [{ type: 'text', text: `✅ 복구 액션 배포 — ${r.appRecoveryId}` }] };
913
+ });
914
+ server.tool('playstore_cancel_recovery_action', [
915
+ '진행 중인 복구 액션을 취소한다 — apprecovery.cancel.',
916
+ '아직 받지 않은 기기에는 더 이상 나가지 않는다. 이미 적용된 기기는 되돌아가지 않는다.',
917
+ 'confirm 필요.',
918
+ ].join(' '), {
919
+ packageName: z.string().describe('패키지명'),
920
+ appRecoveryId: z.string().describe('복구 액션 ID'),
921
+ confirm: z.boolean().optional().describe('true 명시 시에만 취소'),
922
+ }, async ({ packageName, appRecoveryId, confirm }) => {
923
+ if (!confirm) {
924
+ return {
925
+ content: [{
926
+ type: 'text',
927
+ text: `🛑 dry-run — 복구 액션 ${appRecoveryId} 를 취소할 참이다. 이미 적용된 기기는 되돌아가지 않는다. confirm: true 로 다시 호출.`,
928
+ }],
929
+ };
930
+ }
931
+ const auth = await requirePlayStoreAuth(packageName);
932
+ const r = await playstore.cancelRecoveryAction(auth, packageName, appRecoveryId);
933
+ return { content: [{ type: 'text', text: `✅ 복구 액션 취소 — ${r.appRecoveryId}` }] };
934
+ });
812
935
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.17",
3
+ "version": "0.13.18",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 207,
3
+ "total": 214,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -97,7 +97,10 @@
97
97
  "appstore_submit_beta_review",
98
98
  "appstore_set_beta_group_build",
99
99
  "appstore_add_beta_testers",
100
- "appstore_notify_beta_testers"
100
+ "appstore_notify_beta_testers",
101
+ "appstore_list_previews",
102
+ "appstore_upload_preview",
103
+ "appstore_delete_preview"
101
104
  ]
102
105
  },
103
106
  "auth": {
@@ -305,7 +308,11 @@
305
308
  "playstore_update_product",
306
309
  "playstore_delete_product",
307
310
  "setup_playstore_connection",
308
- "playstore_upload_data_safety"
311
+ "playstore_upload_data_safety",
312
+ "playstore_list_recovery_actions",
313
+ "playstore_create_recovery_action",
314
+ "playstore_deploy_recovery_action",
315
+ "playstore_cancel_recovery_action"
309
316
  ]
310
317
  },
311
318
  "threads": {