@yoonion/mimi-seed-mcp 0.13.16 → 0.13.17

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,7 +78,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
78
78
 
79
79
  | 영역 | 도구 수 | 주요 도구 |
80
80
  |------|---------|-----------|
81
- | App Store Connect | 50 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
81
+ | App Store Connect | 58 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
82
82
  | Google Play | 33 | `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` |
@@ -56,6 +56,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
56
56
  | App Store / TestFlight | `select:appstore_list_apps,appstore_verify_credentials,appstore_get_app,appstore_list_versions,appstore_create_version,appstore_get_metadata,appstore_update_whats_new,appstore_list_builds,appstore_attach_build,appstore_attach_latest_build,appstore_list_beta_groups,appstore_submit_for_review,appstore_check_submission_risks,appstore_plan_release` |
57
57
  | App Store release control (after approval) | `select:appstore_release_status,appstore_release_version,appstore_update_release_type,appstore_phased_release,appstore_list_versions` |
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
+ | 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` |
59
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` |
60
61
  | 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` |
61
62
  | 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` |
@@ -5,7 +5,7 @@
5
5
  // 판매 지역 (appAvailabilityV2 / territoryAvailabilities): 지역별 available·출시일
6
6
  //
7
7
  // Play 쪽 대응물(데이터 안전 CSV)은 playstore/tools.ts 에 있다 — 자격증명 계통이 달라서 파일을 나눴다.
8
- import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow } from './http.js';
8
+ import { V1_BASE, V2_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
9
9
  async function get(base, path, params) {
10
10
  const headers = await authHeadersOrThrow();
11
11
  const query = params ? `?${new URLSearchParams(params).toString()}` : '';
@@ -33,12 +33,21 @@ async function resolveAppInfoId(appId) {
33
33
  }
34
34
  export async function getAgeRating(appId) {
35
35
  const appInfoId = await resolveAppInfoId(appId);
36
- const data = await get(V1_BASE, `/appInfos/${appInfoId}/ageRatingDeclaration`);
37
- return {
38
- appInfoId,
39
- declarationId: data.data?.id,
40
- declaration: data.data?.attributes ?? {},
41
- };
36
+ // 선언 리소스가 아직 없는 앱이 있다 — 그때는 404 다. 에러 대신 "없음"으로 돌려주고
37
+ // updateAgeRating 이 사람이 읽을 안내를 내도록 한다.
38
+ try {
39
+ const data = await get(V1_BASE, `/appInfos/${appInfoId}/ageRatingDeclaration`);
40
+ return {
41
+ appInfoId,
42
+ declarationId: data.data?.id,
43
+ declaration: data.data?.attributes ?? {},
44
+ };
45
+ }
46
+ catch (err) {
47
+ if (isNotFound(err))
48
+ return { appInfoId, declarationId: undefined, declaration: {} };
49
+ throw err;
50
+ }
42
51
  }
43
52
  /**
44
53
  * 연령 등급 설문 갱신 (PATCH /v1/ageRatingDeclarations/{id}).
@@ -3,3 +3,11 @@ export declare const V2_BASE = "https://api.appstoreconnect.apple.com/v2";
3
3
  export type AppStoreProductType = 'subscription' | 'consumable' | 'non_consumable';
4
4
  export declare function authHeadersOrThrow(): Promise<Record<string, string>>;
5
5
  export declare function apiRequest<T>(base: string, resourcePath: string, authHeaders: Record<string, string>, init: RequestInit): Promise<T>;
6
+ /**
7
+ * "리소스 없음"인지 **상태 코드로** 판별한다.
8
+ *
9
+ * friendlyAppStoreError 가 cause.status 에 실제 HTTP 상태를 붙여준다. 메시지 문자열로
10
+ * 404 를 찾으면 본문에 'not found'/'404' 가 섞인 403·409 까지 "없음"으로 삼켜서,
11
+ * 권한 오류가 조용히 빈 결과로 둔갑한다. 상태를 못 읽는 경우에만 문자열로 폴백한다.
12
+ */
13
+ export declare function isNotFound(err: unknown): boolean;
@@ -26,3 +26,16 @@ export async function apiRequest(base, resourcePath, authHeaders, init) {
26
26
  const text = await response.text();
27
27
  return (text ? JSON.parse(text) : { ok: true });
28
28
  }
29
+ /**
30
+ * "리소스 없음"인지 **상태 코드로** 판별한다.
31
+ *
32
+ * friendlyAppStoreError 가 cause.status 에 실제 HTTP 상태를 붙여준다. 메시지 문자열로
33
+ * 404 를 찾으면 본문에 'not found'/'404' 가 섞인 403·409 까지 "없음"으로 삼켜서,
34
+ * 권한 오류가 조용히 빈 결과로 둔갑한다. 상태를 못 읽는 경우에만 문자열로 폴백한다.
35
+ */
36
+ export function isNotFound(err) {
37
+ const cause = err?.cause;
38
+ if (typeof cause?.status === 'number')
39
+ return cause.status === 404;
40
+ return /App Store API 404\b/.test(err?.message ?? '');
41
+ }
@@ -6,7 +6,7 @@
6
6
  // 2. 만들어 둔 버전의 releaseType 을 나중에 바꾸기 (PATCH appStoreVersions)
7
7
  // 3. 단계적 출시 시작·일시중지·재개·즉시완료 (phasedRelease)
8
8
  // Play 는 userFraction/halted 로 3번이 되는데 iOS 만 비어 있었다.
9
- import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
9
+ import { V1_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
10
10
  /** 각 상태가 "지금 출시" 요청을 받을 수 있는지 + 사람이 읽을 설명. */
11
11
  const STATE_NOTE = {
12
12
  PENDING_DEVELOPER_RELEASE: '심사 통과 후 개발자 출시 대기 — 지금 출시할 수 있다.',
@@ -60,7 +60,7 @@ async function getPhasedRelease(versionId) {
60
60
  };
61
61
  }
62
62
  catch (err) {
63
- if (/404|not found|NOT_FOUND/i.test(err.message))
63
+ if (isNotFound(err))
64
64
  return null;
65
65
  throw err;
66
66
  }
@@ -0,0 +1,89 @@
1
+ export interface BetaStatus {
2
+ buildId: string;
3
+ internalState?: string;
4
+ externalState?: string;
5
+ note: string;
6
+ autoNotifyEnabled?: boolean;
7
+ submissionState?: string;
8
+ whatsToTestLocales: string[];
9
+ reviewDetail?: {
10
+ id: string;
11
+ complete: boolean;
12
+ missing: string[];
13
+ };
14
+ testInfoLocales?: string[];
15
+ }
16
+ /**
17
+ * 외부 테스트 제출 전 "뭐가 비었는지"를 한 번에 본다.
18
+ * appId 를 함께 주면 앱 단위 항목(심사 정보·테스트 정보)까지 검사한다.
19
+ */
20
+ export declare function getBetaStatus(args: {
21
+ buildId: string;
22
+ appId?: string;
23
+ }): Promise<BetaStatus>;
24
+ /** 베타 심사 정보 (앱 단위, 단일 리소스). PATCH 만 가능하다 — Apple 이 앱 생성 때 만들어 둔다. */
25
+ export declare function updateBetaReviewDetail(args: {
26
+ appId: string;
27
+ fields: Record<string, string | boolean | undefined>;
28
+ }): Promise<{
29
+ id: string;
30
+ attributes: Record<string, unknown>;
31
+ }>;
32
+ /** 앱 단위 테스트 정보(피드백 이메일·설명 등)를 로케일별로 upsert. */
33
+ export declare function upsertBetaTestInfo(args: {
34
+ appId: string;
35
+ locale: string;
36
+ fields: {
37
+ feedbackEmail?: string;
38
+ description?: string;
39
+ marketingUrl?: string;
40
+ privacyPolicyUrl?: string;
41
+ };
42
+ }): Promise<{
43
+ id: string;
44
+ created: boolean;
45
+ locale: string;
46
+ }>;
47
+ /** 빌드 단위 What to Test 를 로케일별로 upsert. */
48
+ export declare function upsertWhatsToTest(args: {
49
+ buildId: string;
50
+ locale: string;
51
+ whatsNew: string;
52
+ }): Promise<{
53
+ id: string;
54
+ created: boolean;
55
+ locale: string;
56
+ }>;
57
+ /** 빌드를 베타 심사에 제출 (외부 테스터 배포 전 필수). */
58
+ export declare function submitBetaReview(buildId: string): Promise<{
59
+ submissionId: string;
60
+ state?: string;
61
+ }>;
62
+ /** 베타 그룹에 빌드를 붙이거나 뗀다. 외부 그룹이면 실제 배포/회수다. */
63
+ export declare function setBetaGroupBuild(args: {
64
+ groupId: string;
65
+ buildId: string;
66
+ action: 'add' | 'remove';
67
+ }): Promise<{
68
+ groupId: string;
69
+ buildId: string;
70
+ action: string;
71
+ }>;
72
+ /** 테스터 초대. 이미 등록된 이메일은 409 가 나므로 개별 결과로 보고한다. */
73
+ export declare function addBetaTesters(args: {
74
+ groupId: string;
75
+ testers: Array<{
76
+ email: string;
77
+ firstName?: string;
78
+ lastName?: string;
79
+ }>;
80
+ }): Promise<Array<{
81
+ email: string;
82
+ ok: boolean;
83
+ testerId?: string;
84
+ error?: string;
85
+ }>>;
86
+ /** 이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다. */
87
+ export declare function notifyBetaTesters(buildId: string): Promise<{
88
+ notificationId: string;
89
+ }>;
@@ -0,0 +1,199 @@
1
+ // TestFlight 외부 테스트 — 심사 제출과 배포.
2
+ //
3
+ // 내부 테스터(팀)는 빌드가 처리되면 바로 받지만, **외부 테스터는 Apple 베타 심사를 통과해야** 받는다.
4
+ // 그 심사에 필요한 것이 세 갈래로 흩어져 있다:
5
+ // 앱 단위 betaAppReviewDetail 연락처·데모 계정·심사 노트
6
+ // 앱 단위 betaAppLocalizations 피드백 이메일·앱 설명 (로케일별)
7
+ // 빌드 단위 betaBuildLocalizations What to Test (로케일별)
8
+ // 하나라도 비면 제출이 막히거나 반려된다. 그래서 상태 조회를 "뭐가 비었는지" 중심으로 만든다.
9
+ import { V1_BASE, apiRequest, authHeadersOrThrow, isNotFound } from './http.js';
10
+ /** 외부 빌드 상태 → 사람이 읽을 뜻 + 다음 행동. */
11
+ const EXTERNAL_STATE = {
12
+ PROCESSING: '업로드 처리 중 — 끝날 때까지 기다린다.',
13
+ PROCESSING_EXCEPTION: '처리 실패 — 빌드를 다시 업로드해야 한다.',
14
+ MISSING_EXPORT_COMPLIANCE: '수출 규정 정보 없음 — appstore_declare_encryption 으로 선언하거나 Info.plist 에 ITSAppUsesNonExemptEncryption 을 넣는다.',
15
+ READY_FOR_BETA_SUBMISSION: '베타 심사 제출 가능 — appstore_submit_beta_review.',
16
+ WAITING_FOR_BETA_REVIEW: '베타 심사 대기열.',
17
+ IN_BETA_REVIEW: '베타 심사 진행 중.',
18
+ BETA_APPROVED: '베타 심사 통과 — 외부 그룹에 배포할 수 있다.',
19
+ BETA_REJECTED: '베타 심사 반려 — 사유 확인 후 수정하고 재제출.',
20
+ READY_FOR_BETA_TESTING: '테스트 준비 완료.',
21
+ IN_BETA_TESTING: '외부 테스트 중.',
22
+ EXPIRED: '빌드 만료 — 새 빌드가 필요하다.',
23
+ };
24
+ async function get(path, params) {
25
+ const headers = await authHeadersOrThrow();
26
+ const query = params ? `?${new URLSearchParams(params).toString()}` : '';
27
+ return apiRequest(V1_BASE, `${path}${query}`, headers, { method: 'GET' });
28
+ }
29
+ async function send(method, path, body) {
30
+ const headers = await authHeadersOrThrow();
31
+ return apiRequest(V1_BASE, path, headers, {
32
+ method,
33
+ ...(body === undefined
34
+ ? {}
35
+ : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
36
+ });
37
+ }
38
+ /** to-one 관계는 없을 때 data:null 또는 404 로 온다. */
39
+ async function getOrNull(path) {
40
+ try {
41
+ const r = await get(path);
42
+ return r.data ?? null;
43
+ }
44
+ catch (err) {
45
+ if (isNotFound(err))
46
+ return null;
47
+ throw err;
48
+ }
49
+ }
50
+ /**
51
+ * 외부 테스트 제출 전 "뭐가 비었는지"를 한 번에 본다.
52
+ * appId 를 함께 주면 앱 단위 항목(심사 정보·테스트 정보)까지 검사한다.
53
+ */
54
+ export async function getBetaStatus(args) {
55
+ const { buildId, appId } = args;
56
+ const detail = await getOrNull(`/builds/${buildId}/buildBetaDetail`);
57
+ const submission = await getOrNull(`/builds/${buildId}/betaAppReviewSubmission`);
58
+ const locs = await get(`/builds/${buildId}/betaBuildLocalizations`);
59
+ const externalState = detail?.attributes?.externalBuildState;
60
+ const status = {
61
+ buildId,
62
+ internalState: detail?.attributes?.internalBuildState,
63
+ externalState,
64
+ note: (externalState && EXTERNAL_STATE[externalState]) || '',
65
+ autoNotifyEnabled: detail?.attributes?.autoNotifyEnabled,
66
+ submissionState: submission?.attributes?.betaReviewState,
67
+ whatsToTestLocales: (locs.data ?? [])
68
+ .filter((l) => (l.attributes?.whatsNew ?? '').trim().length > 0)
69
+ .map((l) => l.attributes?.locale ?? '?'),
70
+ };
71
+ if (appId) {
72
+ const rd = await getOrNull(`/apps/${appId}/betaAppReviewDetail`);
73
+ if (rd) {
74
+ const a = rd.attributes ?? {};
75
+ const missing = [];
76
+ for (const f of ['contactFirstName', 'contactLastName', 'contactPhone', 'contactEmail']) {
77
+ if (!a[f])
78
+ missing.push(f);
79
+ }
80
+ if (a.demoAccountRequired && (!a.demoAccountName || !a.demoAccountPassword)) {
81
+ missing.push('demoAccountName/demoAccountPassword (demoAccountRequired=true 인데 비어 있음)');
82
+ }
83
+ status.reviewDetail = { id: rd.id, complete: missing.length === 0, missing };
84
+ }
85
+ const appLocs = await get(`/apps/${appId}/betaAppLocalizations`);
86
+ status.testInfoLocales = (appLocs.data ?? []).map((l) => l.attributes?.locale ?? '?');
87
+ }
88
+ return status;
89
+ }
90
+ /** 베타 심사 정보 (앱 단위, 단일 리소스). PATCH 만 가능하다 — Apple 이 앱 생성 때 만들어 둔다. */
91
+ export async function updateBetaReviewDetail(args) {
92
+ const attributes = Object.fromEntries(Object.entries(args.fields).filter(([, v]) => v !== undefined));
93
+ if (Object.keys(attributes).length === 0)
94
+ throw new Error('바꿀 항목이 없다.');
95
+ const rd = await getOrNull(`/apps/${args.appId}/betaAppReviewDetail`);
96
+ if (!rd)
97
+ throw new Error(`앱 ${args.appId} 의 betaAppReviewDetail 을 찾지 못했다.`);
98
+ await send('PATCH', `/betaAppReviewDetails/${rd.id}`, {
99
+ data: { type: 'betaAppReviewDetails', id: rd.id, attributes },
100
+ });
101
+ const after = await getOrNull(`/apps/${args.appId}/betaAppReviewDetail`);
102
+ return { id: rd.id, attributes: after?.attributes ?? {} };
103
+ }
104
+ /** 앱 단위 테스트 정보(피드백 이메일·설명 등)를 로케일별로 upsert. */
105
+ export async function upsertBetaTestInfo(args) {
106
+ const { appId, locale, fields } = args;
107
+ const existing = await get(`/apps/${appId}/betaAppLocalizations`);
108
+ const hit = (existing.data ?? []).find((l) => l.attributes?.locale === locale);
109
+ const attributes = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
110
+ if (hit) {
111
+ await send('PATCH', `/betaAppLocalizations/${hit.id}`, {
112
+ data: { type: 'betaAppLocalizations', id: hit.id, attributes },
113
+ });
114
+ return { id: hit.id, created: false, locale };
115
+ }
116
+ const created = await send('POST', '/betaAppLocalizations', {
117
+ data: {
118
+ type: 'betaAppLocalizations',
119
+ attributes: { ...attributes, locale },
120
+ relationships: { app: { data: { type: 'apps', id: appId } } },
121
+ },
122
+ });
123
+ return { id: created.data?.id ?? '', created: true, locale };
124
+ }
125
+ /** 빌드 단위 What to Test 를 로케일별로 upsert. */
126
+ export async function upsertWhatsToTest(args) {
127
+ const { buildId, locale, whatsNew } = args;
128
+ const existing = await get(`/builds/${buildId}/betaBuildLocalizations`);
129
+ const hit = (existing.data ?? []).find((l) => l.attributes?.locale === locale);
130
+ if (hit) {
131
+ await send('PATCH', `/betaBuildLocalizations/${hit.id}`, {
132
+ data: { type: 'betaBuildLocalizations', id: hit.id, attributes: { whatsNew } },
133
+ });
134
+ return { id: hit.id, created: false, locale };
135
+ }
136
+ const created = await send('POST', '/betaBuildLocalizations', {
137
+ data: {
138
+ type: 'betaBuildLocalizations',
139
+ attributes: { whatsNew, locale },
140
+ relationships: { build: { data: { type: 'builds', id: buildId } } },
141
+ },
142
+ });
143
+ return { id: created.data?.id ?? '', created: true, locale };
144
+ }
145
+ /** 빌드를 베타 심사에 제출 (외부 테스터 배포 전 필수). */
146
+ export async function submitBetaReview(buildId) {
147
+ const created = await send('POST', '/betaAppReviewSubmissions', {
148
+ data: {
149
+ type: 'betaAppReviewSubmissions',
150
+ relationships: { build: { data: { type: 'builds', id: buildId } } },
151
+ },
152
+ });
153
+ return {
154
+ submissionId: created.data?.id ?? '',
155
+ state: created.data?.attributes?.betaReviewState,
156
+ };
157
+ }
158
+ /** 베타 그룹에 빌드를 붙이거나 뗀다. 외부 그룹이면 실제 배포/회수다. */
159
+ export async function setBetaGroupBuild(args) {
160
+ const { groupId, buildId, action } = args;
161
+ await send(action === 'add' ? 'POST' : 'DELETE', `/betaGroups/${groupId}/relationships/builds`, {
162
+ data: [{ type: 'builds', id: buildId }],
163
+ });
164
+ return { groupId, buildId, action };
165
+ }
166
+ /** 테스터 초대. 이미 등록된 이메일은 409 가 나므로 개별 결과로 보고한다. */
167
+ export async function addBetaTesters(args) {
168
+ const out = [];
169
+ for (const t of args.testers) {
170
+ try {
171
+ const created = await send('POST', '/betaTesters', {
172
+ data: {
173
+ type: 'betaTesters',
174
+ attributes: {
175
+ email: t.email,
176
+ ...(t.firstName ? { firstName: t.firstName } : {}),
177
+ ...(t.lastName ? { lastName: t.lastName } : {}),
178
+ },
179
+ relationships: { betaGroups: { data: [{ type: 'betaGroups', id: args.groupId }] } },
180
+ },
181
+ });
182
+ out.push({ email: t.email, ok: true, testerId: created.data?.id });
183
+ }
184
+ catch (err) {
185
+ out.push({ email: t.email, ok: false, error: err.message });
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ /** 이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다. */
191
+ export async function notifyBetaTesters(buildId) {
192
+ const created = await send('POST', '/buildBetaNotifications', {
193
+ data: {
194
+ type: 'buildBetaNotifications',
195
+ relationships: { build: { data: { type: 'builds', id: buildId } } },
196
+ },
197
+ });
198
+ return { notificationId: created.data?.id ?? '' };
199
+ }
@@ -5,6 +5,7 @@ import * as appstoreProductReview from '../appstore/product-review.js';
5
5
  import * as appstoreProductLocalization from '../appstore/product-localization.js';
6
6
  import * as appstoreRelease from '../appstore/release.js';
7
7
  import * as appstoreDeclarations from '../appstore/declarations.js';
8
+ import * as testflight from '../appstore/testflight.js';
8
9
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
9
10
  import { requireAppStoreCreds } from '../helpers.js';
10
11
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
@@ -1201,4 +1202,239 @@ export function registerAppstoreTools(server) {
1201
1202
  }],
1202
1203
  };
1203
1204
  });
1205
+ // ─── TestFlight 외부 테스트 ───
1206
+ // 내부 테스터는 빌드 처리 후 바로 받지만, 외부 테스터는 Apple 베타 심사를 통과해야 한다.
1207
+ // 심사에 필요한 항목이 앱 단위(심사 정보·테스트 정보)와 빌드 단위(What to Test)로 흩어져 있다.
1208
+ server.tool('appstore_beta_status', [
1209
+ 'TestFlight 외부 테스트 제출 전 점검 — 읽기 전용. 빌드의 internal/external 상태,',
1210
+ '베타 심사 제출 상태, What to Test 가 채워진 로케일을 보여준다.',
1211
+ 'appId 를 함께 주면 앱 단위 항목(베타 심사 연락처·데모 계정, 테스트 정보 로케일)까지 검사해 빠진 필드를 알려준다.',
1212
+ '외부 배포가 막히면 여기부터 본다 — 대부분 수출 규정 미선언이나 심사 정보 공란이다.',
1213
+ ].join(' '), {
1214
+ buildId: z.string().describe('빌드 ID (appstore_list_builds 결과)'),
1215
+ appId: z.string().optional().describe('앱 ID — 주면 앱 단위 항목까지 함께 점검'),
1216
+ }, async ({ buildId, appId }) => {
1217
+ const s = await testflight.getBetaStatus({ buildId, appId });
1218
+ const lines = [
1219
+ `빌드 ${buildId}`,
1220
+ ` 내부 상태: ${s.internalState ?? '?'}`,
1221
+ ` 외부 상태: ${s.externalState ?? '?'}${s.note ? ` — ${s.note}` : ''}`,
1222
+ s.submissionState ? ` 베타 심사 제출: ${s.submissionState}` : ' 베타 심사 제출: 없음',
1223
+ ` What to Test: ${s.whatsToTestLocales.length ? s.whatsToTestLocales.join(', ') : '❌ 비어 있음 (외부 배포 필수)'}`,
1224
+ ` 자동 알림: ${s.autoNotifyEnabled === undefined ? '?' : s.autoNotifyEnabled}`,
1225
+ ];
1226
+ if (s.reviewDetail) {
1227
+ lines.push(s.reviewDetail.complete
1228
+ ? ' 베타 심사 정보: ✅ 채워짐'
1229
+ : ` 베타 심사 정보: ❌ 누락 — ${s.reviewDetail.missing.join(', ')}`);
1230
+ }
1231
+ if (s.testInfoLocales) {
1232
+ lines.push(` 테스트 정보 로케일: ${s.testInfoLocales.length ? s.testInfoLocales.join(', ') : '❌ 없음'}`);
1233
+ }
1234
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1235
+ });
1236
+ server.tool('appstore_update_beta_review_detail', [
1237
+ 'TestFlight 베타 심사 정보(연락처·데모 계정·심사 노트)를 채운다 — PATCH /v1/betaAppReviewDetails/{id}.',
1238
+ '앱 단위 단일 리소스이며 외부 테스트 심사 제출 전 필수다. 넘긴 필드만 바뀐다.',
1239
+ '로그인이 필요한 앱이면 demoAccountRequired=true 와 계정/비밀번호를 반드시 함께 넣는다 — 없으면 반려된다.',
1240
+ ].join(' '), {
1241
+ appId: z.string().describe('App Store 앱 ID'),
1242
+ contactFirstName: z.string().optional().describe('심사 연락처 이름'),
1243
+ contactLastName: z.string().optional().describe('심사 연락처 성'),
1244
+ contactPhone: z.string().optional().describe('심사 연락처 전화번호'),
1245
+ contactEmail: z.string().optional().describe('심사 연락처 이메일'),
1246
+ demoAccountRequired: z.boolean().optional().describe('심사에 데모 계정이 필요한가'),
1247
+ demoAccountName: z.string().optional().describe('데모 계정 ID'),
1248
+ demoAccountPassword: z.string().optional().describe('데모 계정 비밀번호'),
1249
+ notes: z.string().optional().describe('심사자에게 남길 메모'),
1250
+ }, async ({ appId, ...fields }) => {
1251
+ const r = await testflight.updateBetaReviewDetail({ appId, fields });
1252
+ const changed = Object.keys(fields).filter((k) => fields[k] !== undefined);
1253
+ return {
1254
+ content: [{
1255
+ type: 'text',
1256
+ // 비밀번호는 값을 되읽어 출력하지 않는다 — 채워졌는지만 알린다.
1257
+ text: [`✅ 베타 심사 정보 갱신 (${r.id})`, ...changed.map((k) => k === 'demoAccountPassword' ? ' demoAccountPassword: (설정됨)' : ` ${k}: ${String(r.attributes[k] ?? '')}`)].join('\n'),
1258
+ }],
1259
+ };
1260
+ });
1261
+ server.tool('appstore_update_beta_test_info', [
1262
+ 'TestFlight 테스트 정보(피드백 이메일·앱 설명 등)를 로케일별로 저장한다 — betaAppLocalizations upsert.',
1263
+ '해당 로케일이 있으면 PATCH, 없으면 POST 한다.',
1264
+ '외부 테스트에는 feedbackEmail 과 description 이 필요하다.',
1265
+ ].join(' '), {
1266
+ appId: z.string().describe('App Store 앱 ID'),
1267
+ locale: z.string().describe('로케일 (예: ko, en-US)'),
1268
+ feedbackEmail: z.string().optional().describe('테스터 피드백 수신 이메일'),
1269
+ description: z.string().optional().describe('테스터에게 보여줄 앱 설명'),
1270
+ marketingUrl: z.string().optional().describe('마케팅 URL'),
1271
+ privacyPolicyUrl: z.string().optional().describe('개인정보처리방침 URL'),
1272
+ }, async ({ appId, locale, ...fields }) => {
1273
+ const r = await testflight.upsertBetaTestInfo({ appId, locale, fields });
1274
+ return {
1275
+ content: [{
1276
+ type: 'text',
1277
+ text: `✅ 테스트 정보 ${r.created ? '생성' : '수정'} — ${r.locale} (${r.id})`,
1278
+ }],
1279
+ };
1280
+ });
1281
+ server.tool('appstore_update_whats_to_test', [
1282
+ '빌드의 What to Test 를 로케일별로 저장한다 — betaBuildLocalizations upsert.',
1283
+ '외부 테스터 배포에는 필수다. 비어 있으면 베타 심사에서 막힌다.',
1284
+ '버전 릴리스 노트(appstore_update_whats_new)와는 별개다 — 이건 TestFlight 전용이다.',
1285
+ ].join(' '), {
1286
+ buildId: z.string().describe('빌드 ID'),
1287
+ locale: z.string().describe('로케일 (예: ko, en-US)'),
1288
+ whatsNew: z.string().describe('이번 빌드에서 테스트할 내용'),
1289
+ }, async ({ buildId, locale, whatsNew }) => {
1290
+ const r = await testflight.upsertWhatsToTest({ buildId, locale, whatsNew });
1291
+ return {
1292
+ content: [{
1293
+ type: 'text',
1294
+ text: `✅ What to Test ${r.created ? '생성' : '수정'} — ${r.locale} (${r.id})`,
1295
+ }],
1296
+ };
1297
+ });
1298
+ server.tool('appstore_submit_beta_review', [
1299
+ '빌드를 TestFlight 베타 심사에 제출한다 — POST /v1/betaAppReviewSubmissions.',
1300
+ '외부 테스터에게 배포하려면 이 심사를 통과해야 한다 (내부 테스터는 불필요).',
1301
+ '사전 조건: 외부 상태가 READY_FOR_BETA_SUBMISSION, What to Test 채움, 베타 심사 정보 채움.',
1302
+ 'appstore_beta_status 로 먼저 점검할 것. confirm 생략/false 면 현재 상태만 보여주는 dry-run.',
1303
+ ].join(' '), {
1304
+ buildId: z.string().describe('빌드 ID'),
1305
+ appId: z.string().optional().describe('앱 ID — dry-run 점검을 앱 단위 항목까지 확장'),
1306
+ confirm: z.boolean().optional().describe('true 명시 시에만 실제 제출'),
1307
+ }, async ({ buildId, appId, confirm }) => {
1308
+ if (!confirm) {
1309
+ const s = await testflight.getBetaStatus({ buildId, appId });
1310
+ const blockers = [];
1311
+ if (s.externalState !== 'READY_FOR_BETA_SUBMISSION') {
1312
+ blockers.push(`외부 상태가 ${s.externalState ?? '?'} — ${s.note || '제출 가능 상태가 아니다'}`);
1313
+ }
1314
+ if (s.whatsToTestLocales.length === 0)
1315
+ blockers.push('What to Test 가 비어 있다');
1316
+ if (s.reviewDetail && !s.reviewDetail.complete) {
1317
+ blockers.push(`베타 심사 정보 누락: ${s.reviewDetail.missing.join(', ')}`);
1318
+ }
1319
+ return {
1320
+ content: [{
1321
+ type: 'text',
1322
+ text: [
1323
+ '🛑 베타 심사 제출 dry-run — 아직 제출하지 않았다.',
1324
+ ` 빌드: ${buildId} (${s.externalState ?? '?'})`,
1325
+ blockers.length ? ' 블로커:' : ' 블로커 없음.',
1326
+ ...blockers.map((b) => ` - ${b}`),
1327
+ '',
1328
+ blockers.length ? '위 항목을 먼저 해결할 것.' : '제출하려면 confirm: true 로 다시 호출.',
1329
+ ].join('\n'),
1330
+ }],
1331
+ };
1332
+ }
1333
+ const r = await testflight.submitBetaReview(buildId);
1334
+ return {
1335
+ content: [{
1336
+ type: 'text',
1337
+ text: [
1338
+ '✅ 베타 심사 제출',
1339
+ ` submissionId: ${r.submissionId}`,
1340
+ ` 상태: ${r.state ?? '(응답에 없음)'}`,
1341
+ '진행 상황은 appstore_beta_status 로 확인.',
1342
+ ].join('\n'),
1343
+ }],
1344
+ };
1345
+ });
1346
+ server.tool('appstore_set_beta_group_build', [
1347
+ '베타 그룹에 빌드를 붙이거나 뗀다 — POST/DELETE /v1/betaGroups/{id}/relationships/builds.',
1348
+ '⚠️ 외부 그룹에 붙이는 것은 **실제 배포**다 (심사 통과 후에만 가능). 떼면 테스터가 더 이상 받지 못한다.',
1349
+ 'groupId 는 appstore_list_beta_groups 결과. confirm 생략/false 면 dry-run.',
1350
+ ].join(' '), {
1351
+ groupId: z.string().describe('베타 그룹 ID'),
1352
+ buildId: z.string().describe('빌드 ID'),
1353
+ action: z.enum(['add', 'remove']).describe('붙이기 / 떼기'),
1354
+ confirm: z.boolean().optional().describe('true 명시 시에만 실행'),
1355
+ }, async ({ groupId, buildId, action, confirm }) => {
1356
+ if (!confirm) {
1357
+ return {
1358
+ content: [{
1359
+ type: 'text',
1360
+ text: [
1361
+ `🛑 dry-run — 아직 실행하지 않았다.`,
1362
+ ` 그룹 ${groupId} ${action === 'add' ? '←' : '↛'} 빌드 ${buildId}`,
1363
+ action === 'add'
1364
+ ? ' 외부 그룹이면 이 순간 테스터에게 배포된다.'
1365
+ : ' 테스터는 이 빌드를 더 이상 설치할 수 없게 된다.',
1366
+ '',
1367
+ '실행하려면 confirm: true 로 다시 호출.',
1368
+ ].join('\n'),
1369
+ }],
1370
+ };
1371
+ }
1372
+ const r = await testflight.setBetaGroupBuild({ groupId, buildId, action });
1373
+ return {
1374
+ content: [{
1375
+ type: 'text',
1376
+ text: `✅ 그룹 ${r.groupId} ${r.action === 'add' ? '에 빌드 추가' : '에서 빌드 제거'} — ${r.buildId}`,
1377
+ }],
1378
+ };
1379
+ });
1380
+ server.tool('appstore_add_beta_testers', [
1381
+ '베타 그룹에 테스터를 초대한다 — POST /v1/betaTesters.',
1382
+ '이메일별로 개별 호출하며, 이미 등록된 주소는 실패로 표시하고 나머지는 계속 진행한다.',
1383
+ '⚠️ 초대 메일이 즉시 발송된다 — 주소를 사용자에게 확인받고 실행할 것. confirm 필요.',
1384
+ ].join(' '), {
1385
+ groupId: z.string().describe('베타 그룹 ID (appstore_list_beta_groups 결과)'),
1386
+ testers: z
1387
+ .array(z.object({
1388
+ email: z.string().describe('테스터 이메일'),
1389
+ firstName: z.string().optional(),
1390
+ lastName: z.string().optional(),
1391
+ }))
1392
+ .min(1)
1393
+ .describe('초대할 테스터 목록'),
1394
+ confirm: z.boolean().optional().describe('true 명시 시에만 초대 발송'),
1395
+ }, async ({ groupId, testers, confirm }) => {
1396
+ if (!confirm) {
1397
+ return {
1398
+ content: [{
1399
+ type: 'text',
1400
+ text: [
1401
+ `🛑 dry-run — ${testers.length}명, 아직 초대하지 않았다.`,
1402
+ ...testers.map((t) => ` ${t.email}`),
1403
+ '',
1404
+ '실행하려면 confirm: true 로 다시 호출. 초대 메일이 즉시 발송된다.',
1405
+ ].join('\n'),
1406
+ }],
1407
+ };
1408
+ }
1409
+ const results = await testflight.addBetaTesters({ groupId, testers });
1410
+ const ok = results.filter((r) => r.ok).length;
1411
+ return {
1412
+ content: [{
1413
+ type: 'text',
1414
+ text: [
1415
+ `테스터 초대: 성공 ${ok} / 실패 ${results.length - ok}`,
1416
+ ...results.filter((r) => !r.ok).map((r) => ` ✗ ${r.email}: ${r.error}`),
1417
+ ].join('\n'),
1418
+ }],
1419
+ };
1420
+ });
1421
+ server.tool('appstore_notify_beta_testers', [
1422
+ '이미 배포된 빌드에 대해 테스터에게 알림을 다시 보낸다 — POST /v1/buildBetaNotifications.',
1423
+ '자동 알림(autoNotifyEnabled)이 꺼져 있거나, 배포 후 다시 알리고 싶을 때.',
1424
+ '⚠️ 테스터 전원에게 푸시/메일이 나간다. confirm 필요.',
1425
+ ].join(' '), {
1426
+ buildId: z.string().describe('빌드 ID'),
1427
+ confirm: z.boolean().optional().describe('true 명시 시에만 발송'),
1428
+ }, async ({ buildId, confirm }) => {
1429
+ if (!confirm) {
1430
+ return {
1431
+ content: [{
1432
+ type: 'text',
1433
+ text: `🛑 dry-run — 빌드 ${buildId} 의 테스터 전원에게 알림을 보낼 참이다. confirm: true 로 다시 호출.`,
1434
+ }],
1435
+ };
1436
+ }
1437
+ const r = await testflight.notifyBetaTesters(buildId);
1438
+ return { content: [{ type: 'text', text: `✅ 알림 발송 (${r.notificationId})` }] };
1439
+ });
1204
1440
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.16",
3
+ "version": "0.13.17",
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": 199,
3
+ "total": 207,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -89,7 +89,15 @@
89
89
  "appstore_update_age_rating",
90
90
  "appstore_declare_encryption",
91
91
  "appstore_get_availability",
92
- "appstore_set_territory_availability"
92
+ "appstore_set_territory_availability",
93
+ "appstore_beta_status",
94
+ "appstore_update_beta_review_detail",
95
+ "appstore_update_beta_test_info",
96
+ "appstore_update_whats_to_test",
97
+ "appstore_submit_beta_review",
98
+ "appstore_set_beta_group_build",
99
+ "appstore_add_beta_testers",
100
+ "appstore_notify_beta_testers"
93
101
  ]
94
102
  },
95
103
  "auth": {