@yoonion/mimi-seed-mcp 0.3.18 → 0.3.19

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.
@@ -0,0 +1,464 @@
1
+ import { z } from 'zod';
2
+ import * as appstore from '../appstore/tools.js';
3
+ import * as appstoreScreenshots from '../appstore/screenshots.js';
4
+ import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
5
+ import { requireAppStoreCreds } from '../helpers.js';
6
+ import { buildAppStoreReleasePlan } from '../checks/plan.js';
7
+ export function registerAppstoreTools(server) {
8
+ server.tool('appstore_list_apps', 'App Store Connect 앱 목록 조회', {}, async () => {
9
+ const apps = await appstore.listApps();
10
+ return { content: [{ type: 'text', text: JSON.stringify(apps, null, 2) }] };
11
+ });
12
+ server.tool('appstore_get_app', 'App Store Connect 앱 상세 정보', { appId: z.string().describe('앱 ID (숫자)') }, async ({ appId }) => {
13
+ const app = await appstore.getApp(appId);
14
+ return { content: [{ type: 'text', text: JSON.stringify(app, null, 2) }] };
15
+ });
16
+ server.tool('appstore_list_versions', 'App Store 버전 목록 (심사 상태 포함)', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
17
+ const versions = await appstore.listVersions(appId);
18
+ return { content: [{ type: 'text', text: JSON.stringify(versions, null, 2) }] };
19
+ });
20
+ server.tool('appstore_create_version', [
21
+ 'App Store 새 버전 레코드 생성 — POST /v1/appStoreVersions.',
22
+ '새 versionString(예: "1.2.3")으로 PREPARE_FOR_SUBMISSION 상태의 버전을 만듦.',
23
+ 'buildId를 함께 주면 생성과 동시에 빌드 연결. 나중에 붙이려면 appstore_attach_build 사용.',
24
+ 'releaseType: MANUAL(개발자가 출시) / AFTER_APPROVAL(승인 후 자동) / SCHEDULED(earliestReleaseDate 필요).',
25
+ ].join(' '), {
26
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id, 숫자형)'),
27
+ versionString: z.string().describe('버전 문자열 (예: "1.2.3")'),
28
+ platform: z
29
+ .enum(['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS'])
30
+ .default('IOS')
31
+ .describe('플랫폼 (기본 IOS)'),
32
+ copyright: z.string().optional().describe('저작권 표기 (예: "© 2026 Foo Inc.")'),
33
+ releaseType: z
34
+ .enum(['MANUAL', 'AFTER_APPROVAL', 'SCHEDULED'])
35
+ .optional()
36
+ .describe('출시 방식 (생략 시 Apple 기본값)'),
37
+ earliestReleaseDate: z
38
+ .string()
39
+ .optional()
40
+ .describe('SCHEDULED일 때 가장 빠른 출시 시각 (ISO 8601, 예: "2026-05-01T00:00:00Z")'),
41
+ buildId: z
42
+ .string()
43
+ .optional()
44
+ .describe('연결할 빌드 ID (appstore_list_builds 결과). 생략 시 버전만 생성하고 나중에 attach.'),
45
+ }, async ({ appId, versionString, platform, copyright, releaseType, earliestReleaseDate, buildId }) => {
46
+ const result = await appstore.createVersion({
47
+ appId,
48
+ versionString,
49
+ platform,
50
+ copyright,
51
+ releaseType,
52
+ earliestReleaseDate,
53
+ buildId,
54
+ });
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: `✅ 버전 ${versionString} (${platform}) 생성됨${buildId ? ` + 빌드 ${buildId} 연결됨` : ''}.\n\n${JSON.stringify(result, null, 2)}`,
60
+ },
61
+ ],
62
+ };
63
+ });
64
+ server.tool('appstore_attach_build', [
65
+ 'App Store 버전에 업로드된 빌드를 연결 — PATCH /v1/appStoreVersions/{id}/relationships/build.',
66
+ 'TestFlight에 업로드되어 processingState=VALID 상태인 빌드만 연결 가능.',
67
+ '편집 가능한 버전(PREPARE_FOR_SUBMISSION 등)에서만 변경됨.',
68
+ 'buildId는 appstore_list_builds 결과 사용.',
69
+ ].join(' '), {
70
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 또는 appstore_create_version 결과)'),
71
+ buildId: z.string().describe('빌드 ID (appstore_list_builds 결과)'),
72
+ }, async ({ versionId, buildId }) => {
73
+ const result = await appstore.attachBuildToVersion(versionId, buildId);
74
+ return {
75
+ content: [
76
+ {
77
+ type: 'text',
78
+ text: `✅ 빌드 ${buildId}가 버전 ${versionId}에 연결됐어.\n\n${JSON.stringify(result, null, 2)}`,
79
+ },
80
+ ],
81
+ };
82
+ });
83
+ server.tool('appstore_get_metadata', 'App Store 버전 메타데이터 (설명문, 키워드, What\'s New)', { versionId: z.string().describe('버전 ID') }, async ({ versionId }) => {
84
+ const localizations = await appstore.getVersionLocalizations(versionId);
85
+ return { content: [{ type: 'text', text: JSON.stringify(localizations, null, 2) }] };
86
+ });
87
+ server.tool('appstore_update_localization', "App Store 버전 로컬라이제이션(메타데이터) 수정 — localizationId 직접 지정. 이 버전의 새로운 기능(whatsNew), 설명(description), 키워드, 프로모션 텍스트를 편집. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
88
+ localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)'),
89
+ whatsNew: z.string().optional().describe('이 버전의 새로운 기능 (4000자 이내)'),
90
+ description: z.string().optional().describe('앱 설명 (4000자 이내)'),
91
+ keywords: z.string().optional().describe('키워드 (쉼표 구분, 100자 이내)'),
92
+ promotionalText: z.string().optional().describe('프로모션 텍스트 (170자 이내)'),
93
+ supportUrl: z.string().url().optional().describe('지원 URL'),
94
+ marketingUrl: z.string().url().optional().describe('마케팅 URL'),
95
+ }, async ({ localizationId, ...fields }) => {
96
+ const cleaned = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
97
+ if (Object.keys(cleaned).length === 0) {
98
+ throw new Error('수정할 필드를 하나 이상 지정해줘 (whatsNew, description, keywords, promotionalText, supportUrl, marketingUrl).');
99
+ }
100
+ const result = await appstore.updateVersionLocalization(localizationId, cleaned);
101
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
102
+ });
103
+ server.tool('appstore_list_screenshots', "App Store 스크린샷 셋 + 이미지 목록 조회 (로컬라이제이션 단위). 디스플레이 타입별로 그룹핑됨", { localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)') }, async ({ localizationId }) => {
104
+ const sets = await appstoreScreenshots.listScreenshotSets(localizationId);
105
+ return { content: [{ type: 'text', text: JSON.stringify(sets, null, 2) }] };
106
+ });
107
+ server.tool('appstore_upload_screenshot', [
108
+ "App Store 스크린샷 업로드 (자동 4단계: 셋 확보 → 예약 → 청크 업로드 → 커밋).",
109
+ "파일 경로는 로컬 절대경로. displayType은 Apple 공식 enum:",
110
+ "APP_IPHONE_69 (6.9\", 1290x2796) / APP_IPHONE_67 (6.7\", 1290x2796) /",
111
+ "APP_IPHONE_65 (6.5\", 1242x2688) / APP_IPHONE_61 (6.1\", 1170x2532) /",
112
+ "APP_IPHONE_58 (5.8\") / APP_IPHONE_55 (5.5\", 1242x2208) /",
113
+ "APP_IPAD_PRO_3GEN_129 (13\", 2064x2752) / APP_IPAD_PRO_3GEN_11 (11\", 1668x2388) /",
114
+ "APP_IPAD_PRO_129 (12.9\", 2048x2732) / APP_DESKTOP (2560x1600+).",
115
+ "해상도 틀리면 검수에서 리젝됨. 수정 가능한 버전 상태에서만 반영됨.",
116
+ ].join(' '), {
117
+ localizationId: z.string().describe('로컬라이제이션 ID'),
118
+ displayType: z.string().describe('Apple 스크린샷 디스플레이 타입 (예: APP_IPHONE_69)'),
119
+ filePath: z.string().describe('업로드할 이미지 파일의 절대경로'),
120
+ }, async ({ localizationId, displayType, filePath }) => {
121
+ const result = await appstoreScreenshots.uploadScreenshot(localizationId, displayType, filePath);
122
+ return {
123
+ content: [{
124
+ type: 'text',
125
+ text: `✅ 스크린샷 업로드 완료 (${displayType})\n\n${JSON.stringify(result, null, 2)}`,
126
+ }],
127
+ };
128
+ });
129
+ server.tool('appstore_delete_screenshot', 'App Store 개별 스크린샷 삭제', { screenshotId: z.string().describe('스크린샷 ID (appstore_list_screenshots 결과)') }, async ({ screenshotId }) => {
130
+ const result = await appstoreScreenshots.deleteScreenshot(screenshotId);
131
+ return { content: [{ type: 'text', text: `✅ 스크린샷 삭제됨\n${JSON.stringify(result, null, 2)}` }] };
132
+ });
133
+ server.tool('appstore_delete_screenshot_set', 'App Store 스크린샷 셋 전체 삭제 (디스플레이 타입 교체 시 먼저 정리)', { setId: z.string().describe('스크린샷 셋 ID (appstore_list_screenshots 결과)') }, async ({ setId }) => {
134
+ const result = await appstoreScreenshots.deleteScreenshotSet(setId);
135
+ return { content: [{ type: 'text', text: `✅ 셋 삭제됨\n${JSON.stringify(result, null, 2)}` }] };
136
+ });
137
+ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기능' 편집 — versionId + locale만 주면 자동으로 로컬라이제이션을 찾아 PATCH. 가장 흔한 사용 케이스. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
138
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
139
+ locale: z.string().describe('로캘 (예: ko, en-US, ja)'),
140
+ whatsNew: z.string().describe("'이 버전의 새로운 기능' 텍스트 (4000자 이내)"),
141
+ }, async ({ versionId, locale, whatsNew }) => {
142
+ const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
143
+ return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
144
+ });
145
+ server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용. 4000자 권장 한도.", {
146
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
147
+ notes: z.string().min(1).max(4000).describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내.'),
148
+ }, async ({ versionId, notes }) => {
149
+ const result = await appstore.updateReviewNotes(versionId, notes);
150
+ const action = result.created ? 'created' : 'updated';
151
+ const summary = `✅ 리뷰어 노트 ${result.created ? '신규 등록' : '수정'} 완료 (reviewDetailId: ${result.reviewDetailId})`;
152
+ return {
153
+ content: [{
154
+ type: 'text',
155
+ text: `${summary}\n\n${JSON.stringify({ ok: true, action, ...result }, null, 2)}`,
156
+ }],
157
+ };
158
+ });
159
+ server.tool('appstore_get_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 조회. 현재 등록된 notes, contactEmail 확인용.", {
160
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
161
+ }, async ({ versionId }) => {
162
+ const result = await appstore.getReviewNotes(versionId);
163
+ if (!result.reviewDetailId) {
164
+ return {
165
+ content: [{
166
+ type: 'text',
167
+ text: `이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.\n\n${JSON.stringify({ ok: true, exists: false }, null, 2)}`,
168
+ }],
169
+ };
170
+ }
171
+ const summary = `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`;
172
+ return {
173
+ content: [{
174
+ type: 'text',
175
+ text: `${summary}\n\n${JSON.stringify({ ok: true, exists: true, ...result }, null, 2)}`,
176
+ }],
177
+ };
178
+ });
179
+ server.tool('appstore_list_builds', 'TestFlight 빌드 목록', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
180
+ const builds = await appstore.listBuilds(appId);
181
+ return { content: [{ type: 'text', text: JSON.stringify(builds, null, 2) }] };
182
+ });
183
+ server.tool('appstore_list_beta_groups', 'TestFlight 베타 그룹 목록', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
184
+ const groups = await appstore.listBetaGroups(appId);
185
+ return { content: [{ type: 'text', text: JSON.stringify(groups, null, 2) }] };
186
+ });
187
+ server.tool('appstore_get_app_info', 'App Store 앱 정보 (카테고리, 연령 등급, state). state=READY_FOR_DISTRIBUTION이 라이브, 그 외가 편집 가능 appInfo.', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
188
+ const info = await appstore.getAppInfo(appId);
189
+ return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
190
+ });
191
+ server.tool('appstore_list_app_info_localizations', [
192
+ '편집 가능한 appInfo의 로컬라이제이션 목록 조회 — 앱 이름(name), 부제(subtitle), 개인정보 URL/텍스트.',
193
+ 'appInfo.relationships.appInfoLocalizations가 빈 배열로 오는 케이스를 우회하려고 /appInfos/{id}/appInfoLocalizations 직접 호출.',
194
+ 'locale을 주면 해당 언어만 반환 (예: "ko", "en-US").',
195
+ '※ versionLocalization(설명/키워드/whatsNew)과 다름 — 그건 appstore_get_metadata 사용.',
196
+ ].join(' '), {
197
+ appId: z.string().describe('앱 ID (appstore_list_apps 결과)'),
198
+ locale: z.string().optional().describe('언어 필터 (예: "ko", "en-US"). 생략 시 전체 반환.'),
199
+ }, async ({ appId, locale }) => {
200
+ const result = await appstore.listAppInfoLocalizations(appId, locale);
201
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
202
+ });
203
+ server.tool('appstore_update_app_info_localization', [
204
+ 'appInfo 로컬라이제이션(앱 이름/부제/개인정보 URL/텍스트) 수정 — PATCH /appInfoLocalizations/{id}.',
205
+ 'localizationId는 appstore_list_app_info_localizations 결과의 id.',
206
+ '편집 가능 상태(PREPARE_FOR_SUBMISSION / DEVELOPER_REJECTED 등)에서만 반영됨.',
207
+ '제한: name 30자, subtitle 30자.',
208
+ ].join(' '), {
209
+ localizationId: z.string().describe('appInfoLocalization ID'),
210
+ name: z.string().optional().describe('앱 이름 (30자 이내)'),
211
+ subtitle: z.string().optional().describe('부제 (30자 이내)'),
212
+ privacyPolicyUrl: z.string().url().optional().describe('개인정보 처리방침 URL'),
213
+ privacyPolicyText: z.string().optional().describe('개인정보 처리방침 텍스트'),
214
+ }, async ({ localizationId, ...fields }) => {
215
+ const result = await appstore.updateAppInfoLocalization(localizationId, fields);
216
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
217
+ });
218
+ server.tool('appstore_list_reviews', [
219
+ 'App Store 받은 고객 리뷰 조회 (최신순).',
220
+ 'response 필드에 개발자 답변 존재 여부와 내용이 함께 포함됨 (없으면 null).',
221
+ 'territory(예: KOR/USA — ISO 3166 alpha-3) / rating(1~5)으로 필터 가능.',
222
+ ].join(' '), {
223
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과)'),
224
+ limit: z.number().int().positive().max(200).optional().describe('가져올 개수 (기본 50, 최대 200)'),
225
+ territory: z.string().optional().describe("국가 코드 (예: 'KOR', 'USA' — alpha-3)"),
226
+ rating: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional().describe('별점 필터 (1~5)'),
227
+ }, async ({ appId, limit, territory, rating }) => {
228
+ const reviews = await appstore.listCustomerReviews(appId, { limit, territory, rating });
229
+ return { content: [{ type: 'text', text: JSON.stringify(reviews, null, 2) }] };
230
+ });
231
+ server.tool('appstore_reply_review', [
232
+ 'App Store 고객 리뷰에 개발자 답변을 등록 (또는 갱신).',
233
+ '동일 리뷰에 한 번만 답변 가능 — 기존 답변이 있으면 Apple이 새 응답으로 대체함.',
234
+ 'reviewId는 appstore_list_reviews 결과의 id.',
235
+ '답변 본문은 5970자 이내.',
236
+ ].join(' '), {
237
+ reviewId: z.string().describe('리뷰 ID (appstore_list_reviews 결과)'),
238
+ responseBody: z.string().describe('답변 본문 (5970자 이내)'),
239
+ }, async ({ reviewId, responseBody }) => {
240
+ const result = await appstore.createReviewResponse(reviewId, responseBody);
241
+ return { content: [{ type: 'text', text: `✅ 리뷰 ${reviewId}에 답변 등록됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
242
+ });
243
+ server.tool('appstore_create_inapp_purchase', [
244
+ 'App Store에 일회성 인앱 구매(IAP)를 생성 — CONSUMABLE (소비성) / NON_CONSUMABLE (비소비성).',
245
+ '생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
246
+ ].join(' '), {
247
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id, 숫자형)'),
248
+ productId: z
249
+ .string()
250
+ .describe('상품 ID (글로벌 unique 권장: 예 com.example.coins_100)'),
251
+ name: z.string().describe('상품 이름 (스토어 노출, 최대 30자)'),
252
+ price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $0.99 → 99, ₩1,100 → 1100)'),
253
+ currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
254
+ type: z
255
+ .enum(['consumable', 'non_consumable'])
256
+ .default('non_consumable')
257
+ .describe('IAP 유형 (소비성/비소비성)'),
258
+ extraRegions: z
259
+ .array(z.object({
260
+ currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
261
+ price: z.number().describe('가격 (최소 단위)'),
262
+ }))
263
+ .optional()
264
+ .describe('추가 지역별 명시 가격'),
265
+ bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
266
+ }, async (args) => {
267
+ const creds = requireAppStoreCreds();
268
+ const result = await createAppleOneTimePurchase({
269
+ appId: args.appId,
270
+ bundleId: args.bundleId,
271
+ productId: args.productId,
272
+ name: args.name,
273
+ price: args.price,
274
+ currency: args.currency,
275
+ type: args.type,
276
+ ...(args.extraRegions && { extraRegions: args.extraRegions }),
277
+ keyId: creds.keyId,
278
+ issuerId: creds.issuerId,
279
+ privateKey: creds.privateKey,
280
+ });
281
+ if (!result.success) {
282
+ const hint = result.errorType === 'DUPLICATE'
283
+ ? '\n이미 같은 productId가 존재해. App Store Connect에서 확인해줘.'
284
+ : result.errorType === 'PRICE_NOT_FOUND'
285
+ ? `\n가장 가까운 가격: ${JSON.stringify(result.priceNearest)}`
286
+ : '';
287
+ return { content: [{ type: 'text', text: `❌ IAP 생성 실패: ${result.error}${hint}` }] };
288
+ }
289
+ return {
290
+ content: [{
291
+ type: 'text',
292
+ text: [
293
+ `✓ App Store IAP 생성 완료`,
294
+ `productId: ${result.productId}`,
295
+ `internalId: ${result.internalId}`,
296
+ result.priceSet ? `✓ 가격 설정됨` : `⚠ 가격 미설정 (가장 가까운 가격: ${JSON.stringify(result.priceNearest)})`,
297
+ result.extraRegionsSet?.length ? `✓ 추가 지역: ${result.extraRegionsSet.join(', ')}` : '',
298
+ '',
299
+ '스크린샷·리뷰 노트를 추가한 후 App Store Connect에서 심사 제출:',
300
+ `https://appstoreconnect.apple.com/apps/${args.appId}/distribution/iaps`,
301
+ ].filter(Boolean).join('\n'),
302
+ }],
303
+ };
304
+ });
305
+ server.tool('appstore_create_subscription', [
306
+ 'App Store에 자동 갱신 구독을 생성 — Subscription Group 자동 생성 포함.',
307
+ '생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
308
+ ].join(' '), {
309
+ appId: z.string().describe('App Store 앱 ID'),
310
+ productId: z.string().describe('구독 productId (예: com.example.premium.monthly)'),
311
+ name: z.string().describe('구독 이름 (스토어 노출)'),
312
+ price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $9.99 → 999, ₩9,900 → 9900)'),
313
+ currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
314
+ period: z
315
+ .enum(['monthly', 'yearly'])
316
+ .describe('구독 주기'),
317
+ extraRegions: z
318
+ .array(z.object({
319
+ currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
320
+ price: z.number().describe('가격 (최소 단위)'),
321
+ }))
322
+ .optional()
323
+ .describe('추가 지역별 명시 가격'),
324
+ bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
325
+ }, async (args) => {
326
+ const creds = requireAppStoreCreds();
327
+ const result = await createAppleSubscription({
328
+ appId: args.appId,
329
+ bundleId: args.bundleId,
330
+ productId: args.productId,
331
+ name: args.name,
332
+ price: args.price,
333
+ currency: args.currency,
334
+ period: args.period,
335
+ ...(args.extraRegions && { extraRegions: args.extraRegions }),
336
+ keyId: creds.keyId,
337
+ issuerId: creds.issuerId,
338
+ privateKey: creds.privateKey,
339
+ });
340
+ if (!result.success) {
341
+ const hint = result.errorType === 'DUPLICATE'
342
+ ? '\n이미 같은 productId가 존재해. App Store Connect에서 확인해줘.'
343
+ : result.errorType === 'PRICE_NOT_FOUND'
344
+ ? `\n가장 가까운 가격: ${JSON.stringify(result.priceNearest)}`
345
+ : '';
346
+ return { content: [{ type: 'text', text: `❌ 구독 생성 실패: ${result.error}${hint}` }] };
347
+ }
348
+ return {
349
+ content: [{
350
+ type: 'text',
351
+ text: [
352
+ `✓ App Store 구독 생성 완료`,
353
+ `productId: ${result.productId}`,
354
+ `internalId: ${result.internalId}`,
355
+ result.priceSet ? `✓ 가격 설정됨` : `⚠ 가격 미설정 (가장 가까운 가격: ${JSON.stringify(result.priceNearest)})`,
356
+ result.extraRegionsSet?.length ? `✓ 추가 지역: ${result.extraRegionsSet.join(', ')}` : '',
357
+ result.localizationAdded ? '✓ KRW 한국어 로컬라이제이션 추가됨' : '',
358
+ '',
359
+ 'App Store Connect에서 심사 제출:',
360
+ `https://appstoreconnect.apple.com/apps/${args.appId}/distribution/subscriptions`,
361
+ ].filter(Boolean).join('\n'),
362
+ }],
363
+ };
364
+ });
365
+ server.tool('appstore_list_products', 'App Store의 모든 IAP 상품(구독 + 일회성) 통합 조회. productId / internalId / name / status / type 반환.', {
366
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
367
+ }, async ({ appId }) => {
368
+ const creds = requireAppStoreCreds();
369
+ const products = await listAppleProducts({
370
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
371
+ });
372
+ return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
373
+ });
374
+ server.tool('appstore_update_product', 'App Store IAP 상품의 reference name 변경. productId / 유형은 변경 불가.', {
375
+ appId: z.string().optional().describe('App Store 앱 ID'),
376
+ bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
377
+ productId: z.string().describe('상품 ID'),
378
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
379
+ name: z.string().describe('새 reference name'),
380
+ }, async ({ appId, bundleId, productId, productType, name }) => {
381
+ if (!appId && !bundleId) {
382
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
383
+ }
384
+ const creds = requireAppStoreCreds();
385
+ const result = await updateAppleProduct({
386
+ appId, bundleId, productId, productType, name,
387
+ keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
388
+ });
389
+ if (!result.success) {
390
+ return { content: [{ type: 'text', text: `❌ 수정 실패: ${result.error}` }] };
391
+ }
392
+ return { content: [{ type: 'text', text: `✓ 수정 완료 (변경 필드: ${result.updated.join(', ') || 'none'})` }] };
393
+ });
394
+ server.tool('appstore_delete_product', '⚠️ 비가역. App Store IAP 상품 삭제. MISSING_METADATA / WAITING_FOR_REVIEW 상태만 가능 — 이미 승인(READY_FOR_SALE)된 상품은 Console에서 "Remove from sale" 해야 함.', {
395
+ appId: z.string().optional().describe('App Store 앱 ID'),
396
+ bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
397
+ productId: z.string().describe('상품 ID'),
398
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
399
+ }, async ({ appId, bundleId, productId, productType }) => {
400
+ if (!appId && !bundleId) {
401
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
402
+ }
403
+ const creds = requireAppStoreCreds();
404
+ const result = await deleteAppleProduct({
405
+ appId, bundleId, productId, productType,
406
+ keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
407
+ });
408
+ if (!result.success) {
409
+ const hint = result.errorType === 'CANNOT_DELETE'
410
+ ? '\n승인된 상품은 API 삭제 불가 — App Store Connect → 상품 → "Remove from sale"'
411
+ : '';
412
+ return { content: [{ type: 'text', text: `❌ 삭제 실패: ${result.error}${hint}` }] };
413
+ }
414
+ return { content: [{ type: 'text', text: `✓ ${productId} 삭제 완료` }] };
415
+ });
416
+ server.tool('appstore_plan_release', [
417
+ 'App Store 배포 플랜 — 편집 가능한 버전/빌드 attach/메타/스크린샷/정책 위험을 한 번에 점검하고',
418
+ '체크리스트로 반환합니다 (read-only).',
419
+ '⚠️ AI 호출자 지시: 응답의 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
420
+ '사용자 동의 후 단계별로 기존 도구(appstore_update_localization, appstore_update_whats_new,',
421
+ 'appstore_submit_for_review 등)를 호출하세요. submit_for_review는 비가역이므로 반드시 명시 동의 필요.',
422
+ ].join(' '), {
423
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id)'),
424
+ versionString: z.string().optional().describe('대상 버전 (예: 1.3.0). 미지정 시 가장 최근 편집 가능 버전'),
425
+ }, async ({ appId, versionString }) => {
426
+ const text = await buildAppStoreReleasePlan({ appId, versionString });
427
+ return { content: [{ type: 'text', text }] };
428
+ });
429
+ server.tool('appstore_submit_for_review', [
430
+ 'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
431
+ '내부 흐름: POST /reviewSubmissions(또는 CREATED 상태 재사용) → POST /reviewSubmissionItems(version attach) → PATCH submitted=true.',
432
+ 'appId와 platform은 versionId에서 자동 조회 — 별도 입력 불필요.',
433
+ '⚠️ 비가역 작업: 제출 후엔 Apple 심사가 시작되며, 메타데이터/스크린샷/빌드를 더 못 바꿈 (REJECTED/METADATA_REJECTED 시 다시 편집 가능).',
434
+ '사전 조건: 버전이 PREPARE_FOR_SUBMISSION 또는 DEVELOPER_REJECTED 상태, 빌드 attached, 모든 필수 메타데이터 채워짐.',
435
+ 'appstore_check_submission_risks로 사전 점검 권장.',
436
+ ].join(' '), {
437
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
438
+ }, async ({ versionId }) => {
439
+ const result = await appstore.submitVersionForReview(versionId);
440
+ return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
441
+ });
442
+ server.tool('appstore_cancel_review', [
443
+ 'App Store 심사 제출을 철회 — WAITING_FOR_REVIEW 상태에서만 가능.',
444
+ 'submitted=false PATCH → reviewSubmission이 READY_FOR_REVIEW로 복귀, 버전은 PREPARE_FOR_SUBMISSION 상태로 돌아가 메타데이터/빌드 수정 가능.',
445
+ '⚠️ IN_REVIEW 이상이면 Apple API가 409로 거부함 — 이 경우 App Store Connect 웹에서 직접 처리하거나 심사 결과를 기다려야 함.',
446
+ '철회 후 수정 완료 시 appstore_submit_for_review로 재제출 가능.',
447
+ ].join(' '), {
448
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
449
+ }, async ({ versionId }) => {
450
+ const result = await appstore.cancelVersionReview(versionId);
451
+ return {
452
+ content: [{
453
+ type: 'text',
454
+ text: [
455
+ `✅ 심사 철회 완료`,
456
+ ` submissionId: ${result.submissionId}`,
457
+ ` ${result.previousState} → ${result.newState}`,
458
+ ` 버전 ${result.versionId}이(가) PREPARE_FOR_SUBMISSION 상태로 복귀됨.`,
459
+ ` 메타데이터/빌드 수정 후 appstore_submit_for_review로 재제출 가능.`,
460
+ ].join('\n'),
461
+ }],
462
+ };
463
+ });
464
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerAuthTools(server: McpServer): void;
@@ -0,0 +1,67 @@
1
+ import { getMcpOAuthClient } from '../auth/constants.js';
2
+ import { startAuth, ensureFreshAccessToken } from '../auth/google-auth.js';
3
+ export function registerAuthTools(server) {
4
+ server.tool('mimi_seed_auth_start', [
5
+ 'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
6
+ '응답에 포함된 URL을 브라우저에서 열고 승인하면 localhost:9876으로 자동 콜백 → 토큰이 ~/.mimi-seed/tokens.json에 저장됨.',
7
+ '이후 playstore_*, firebase_*, admob_* 등 다른 MCP 도구 바로 호출 가능.',
8
+ '토큰 만료(invalid_rapt) / 재인증 필요 시 사용. 10분 내 완료해야 함.',
9
+ ].join(' '), {}, async () => {
10
+ const { clientId, clientSecret } = await getMcpOAuthClient();
11
+ const { url, wait } = startAuth(clientId, clientSecret);
12
+ // fire-and-forget — 토큰은 콜백 서버가 자동 저장
13
+ wait.then(() => { }, (err) => { console.error('[mimi-seed auth]', err.message); });
14
+ return {
15
+ content: [{
16
+ type: 'text',
17
+ text: [
18
+ '🔐 Google 로그인 링크 (10분 유효):',
19
+ '',
20
+ url,
21
+ '',
22
+ '이 URL을 브라우저에서 열고 Google 계정으로 승인해줘.',
23
+ '완료되면 localhost:9876으로 자동 리다이렉트되고 토큰이 저장돼.',
24
+ '이후 바로 다른 MCP 도구(playstore_*, firebase_* 등) 호출 가능.',
25
+ ].join('\n'),
26
+ }],
27
+ };
28
+ });
29
+ server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
30
+ const r = await ensureFreshAccessToken();
31
+ switch (r.status) {
32
+ case 'unauthenticated':
33
+ return {
34
+ content: [{
35
+ type: 'text',
36
+ text: `❌ [${r.error.code}] ${r.error.message}\n` +
37
+ (r.error.hint ? `→ ${r.error.hint}\n\n` : '\n') +
38
+ '터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
39
+ }],
40
+ };
41
+ case 'fresh': {
42
+ const min = Math.round(r.msUntilExpiry / 60000);
43
+ return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
44
+ }
45
+ case 'refreshed': {
46
+ const min = Math.round(r.msUntilExpiry / 60000);
47
+ return {
48
+ content: [{
49
+ type: 'text',
50
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
51
+ }],
52
+ };
53
+ }
54
+ case 'expired_refresh_failed':
55
+ return {
56
+ content: [{
57
+ type: 'text',
58
+ text: `⚠️ 토큰 만료 + 자동 갱신 실패\n` +
59
+ ` 코드: ${r.error.code}\n` +
60
+ ` ${r.error.message}\n` +
61
+ (r.error.hint ? ` → ${r.error.hint}\n` : '') +
62
+ '\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
63
+ }],
64
+ };
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerBigqueryTools(server: McpServer): void;
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import * as bigquery from '../bigquery/tools.js';
3
+ import { requireAuth } from '../helpers.js';
4
+ export function registerBigqueryTools(server) {
5
+ server.tool('bigquery_run_query', 'BigQuery SQL 쿼리 실행 (SELECT). GA4 analytics_* 테이블 분석에 사용.', {
6
+ projectId: z.string().describe('GCP 프로젝트 ID (예: ads-coffee)'),
7
+ query: z.string().describe('실행할 StandardSQL 쿼리'),
8
+ maxResults: z.number().optional().describe('최대 행 수 (기본 1000)'),
9
+ }, async ({ projectId, query, maxResults }) => {
10
+ const auth = requireAuth();
11
+ const result = await bigquery.runQuery(auth, projectId, query, maxResults ?? 1000);
12
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
13
+ });
14
+ server.tool('bigquery_list_datasets', 'BigQuery 프로젝트의 데이터셋 목록 조회', {
15
+ projectId: z.string().describe('GCP 프로젝트 ID'),
16
+ }, async ({ projectId }) => {
17
+ const auth = requireAuth();
18
+ const datasets = await bigquery.listDatasets(auth, projectId);
19
+ return { content: [{ type: 'text', text: JSON.stringify(datasets, null, 2) }] };
20
+ });
21
+ server.tool('bigquery_list_tables', 'BigQuery 데이터셋의 테이블 목록 조회', {
22
+ projectId: z.string().describe('GCP 프로젝트 ID'),
23
+ datasetId: z.string().describe('데이터셋 ID (예: analytics_530080532)'),
24
+ }, async ({ projectId, datasetId }) => {
25
+ const auth = requireAuth();
26
+ const tables = await bigquery.listTables(auth, projectId, datasetId);
27
+ return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
28
+ });
29
+ server.tool('bigquery_get_table_schema', 'BigQuery 테이블 스키마(컬럼 정보) 조회', {
30
+ projectId: z.string().describe('GCP 프로젝트 ID'),
31
+ datasetId: z.string().describe('데이터셋 ID'),
32
+ tableId: z.string().describe('테이블 ID'),
33
+ }, async ({ projectId, datasetId, tableId }) => {
34
+ const auth = requireAuth();
35
+ const schema = await bigquery.getTableSchema(auth, projectId, datasetId, tableId);
36
+ return { content: [{ type: 'text', text: JSON.stringify(schema, null, 2) }] };
37
+ });
38
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerChecksTools(server: McpServer): void;
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from '../checks/risks.js';
3
+ import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from '../checks/screenshots.js';
4
+ import { requireAuth } from '../helpers.js';
5
+ export function registerChecksTools(server) {
6
+ server.tool('playstore_check_submission_risks', [
7
+ 'Google Play 제출 전 위험 요소를 자동으로 점검합니다.',
8
+ '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
9
+ '점검 항목: 리스팅 완성도(제목/설명/짧은설명), 스크린샷 수, 아이콘, 빌드 존재 여부, 연락처.',
10
+ ].join(' '), {
11
+ packageName: z.string().describe('Android 패키지명 (예: com.example.myapp)'),
12
+ language: z.string().optional().describe('언어 코드 (기본: ko-KR)'),
13
+ }, async ({ packageName, language }) => {
14
+ const auth = requireAuth();
15
+ const risks = await checkPlayStoreRisks(auth, packageName, language ?? 'ko-KR');
16
+ const text = formatRisks(risks, 'Google Play');
17
+ return { content: [{ type: 'text', text }] };
18
+ });
19
+ server.tool('appstore_check_submission_risks', [
20
+ 'App Store 제출 전 위험 요소를 자동으로 점검합니다.',
21
+ '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
22
+ '점검 항목: 메타데이터 완성도(설명/What\'s New/키워드), 스크린샷, TestFlight 빌드, 개인정보처리방침 URL.',
23
+ 'App Store 인증이 필요합니다 (appstore_* 도구 사용 가능 상태여야 함).',
24
+ ].join(' '), {
25
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id 필드)'),
26
+ }, async ({ appId }) => {
27
+ const risks = await checkAppStoreRisks(appId);
28
+ const text = formatRisks(risks, 'App Store');
29
+ return { content: [{ type: 'text', text }] };
30
+ });
31
+ server.tool('screenshot_validate', [
32
+ '로컬 스크린샷 파일의 해상도·파일크기·종횡비를 App Store / Play Store 규격과 비교 검증합니다.',
33
+ 'PNG/JPEG 파일 경로를 배열로 받아 각 파일의 통과 여부와 문제점을 반환합니다.',
34
+ 'platform: "ios" 또는 "android" (기본: ios)',
35
+ 'iOS displayType 예시: APP_IPHONE_69, APP_IPHONE_67, APP_IPHONE_65, APP_IPAD_PRO_3GEN_129',
36
+ 'Android imageType 예시: phoneScreenshots, sevenInchScreenshots, tenInchScreenshots, featureGraphic',
37
+ '업로드 전 미리 검사하여 리젝 방지에 활용하세요.',
38
+ ].join(' '), {
39
+ filePaths: z.array(z.string()).describe('검증할 이미지 파일 절대경로 배열'),
40
+ platform: z.enum(['ios', 'android']).optional().describe('플랫폼 (기본: ios)'),
41
+ displayType: z.string().optional().describe('iOS 디스플레이 타입 (예: APP_IPHONE_67)'),
42
+ imageType: z.string().optional().describe('Android 이미지 타입 (예: phoneScreenshots)'),
43
+ }, async ({ filePaths, platform, displayType, imageType }) => {
44
+ const plat = platform ?? 'ios';
45
+ if (plat === 'ios') {
46
+ const results = validateAppStoreScreenshots(filePaths, displayType);
47
+ const text = formatValidationResults(results, 'App Store');
48
+ return { content: [{ type: 'text', text }] };
49
+ }
50
+ else {
51
+ const results = validatePlayStoreScreenshots(filePaths, imageType ?? 'phoneScreenshots');
52
+ const text = formatValidationResults(results, 'Google Play');
53
+ return { content: [{ type: 'text', text }] };
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerFirebaseTools(server: McpServer): void;