@yoonion/mimi-seed-mcp 0.3.15 → 0.3.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.
@@ -44,6 +44,7 @@ export declare function updateVersionWhatsNew(versionId: string, locale: string,
44
44
  export declare function updateReviewNotes(versionId: string, notes: string): Promise<{
45
45
  reviewDetailId: string;
46
46
  notes: string;
47
+ created: boolean;
47
48
  }>;
48
49
  export declare function getReviewNotes(versionId: string): Promise<{
49
50
  reviewDetailId: string | null;
@@ -88,6 +89,12 @@ export declare function submitVersionForReview(versionId: string): Promise<{
88
89
  itemAttached: boolean;
89
90
  state: any;
90
91
  }>;
92
+ export declare function cancelVersionReview(versionId: string): Promise<{
93
+ submissionId: string;
94
+ previousState: string;
95
+ newState: string;
96
+ versionId: string;
97
+ }>;
91
98
  export type AppleIapType = 'CONSUMABLE' | 'NON_CONSUMABLE' | 'NON_RENEWING_SUBSCRIPTION';
92
99
  export interface CreateInAppPurchaseInput {
93
100
  appId: string;
@@ -186,8 +186,16 @@ export async function updateVersionWhatsNew(versionId, locale, fields) {
186
186
  return updateVersionLocalization(target.id, fields);
187
187
  }
188
188
  // ─── 리뷰어 노트 (appStoreReviewDetail.notes) ───
189
+ /**
190
+ * apiGet이 throw한 에러가 404(리소스 없음)인지 판별.
191
+ * apiGet은 `App Store API ${status}: ${body}` 형식으로 throw하므로 prefix로 판별.
192
+ * 404 외(401/403/500 등)는 마스킹하지 않고 그대로 throw해야 디버깅 가능.
193
+ */
194
+ function isNotFoundError(err) {
195
+ return err instanceof Error && /^App Store API 404:/.test(err.message);
196
+ }
189
197
  export async function updateReviewNotes(versionId, notes) {
190
- // 1. 기존 reviewDetail 조회
198
+ // 1. 기존 reviewDetail 조회 — 404면 신규 생성, 그 외 에러는 throw
191
199
  let reviewDetailId = null;
192
200
  try {
193
201
  const existing = await apiGet(`/appStoreVersions/${versionId}/appStoreReviewDetail`, {
@@ -195,34 +203,36 @@ export async function updateReviewNotes(versionId, notes) {
195
203
  });
196
204
  reviewDetailId = existing?.data?.id ?? null;
197
205
  }
198
- catch {
199
- // 없으면 새로 생성
206
+ catch (err) {
207
+ if (!isNotFoundError(err))
208
+ throw err;
200
209
  }
201
210
  if (reviewDetailId) {
202
- // 2a. 있으면 PATCH
203
211
  const updated = await apiPatch(`/appStoreReviewDetails/${reviewDetailId}`, {
204
- data: {
205
- type: 'appStoreReviewDetails',
206
- id: reviewDetailId,
207
- attributes: { notes },
208
- },
212
+ data: { type: 'appStoreReviewDetails', id: reviewDetailId, attributes: { notes } },
209
213
  });
210
- return { reviewDetailId, notes: updated?.data?.attributes?.notes ?? notes };
214
+ return {
215
+ reviewDetailId,
216
+ notes: updated?.data?.attributes?.notes ?? notes,
217
+ created: false,
218
+ };
211
219
  }
212
- else {
213
- // 2b. 없으면 POST (version과 relationship 연결)
214
- const created = await apiPost('/appStoreReviewDetails', {
215
- data: {
216
- type: 'appStoreReviewDetails',
217
- attributes: { notes },
218
- relationships: {
219
- appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
220
- },
220
+ // 신규 생성
221
+ const created = await apiPost('/appStoreReviewDetails', {
222
+ data: {
223
+ type: 'appStoreReviewDetails',
224
+ attributes: { notes },
225
+ relationships: {
226
+ appStoreVersion: { data: { type: 'appStoreVersions', id: versionId } },
221
227
  },
222
- });
223
- const newId = created?.data?.id ?? '';
224
- return { reviewDetailId: newId, notes: created?.data?.attributes?.notes ?? notes };
225
- }
228
+ },
229
+ });
230
+ const newId = created?.data?.id ?? '';
231
+ return {
232
+ reviewDetailId: newId,
233
+ notes: created?.data?.attributes?.notes ?? notes,
234
+ created: true,
235
+ };
226
236
  }
227
237
  export async function getReviewNotes(versionId) {
228
238
  try {
@@ -235,8 +245,11 @@ export async function getReviewNotes(versionId) {
235
245
  contactEmail: data?.data?.attributes?.contactEmail ?? null,
236
246
  };
237
247
  }
238
- catch {
239
- return { reviewDetailId: null, notes: null, contactEmail: null };
248
+ catch (err) {
249
+ if (isNotFoundError(err)) {
250
+ return { reviewDetailId: null, notes: null, contactEmail: null };
251
+ }
252
+ throw err;
240
253
  }
241
254
  }
242
255
  // ─── 빌드 ───
@@ -464,6 +477,40 @@ export async function submitVersionForReview(versionId) {
464
477
  state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
465
478
  };
466
479
  }
480
+ // ─── 심사 철회 (Cancel Review) ───
481
+ // WAITING_FOR_REVIEW 상태의 reviewSubmission에만 적용 가능.
482
+ // IN_REVIEW 진입 후에는 Apple API가 거부함 (409).
483
+ // submitted=false PATCH → version이 PREPARE_FOR_SUBMISSION으로 복귀.
484
+ export async function cancelVersionReview(versionId) {
485
+ const { appId, platform } = await getVersionAppAndPlatform(versionId);
486
+ // 취소 가능한 상태(WAITING_FOR_REVIEW)의 submission 검색
487
+ const data = await apiGet('/reviewSubmissions', {
488
+ 'filter[app]': appId,
489
+ 'filter[platform]': platform,
490
+ 'filter[state]': 'WAITING_FOR_REVIEW',
491
+ 'limit': '1',
492
+ });
493
+ const submission = data?.data?.[0];
494
+ if (!submission) {
495
+ throw new Error([
496
+ `취소 가능한 심사 제출이 없어 (WAITING_FOR_REVIEW 상태 없음).`,
497
+ `IN_REVIEW 이상은 API로 취소 불가 — App Store Connect 웹에서 직접 처리하거나`,
498
+ `Apple 심사 결과(APPROVED/REJECTED)를 기다려야 해.`,
499
+ ].join('\n'));
500
+ }
501
+ const submissionId = submission.id;
502
+ const previousState = submission.attributes?.state ?? 'WAITING_FOR_REVIEW';
503
+ // submitted=false → WAITING_FOR_REVIEW → READY_FOR_REVIEW (version은 PREPARE_FOR_SUBMISSION으로 복귀)
504
+ const patched = await apiPatch(`/reviewSubmissions/${submissionId}`, {
505
+ data: {
506
+ type: 'reviewSubmissions',
507
+ id: submissionId,
508
+ attributes: { submitted: false },
509
+ },
510
+ });
511
+ const newState = patched?.data?.attributes?.state ?? 'READY_FOR_REVIEW';
512
+ return { submissionId, previousState, newState, versionId };
513
+ }
467
514
  // ─── 인앱 구매 (IAP) 생성 ───
468
515
  // 2023년 출시된 IAP v2 API (POST /v2/inAppPurchases) 사용.
469
516
  // 흐름: (1) IAP draft 생성 → (2) 로컬라이제이션 → (3) priceSchedule → (4) submission(선택).
@@ -1,4 +1,37 @@
1
1
  import { JWT } from 'google-auth-library';
2
- export declare function getServiceAccountJson(): string | null;
2
+ /**
3
+ * 패키지별 → 레거시(default) 순으로 SA JSON을 로드.
4
+ * 여러 앱이 다른 GCP 프로젝트의 SA를 쓰는 환경 지원.
5
+ */
6
+ export declare function getServiceAccountJson(packageName?: string): string | null;
7
+ /**
8
+ * 레거시 호환 — 단일 SA 저장 (default).
9
+ */
3
10
  export declare function saveServiceAccountJson(json: string): void;
4
- export declare function getServiceAccountClient(): JWT | null;
11
+ /**
12
+ * 패키지명에 묶여 SA JSON을 저장. 여러 앱이 다른 GCP 프로젝트일 때 사용.
13
+ * ~/.mimi-seed/play-service-accounts/{packageName}.json
14
+ */
15
+ export declare function saveServiceAccountJsonForPackage(packageName: string, json: string): void;
16
+ /**
17
+ * 등록된 패키지별 SA + default(레거시) 정보 요약.
18
+ */
19
+ export declare function listRegisteredServiceAccounts(): {
20
+ perPackage: {
21
+ packageName: string;
22
+ clientEmail: string | null;
23
+ projectId: string | null;
24
+ }[];
25
+ default: {
26
+ clientEmail: string | null;
27
+ projectId: string | null;
28
+ } | null;
29
+ };
30
+ /**
31
+ * 패키지별 SA 삭제. 없으면 false.
32
+ */
33
+ export declare function deleteServiceAccountJsonForPackage(packageName: string): boolean;
34
+ /**
35
+ * SA JWT 클라이언트 생성. packageName이 주어지면 패키지별 → default 순으로 탐색.
36
+ */
37
+ export declare function getServiceAccountClient(packageName?: string): JWT | null;
@@ -2,25 +2,109 @@ import { JWT } from 'google-auth-library';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- const SA_PATH = path.join(os.homedir(), '.mimi-seed', 'play-service-account.json');
6
- export function getServiceAccountJson() {
7
- if (!fs.existsSync(SA_PATH))
8
- return null;
5
+ const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
6
+ const SA_DIR = path.join(CONFIG_DIR, 'play-service-accounts');
7
+ const LEGACY_SA_PATH = path.join(CONFIG_DIR, 'play-service-account.json');
8
+ function safeReadFile(p) {
9
9
  try {
10
- return fs.readFileSync(SA_PATH, 'utf-8');
10
+ return fs.readFileSync(p, 'utf-8');
11
11
  }
12
12
  catch {
13
13
  return null;
14
14
  }
15
15
  }
16
+ /**
17
+ * 패키지별 → 레거시(default) 순으로 SA JSON을 로드.
18
+ * 여러 앱이 다른 GCP 프로젝트의 SA를 쓰는 환경 지원.
19
+ */
20
+ export function getServiceAccountJson(packageName) {
21
+ if (packageName) {
22
+ const perPkg = path.join(SA_DIR, `${packageName}.json`);
23
+ if (fs.existsSync(perPkg)) {
24
+ const json = safeReadFile(perPkg);
25
+ if (json)
26
+ return json;
27
+ }
28
+ }
29
+ if (fs.existsSync(LEGACY_SA_PATH)) {
30
+ const json = safeReadFile(LEGACY_SA_PATH);
31
+ if (json)
32
+ return json;
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * 레거시 호환 — 단일 SA 저장 (default).
38
+ */
16
39
  export function saveServiceAccountJson(json) {
17
- const dir = path.dirname(SA_PATH);
18
- if (!fs.existsSync(dir))
19
- fs.mkdirSync(dir, { recursive: true });
20
- fs.writeFileSync(SA_PATH, json, { mode: 0o600 });
40
+ if (!fs.existsSync(CONFIG_DIR))
41
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
42
+ fs.writeFileSync(LEGACY_SA_PATH, json, { mode: 0o600 });
43
+ }
44
+ /**
45
+ * 패키지명에 묶여 SA JSON을 저장. 여러 앱이 다른 GCP 프로젝트일 때 사용.
46
+ * ~/.mimi-seed/play-service-accounts/{packageName}.json
47
+ */
48
+ export function saveServiceAccountJsonForPackage(packageName, json) {
49
+ if (!fs.existsSync(SA_DIR))
50
+ fs.mkdirSync(SA_DIR, { recursive: true });
51
+ const filePath = path.join(SA_DIR, `${packageName}.json`);
52
+ fs.writeFileSync(filePath, json, { mode: 0o600 });
53
+ }
54
+ /**
55
+ * 등록된 패키지별 SA + default(레거시) 정보 요약.
56
+ */
57
+ export function listRegisteredServiceAccounts() {
58
+ const perPackage = [];
59
+ if (fs.existsSync(SA_DIR)) {
60
+ for (const f of fs.readdirSync(SA_DIR)) {
61
+ if (!f.endsWith('.json'))
62
+ continue;
63
+ const packageName = f.replace(/\.json$/, '');
64
+ const json = safeReadFile(path.join(SA_DIR, f));
65
+ let clientEmail = null;
66
+ let projectId = null;
67
+ if (json) {
68
+ try {
69
+ const parsed = JSON.parse(json);
70
+ clientEmail = parsed.client_email ?? null;
71
+ projectId = parsed.project_id ?? null;
72
+ }
73
+ catch { /* ignore parse errors */ }
74
+ }
75
+ perPackage.push({ packageName, clientEmail, projectId });
76
+ }
77
+ }
78
+ let defaultInfo = null;
79
+ if (fs.existsSync(LEGACY_SA_PATH)) {
80
+ const json = safeReadFile(LEGACY_SA_PATH);
81
+ if (json) {
82
+ try {
83
+ const parsed = JSON.parse(json);
84
+ defaultInfo = { clientEmail: parsed.client_email ?? null, projectId: parsed.project_id ?? null };
85
+ }
86
+ catch {
87
+ defaultInfo = { clientEmail: null, projectId: null };
88
+ }
89
+ }
90
+ }
91
+ return { perPackage, default: defaultInfo };
92
+ }
93
+ /**
94
+ * 패키지별 SA 삭제. 없으면 false.
95
+ */
96
+ export function deleteServiceAccountJsonForPackage(packageName) {
97
+ const filePath = path.join(SA_DIR, `${packageName}.json`);
98
+ if (!fs.existsSync(filePath))
99
+ return false;
100
+ fs.unlinkSync(filePath);
101
+ return true;
21
102
  }
22
- export function getServiceAccountClient() {
23
- const json = getServiceAccountJson();
103
+ /**
104
+ * SA JWT 클라이언트 생성. packageName이 주어지면 패키지별 → default 순으로 탐색.
105
+ */
106
+ export function getServiceAccountClient(packageName) {
107
+ const json = getServiceAccountJson(packageName);
24
108
  if (!json)
25
109
  return null;
26
110
  try {
@@ -53,21 +53,42 @@ export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR')
53
53
  });
54
54
  return risks;
55
55
  }
56
+ // Apple의 ASC API 호출은 권한·필드명·deprecated endpoint 등으로 실패할 수 있다.
57
+ // 실패를 silent하게 null로 만들면 후속 check가 false-positive blocker를 토해낸다 —
58
+ // 이 헬퍼는 실패 사실을 risks 배열에 warning으로 기록해 사용자에게 노출시키되,
59
+ // 호출부는 null을 받아 blocker 분기를 건너뛸 수 있게 해준다.
60
+ async function safeGet(call, risks, code, title) {
61
+ try {
62
+ return await call();
63
+ }
64
+ catch (e) {
65
+ risks.push({
66
+ level: 'warning',
67
+ code: `API_ERROR_${code}`,
68
+ title: `App Store Connect 조회 실패 — ${title}`,
69
+ detail: `이 항목은 위험 분석에서 제외됨. 원인: ${e instanceof Error ? e.message : String(e)}`,
70
+ });
71
+ return null;
72
+ }
73
+ }
74
+ const APP_INFO_LIVE_STATE = 'READY_FOR_DISTRIBUTION';
56
75
  export async function checkAppStoreRisks(appId) {
57
76
  const risks = [];
58
- const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
77
+ const versions = await safeGet(() => apiGet(`/apps/${appId}/appStoreVersions`, {
59
78
  'filter[appStoreState]': APPSTORE_EDITABLE_STATES,
60
79
  'limit': '5',
61
- }).catch(() => null);
80
+ }), risks, 'VERSIONS', '편집 가능한 버전 목록');
62
81
  if (!versions?.data?.length) {
63
82
  risks.push({ level: 'warning', code: 'NO_EDITABLE_VERSION', title: '편집 가능한 버전 없음', detail: 'App Store Connect에서 새 버전을 만드세요.', fixUrl: 'https://appstoreconnect.apple.com' });
64
83
  return risks;
65
84
  }
66
85
  const versionId = versions.data[0].id;
67
- const [locs, builds, appInfo] = await Promise.all([
68
- apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`).catch(() => null),
69
- apiGet(`/apps/${appId}/builds`, { 'limit': '5', 'sort': '-uploadedDate' }).catch(() => null),
70
- apiGet(`/apps/${appId}`, { 'fields[apps]': 'privacyPolicyUrl' }).catch(() => null),
86
+ // 1) 메타데이터·스크린샷·버전 첨부 빌드는 versionId 기반으로 조회.
87
+ // 2) 개인정보 URL은 appInfoLocalization 단위(앱 단위) 조회.
88
+ const [locs, attachedBuildRel, appInfos] = await Promise.all([
89
+ safeGet(() => apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`), risks, 'LOCALIZATIONS', '버전 로컬라이제이션'),
90
+ safeGet(() => apiGet(`/appStoreVersions/${versionId}/relationships/build`), risks, 'BUILD', '버전 첨부 빌드'),
91
+ safeGet(() => apiGet(`/apps/${appId}/appInfos`, { 'fields[appInfos]': 'state', 'limit': '10' }), risks, 'APP_INFO', '앱 정보'),
71
92
  ]);
72
93
  if (!locs?.data?.length) {
73
94
  risks.push({ level: 'blocker', code: 'NO_LOCALIZATIONS', title: '로컬라이제이션 없음', detail: '메타데이터를 입력하세요.' });
@@ -84,16 +105,49 @@ export async function checkAppStoreRisks(appId) {
84
105
  }
85
106
  // screenshots depend on locs being present
86
107
  const locId = locs.data[0].id;
87
- const screenshots = await apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`).catch(() => null);
88
- if (!screenshots?.data?.length) {
108
+ const screenshots = await safeGet(() => apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`), risks, 'SCREENSHOTS', '스크린샷 셋');
109
+ if (screenshots && !screenshots?.data?.length) {
89
110
  risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '스크린샷 없음', detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.' });
90
111
  }
91
112
  }
92
- if (!builds?.data?.length) {
113
+ // 빌드 — 1차: 버전에 첨부된 빌드. 2차 폴백: 앱 전체 빌드 (예전 동작).
114
+ // attachedBuildRel?.data가 null이면 "관계 조회 성공 + 첨부 안 됨" 의미.
115
+ // attachedBuildRel 자체가 null이면 safeGet 실패 — fall back.
116
+ let buildVerdict;
117
+ if (attachedBuildRel) {
118
+ buildVerdict = attachedBuildRel.data ? 'present' : 'missing';
119
+ }
120
+ else {
121
+ const fallbackBuilds = await safeGet(() => apiGet(`/builds`, {
122
+ 'filter[app]': appId,
123
+ 'fields[builds]': 'version,uploadedDate,processingState',
124
+ 'sort': '-uploadedDate',
125
+ 'limit': '5',
126
+ }), risks, 'BUILDS_FALLBACK', '앱 빌드 목록(폴백)');
127
+ buildVerdict = fallbackBuilds?.data?.length ? 'present' : fallbackBuilds ? 'missing' : 'unknown';
128
+ }
129
+ if (buildVerdict === 'missing') {
93
130
  risks.push({ level: 'blocker', code: 'NO_BUILD', title: 'TestFlight 빌드 없음', detail: 'Xcode 또는 Fastlane으로 빌드를 업로드하세요.' });
94
131
  }
95
- if (!appInfo?.data?.attributes?.privacyPolicyUrl) {
96
- risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다.' });
132
+ // 개인정보처리방침 — appInfoLocalization 어디 한 곳이라도 URL 또는 in-app 텍스트가 있으면 OK.
133
+ // appInfos 조회 자체가 실패하면 unknown으로 두고 blocker 띄움 (warning만 기록됨).
134
+ if (appInfos) {
135
+ const infos = (appInfos.data ?? []);
136
+ const stateOf = (i) => i.attributes?.state ?? i.attributes?.appStoreState ?? '';
137
+ const editable = infos.find((i) => stateOf(i) !== APP_INFO_LIVE_STATE) ?? infos[0];
138
+ if (editable) {
139
+ const localizations = await safeGet(() => apiGet(`/appInfos/${editable.id}/appInfoLocalizations`, {
140
+ 'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
141
+ 'limit': '200',
142
+ }), risks, 'PRIVACY', '개인정보 로컬라이제이션');
143
+ if (localizations) {
144
+ const locs = (localizations.data ?? []);
145
+ const hasPrivacy = locs.some((l) => l.attributes?.privacyPolicyUrl || l.attributes?.privacyPolicyText);
146
+ if (!hasPrivacy) {
147
+ risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다. 모든 로컬라이제이션에서 privacyPolicyUrl 또는 privacyPolicyText 중 하나를 입력하세요.' });
148
+ }
149
+ }
150
+ }
97
151
  }
98
152
  return risks;
99
153
  }
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
  import { getAuthenticatedClient, startAuth, ensureFreshAccessToken } from './auth/google-auth.js';
6
- import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
6
+ import { getServiceAccountClient, getServiceAccountJson, saveServiceAccountJsonForPackage, listRegisteredServiceAccounts, deleteServiceAccountJsonForPackage, } from './auth/playstore-auth.js';
7
7
  import { getAppStoreCredentials } from './appstore/auth.js';
8
8
  import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
9
9
  import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './auth/constants.js';
@@ -60,14 +60,14 @@ const APPSTORE_AUTH_HINT = [
60
60
  'Issuer ID, Key ID, .p8 파일 경로를 입력하면 저장 완료.',
61
61
  '그 다음에 다시 물어봐줘.',
62
62
  ].join('\n');
63
- function requirePlayStoreAuth() {
64
- const client = getServiceAccountClient();
63
+ function requirePlayStoreAuth(packageName) {
64
+ const client = getServiceAccountClient(packageName);
65
65
  if (!client)
66
66
  throw new Error(PLAY_AUTH_HINT);
67
67
  return client;
68
68
  }
69
- function requireServiceAccountJson() {
70
- const json = getServiceAccountJson();
69
+ function requireServiceAccountJson(packageName) {
70
+ const json = getServiceAccountJson(packageName);
71
71
  if (!json)
72
72
  throw new Error(PLAY_AUTH_HINT);
73
73
  return json;
@@ -301,7 +301,7 @@ server.tool('admob_create_ad_unit', 'AdMob 광고 단위 생성 (v1beta — Limi
301
301
  // Google Play Store Tools
302
302
  // ══════════════════════════════════════════════════
303
303
  server.tool('playstore_get_app', 'Google Play 앱 상세 정보 조회', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
304
- const auth = requirePlayStoreAuth();
304
+ const auth = requirePlayStoreAuth(packageName);
305
305
  const details = await playstore.getAppDetails(auth, packageName);
306
306
  return { content: [{ type: 'text', text: JSON.stringify(details, null, 2) }] };
307
307
  });
@@ -309,7 +309,7 @@ server.tool('playstore_get_listing', 'Google Play 스토어 리스팅 조회 (
309
309
  packageName: z.string().describe('패키지명'),
310
310
  language: z.string().default('ko-KR').describe('언어 코드 (기본: ko-KR)'),
311
311
  }, async ({ packageName, language }) => {
312
- const auth = requirePlayStoreAuth();
312
+ const auth = requirePlayStoreAuth(packageName);
313
313
  const listing = await playstore.getListing(auth, packageName, language);
314
314
  return { content: [{ type: 'text', text: JSON.stringify(listing, null, 2) }] };
315
315
  });
@@ -320,14 +320,14 @@ server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정
320
320
  shortDescription: z.string().optional().describe('짧은 설명 (80자 이내)'),
321
321
  fullDescription: z.string().optional().describe('전체 설명 (4000자 이내)'),
322
322
  }, async ({ packageName, language, title, shortDescription, fullDescription }) => {
323
- const auth = requirePlayStoreAuth();
323
+ const auth = requirePlayStoreAuth(packageName);
324
324
  const result = await playstore.updateListing(auth, packageName, language, {
325
325
  title, shortDescription, fullDescription,
326
326
  });
327
327
  return { content: [{ type: 'text', text: `수정 완료:\n${JSON.stringify(result, null, 2)}` }] };
328
328
  });
329
329
  server.tool('playstore_list_tracks', 'Google Play 릴리스 트랙 현황 (프로덕션/베타/알파/내부)', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
330
- const auth = requirePlayStoreAuth();
330
+ const auth = requirePlayStoreAuth(packageName);
331
331
  const tracks = await playstore.listTracks(auth, packageName);
332
332
  return { content: [{ type: 'text', text: JSON.stringify(tracks, null, 2) }] };
333
333
  });
@@ -336,7 +336,7 @@ server.tool('playstore_list_images', 'Google Play 리스팅 이미지 목록 조
336
336
  language: z.string().describe('언어 코드 (예: ko-KR)'),
337
337
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']).describe('이미지 타입'),
338
338
  }, async ({ packageName, language, imageType }) => {
339
- const auth = requirePlayStoreAuth();
339
+ const auth = requirePlayStoreAuth(packageName);
340
340
  const images = await playstore.listImages(auth, packageName, language, imageType);
341
341
  return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
342
342
  });
@@ -346,7 +346,7 @@ server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일
346
346
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
347
347
  filePath: z.string().describe('업로드할 이미지 절대 경로'),
348
348
  }, async ({ packageName, language, imageType, filePath }) => {
349
- const auth = requirePlayStoreAuth();
349
+ const auth = requirePlayStoreAuth(packageName);
350
350
  const result = await playstore.uploadImage(auth, packageName, language, imageType, filePath);
351
351
  return { content: [{ type: 'text', text: `✅ 업로드 완료\n${JSON.stringify(result, null, 2)}` }] };
352
352
  });
@@ -355,7 +355,7 @@ server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageTy
355
355
  language: z.string().describe('언어 코드'),
356
356
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
357
357
  }, async ({ packageName, language, imageType }) => {
358
- const auth = requirePlayStoreAuth();
358
+ const auth = requirePlayStoreAuth(packageName);
359
359
  const result = await playstore.deleteAllImages(auth, packageName, language, imageType);
360
360
  return { content: [{ type: 'text', text: `✅ 전체 삭제\n${JSON.stringify(result, null, 2)}` }] };
361
361
  });
@@ -365,7 +365,7 @@ server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄
365
365
  imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
366
366
  filePaths: z.array(z.string()).describe('업로드할 이미지 절대 경로 배열 (순서 = 노출 순서)'),
367
367
  }, async ({ packageName, language, imageType, filePaths }) => {
368
- const auth = requirePlayStoreAuth();
368
+ const auth = requirePlayStoreAuth(packageName);
369
369
  const result = await playstore.replaceImages(auth, packageName, language, imageType, filePaths);
370
370
  return { content: [{ type: 'text', text: `✅ ${result.count}장 교체 완료\n${JSON.stringify(result, null, 2)}` }] };
371
371
  });
@@ -376,7 +376,7 @@ server.tool('playstore_update_release_notes', "Google Play 트랙 릴리스의 '
376
376
  language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
377
377
  text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
378
378
  }, async ({ packageName, track, versionCode, language, text }) => {
379
- const auth = requirePlayStoreAuth();
379
+ const auth = requirePlayStoreAuth(packageName);
380
380
  const result = await playstore.updateReleaseNotes(auth, packageName, track, versionCode, language, text);
381
381
  return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode} ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
382
382
  });
@@ -386,12 +386,12 @@ server.tool('playstore_update_latest_release_notes', "Google Play 트랙의 최
386
386
  language: z.string().describe('언어 코드 (예: ko-KR)'),
387
387
  text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
388
388
  }, async ({ packageName, track, language, text }) => {
389
- const auth = requirePlayStoreAuth();
389
+ const auth = requirePlayStoreAuth(packageName);
390
390
  const result = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
391
391
  return { content: [{ type: 'text', text: `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(result.updatedVersionCodes)}) ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
392
392
  });
393
393
  server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
394
- const auth = requirePlayStoreAuth();
394
+ const auth = requirePlayStoreAuth(packageName);
395
395
  const reviews = await playstore.listReviews(auth, packageName);
396
396
  return { content: [{ type: 'text', text: JSON.stringify(reviews, null, 2) }] };
397
397
  });
@@ -400,17 +400,17 @@ server.tool('playstore_reply_review', 'Google Play 리뷰에 답변', {
400
400
  reviewId: z.string().describe('리뷰 ID'),
401
401
  replyText: z.string().describe('답변 내용'),
402
402
  }, async ({ packageName, reviewId, replyText }) => {
403
- const auth = requirePlayStoreAuth();
403
+ const auth = requirePlayStoreAuth(packageName);
404
404
  const result = await playstore.replyToReview(auth, packageName, reviewId, replyText);
405
405
  return { content: [{ type: 'text', text: `답변 완료:\n${JSON.stringify(result, null, 2)}` }] };
406
406
  });
407
407
  server.tool('playstore_list_inapp_products', 'Google Play 인앱 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
408
- const auth = requirePlayStoreAuth();
408
+ const auth = requirePlayStoreAuth(packageName);
409
409
  const products = await playstore.listInAppProducts(auth, packageName);
410
410
  return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
411
411
  });
412
412
  server.tool('playstore_list_subscriptions', 'Google Play 구독 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
413
- const auth = requirePlayStoreAuth();
413
+ const auth = requirePlayStoreAuth(packageName);
414
414
  const subs = await playstore.listSubscriptions(auth, packageName);
415
415
  return { content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }] };
416
416
  });
@@ -437,7 +437,7 @@ server.tool('playstore_create_onetime_product', [
437
437
  .optional()
438
438
  .describe('추가 지역별 명시 가격. 자동 환산이 부정확한 KRW/JPY 등에 직접 지정.'),
439
439
  }, async (args) => {
440
- const json = requireServiceAccountJson();
440
+ const json = requireServiceAccountJson(args.packageName);
441
441
  const result = await createGoogleOneTimePurchase({
442
442
  packageName: args.packageName,
443
443
  productId: args.productId,
@@ -488,7 +488,7 @@ server.tool('playstore_create_subscription', [
488
488
  .optional()
489
489
  .describe('추가 지역별 명시 가격'),
490
490
  }, async (args) => {
491
- const json = requireServiceAccountJson();
491
+ const json = requireServiceAccountJson(args.packageName);
492
492
  const result = await createGoogleSubscription({
493
493
  packageName: args.packageName,
494
494
  productId: args.productId,
@@ -584,6 +584,96 @@ server.tool('playstore_verify_service_account', [
584
584
  }
585
585
  return { content: [{ type: 'text', text: lines.join('\n') }] };
586
586
  });
587
+ server.tool('playstore_register_service_account', [
588
+ 'Google Play 서비스 계정 JSON을 패키지 단위로 등록 (~/.mimi-seed/play-service-accounts/{packageName}.json, 0600 mode).',
589
+ '여러 앱이 서로 다른 GCP 프로젝트의 SA를 쓰는 환경 지원. 등록 후 해당 packageName으로 호출하는 모든 playstore_* 도구가 이 SA를 사용 (등록 안 된 패키지는 ~/.mimi-seed/play-service-account.json 의 default SA로 폴백).',
590
+ '먼저 playstore_verify_service_account 로 권한 확인 후 등록을 권장.',
591
+ ].join(' '), {
592
+ packageName: z.string().describe('Android 패키지명 (예: gg.pryzm.weather)'),
593
+ serviceAccountJson: z.string().describe('서비스 계정 JSON 전체 내용 (문자열)'),
594
+ skipVerify: z.boolean().optional().describe('true면 사전 검증 건너뜀 (기본 false: 등록 전 verifyServiceAccountJson 실행)'),
595
+ }, async ({ packageName, serviceAccountJson, skipVerify }) => {
596
+ if (!skipVerify) {
597
+ const verify = await playstore.verifyServiceAccountJson(serviceAccountJson, packageName);
598
+ if (!verify.ok) {
599
+ return {
600
+ content: [{
601
+ type: 'text',
602
+ text: [
603
+ `❌ 검증 실패 (stage: ${verify.stage})로 등록 중단.`,
604
+ verify.message,
605
+ '',
606
+ `검증을 건너뛰고 강제 등록하려면 skipVerify=true 옵션 추가.`,
607
+ ].join('\n'),
608
+ }],
609
+ };
610
+ }
611
+ }
612
+ let clientEmail = '';
613
+ let projectId = '';
614
+ try {
615
+ const parsed = JSON.parse(serviceAccountJson);
616
+ clientEmail = parsed.client_email ?? '';
617
+ projectId = parsed.project_id ?? '';
618
+ }
619
+ catch {
620
+ return { content: [{ type: 'text', text: '❌ JSON 파싱 실패 — 서비스 계정 JSON 형식이 올바르지 않음.' }] };
621
+ }
622
+ saveServiceAccountJsonForPackage(packageName, serviceAccountJson);
623
+ return {
624
+ content: [{
625
+ type: 'text',
626
+ text: [
627
+ `✓ ${packageName} 서비스 계정 등록 완료`,
628
+ '',
629
+ `**clientEmail**: \`${clientEmail}\``,
630
+ `**projectId**: \`${projectId}\``,
631
+ `**저장 경로**: \`~/.mimi-seed/play-service-accounts/${packageName}.json\` (0600)`,
632
+ '',
633
+ '이후 이 packageName으로 호출하는 모든 playstore_* 도구가 자동으로 이 SA 사용.',
634
+ ].join('\n'),
635
+ }],
636
+ };
637
+ });
638
+ server.tool('playstore_list_service_accounts', '등록된 패키지별 서비스 계정 + default(레거시) SA 정보 요약. clientEmail / projectId 만 노출 (private_key 미노출).', {}, async () => {
639
+ const info = listRegisteredServiceAccounts();
640
+ const lines = [];
641
+ if (info.default) {
642
+ lines.push('**Default (legacy)**: `~/.mimi-seed/play-service-account.json`');
643
+ lines.push(` - clientEmail: \`${info.default.clientEmail ?? '(parse error)'}\``);
644
+ lines.push(` - projectId: \`${info.default.projectId ?? '(parse error)'}\``);
645
+ lines.push('');
646
+ }
647
+ else {
648
+ lines.push('**Default (legacy)**: 미등록');
649
+ lines.push('');
650
+ }
651
+ if (info.perPackage.length === 0) {
652
+ lines.push('**Per-package**: 없음');
653
+ lines.push('');
654
+ lines.push('등록 방법: `playstore_register_service_account(packageName, serviceAccountJson)`');
655
+ }
656
+ else {
657
+ lines.push(`**Per-package** (${info.perPackage.length}개):`);
658
+ for (const item of info.perPackage) {
659
+ lines.push(`- \`${item.packageName}\` → \`${item.clientEmail ?? '(parse error)'}\` (project: \`${item.projectId ?? 'unknown'}\`)`);
660
+ }
661
+ }
662
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
663
+ });
664
+ server.tool('playstore_delete_service_account', '등록된 패키지별 서비스 계정 삭제. default(레거시) SA는 영향 없음.', {
665
+ packageName: z.string().describe('삭제할 패키지명'),
666
+ }, async ({ packageName }) => {
667
+ const deleted = deleteServiceAccountJsonForPackage(packageName);
668
+ return {
669
+ content: [{
670
+ type: 'text',
671
+ text: deleted
672
+ ? `✓ ${packageName} 서비스 계정 파일 삭제 완료. 이후 이 패키지는 default SA로 폴백.`
673
+ : `(skip) ${packageName} 등록된 패키지별 SA 없음.`,
674
+ }],
675
+ };
676
+ });
587
677
  // ══════════════════════════════════════════════════
588
678
  // Google Cloud IAM Tools
589
679
  // ══════════════════════════════════════════════════
@@ -831,15 +921,17 @@ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기
831
921
  const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
832
922
  return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
833
923
  });
834
- server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용.", {
924
+ server.tool('appstore_update_review_notes', "App Store 심사 리뷰어 노트(Notes for App Review) 등록/수정. versionId 버전에 appStoreReviewDetail.notes를 PATCH하거나 없으면 POST로 생성. 심사 시 리뷰어에게 전달되는 테스트 계정·기능 안내 텍스트 작성에 사용. 4000자 권장 한도.", {
835
925
  versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
836
- notes: z.string().describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내 권장.'),
926
+ notes: z.string().min(1).max(4000).describe('리뷰어에게 전달할 메모 (테스트 계정, 주요 변경사항, 접근 방법 등). 4000자 이내.'),
837
927
  }, async ({ versionId, notes }) => {
838
928
  const result = await appstore.updateReviewNotes(versionId, notes);
929
+ const action = result.created ? 'created' : 'updated';
930
+ const summary = `✅ 리뷰어 노트 ${result.created ? '신규 등록' : '수정'} 완료 (reviewDetailId: ${result.reviewDetailId})`;
839
931
  return {
840
932
  content: [{
841
933
  type: 'text',
842
- text: `✅ 리뷰어 노트가 저장됐어.\n\nreviewDetailId: ${result.reviewDetailId}\n\n${result.notes}`,
934
+ text: `${summary}\n\n${JSON.stringify({ ok: true, action, ...result }, null, 2)}`,
843
935
  }],
844
936
  };
845
937
  });
@@ -848,12 +940,18 @@ server.tool('appstore_get_review_notes', "App Store 심사 리뷰어 노트(Note
848
940
  }, async ({ versionId }) => {
849
941
  const result = await appstore.getReviewNotes(versionId);
850
942
  if (!result.reviewDetailId) {
851
- return { content: [{ type: 'text', text: '이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.' }] };
943
+ return {
944
+ content: [{
945
+ type: 'text',
946
+ text: `이 버전에는 아직 리뷰어 노트가 없어. appstore_update_review_notes로 등록해줘.\n\n${JSON.stringify({ ok: true, exists: false }, null, 2)}`,
947
+ }],
948
+ };
852
949
  }
950
+ const summary = `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`;
853
951
  return {
854
952
  content: [{
855
953
  type: 'text',
856
- text: `reviewDetailId: ${result.reviewDetailId}\ncontactEmail: ${result.contactEmail ?? '(없음)'}\n\n노트:\n${result.notes ?? '(비어있음)'}`,
954
+ text: `${summary}\n\n${JSON.stringify({ ok: true, exists: true, ...result }, null, 2)}`,
857
955
  }],
858
956
  };
859
957
  });
@@ -1049,7 +1147,7 @@ server.tool('appstore_create_subscription', [
1049
1147
  server.tool('playstore_list_products', 'Google Play의 모든 IAP 상품(구독 + 일회성) 통합 조회. productId / name / status / type / price / currency 반환.', {
1050
1148
  packageName: z.string().describe('패키지명'),
1051
1149
  }, async ({ packageName }) => {
1052
- const json = requireServiceAccountJson();
1150
+ const json = requireServiceAccountJson(packageName);
1053
1151
  const products = await listGoogleProducts({ packageName, serviceAccountKey: json });
1054
1152
  return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
1055
1153
  });
@@ -1059,7 +1157,7 @@ server.tool('playstore_update_product', 'Google Play IAP 상품의 표시 이름
1059
1157
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
1060
1158
  name: z.string().describe('새 표시 이름'),
1061
1159
  }, async ({ packageName, productId, productType, name }) => {
1062
- const json = requireServiceAccountJson();
1160
+ const json = requireServiceAccountJson(packageName);
1063
1161
  const result = await updateGoogleProduct({
1064
1162
  packageName, productId, productType, name, serviceAccountKey: json,
1065
1163
  });
@@ -1073,7 +1171,7 @@ server.tool('playstore_delete_product', '⚠️ 비가역. Google Play IAP 상
1073
1171
  productId: z.string().describe('상품 ID'),
1074
1172
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
1075
1173
  }, async ({ packageName, productId, productType }) => {
1076
- const json = requireServiceAccountJson();
1174
+ const json = requireServiceAccountJson(packageName);
1077
1175
  const result = await deleteGoogleProduct({
1078
1176
  packageName, productId, productType, serviceAccountKey: json,
1079
1177
  });
@@ -1146,7 +1244,7 @@ server.tool('playstore_plan_release', [
1146
1244
  track: z.enum(['production', 'beta', 'alpha', 'internal']).optional().describe('대상 트랙 (기본: production)'),
1147
1245
  language: z.string().optional().describe('점검할 리스팅 언어 (기본: ko-KR)'),
1148
1246
  }, async ({ packageName, versionCode, track, language }) => {
1149
- const auth = requirePlayStoreAuth();
1247
+ const auth = requirePlayStoreAuth(packageName);
1150
1248
  const text = await buildPlayStoreReleasePlan({
1151
1249
  auth,
1152
1250
  packageName,
@@ -1183,6 +1281,29 @@ server.tool('appstore_submit_for_review', [
1183
1281
  const result = await appstore.submitVersionForReview(versionId);
1184
1282
  return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
1185
1283
  });
1284
+ // --- App Store 심사 철회 (Cancel Review) ---
1285
+ server.tool('appstore_cancel_review', [
1286
+ 'App Store 심사 제출을 철회 — WAITING_FOR_REVIEW 상태에서만 가능.',
1287
+ 'submitted=false PATCH → reviewSubmission이 READY_FOR_REVIEW로 복귀, 버전은 PREPARE_FOR_SUBMISSION 상태로 돌아가 메타데이터/빌드 수정 가능.',
1288
+ '⚠️ IN_REVIEW 이상이면 Apple API가 409로 거부함 — 이 경우 App Store Connect 웹에서 직접 처리하거나 심사 결과를 기다려야 함.',
1289
+ '철회 후 수정 완료 시 appstore_submit_for_review로 재제출 가능.',
1290
+ ].join(' '), {
1291
+ versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
1292
+ }, async ({ versionId }) => {
1293
+ const result = await appstore.cancelVersionReview(versionId);
1294
+ return {
1295
+ content: [{
1296
+ type: 'text',
1297
+ text: [
1298
+ `✅ 심사 철회 완료`,
1299
+ ` submissionId: ${result.submissionId}`,
1300
+ ` ${result.previousState} → ${result.newState}`,
1301
+ ` 버전 ${result.versionId}이(가) PREPARE_FOR_SUBMISSION 상태로 복귀됨.`,
1302
+ ` 메타데이터/빌드 수정 후 appstore_submit_for_review로 재제출 가능.`,
1303
+ ].join('\n'),
1304
+ }],
1305
+ };
1306
+ });
1186
1307
  // --- Play Store 심사 제출 / release status 변경 ---
1187
1308
  server.tool('playstore_submit_release', [
1188
1309
  'Google Play 트랙의 release status를 변경 — 일반적으로 draft → completed로 바꿔 검토/배포 큐에 진입시킬 때 사용.',
@@ -1195,7 +1316,7 @@ server.tool('playstore_submit_release', [
1195
1316
  versionCode: z.string().describe('대상 versionCode (문자열)'),
1196
1317
  status: z.enum(['draft', 'inProgress', 'completed', 'halted']).optional().describe('새 status (기본: completed)'),
1197
1318
  }, async ({ packageName, track, versionCode, status }) => {
1198
- const auth = requirePlayStoreAuth();
1319
+ const auth = requirePlayStoreAuth(packageName);
1199
1320
  const result = await playstore.submitRelease(auth, packageName, track, versionCode, status);
1200
1321
  return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode}: ${result.previousStatus} → ${result.newStatus}\n\n${JSON.stringify(result, null, 2)}` }] };
1201
1322
  });
@@ -1221,7 +1342,7 @@ server.tool('playstore_promote_release', [
1221
1342
  text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
1222
1343
  })).optional().describe('대상 트랙용 릴리스 노트 (미지정 + copyReleaseNotes=true면 source 그대로)'),
1223
1344
  }, async ({ packageName, fromTrack, toTrack, versionCode, status, userFraction, releaseName, copyReleaseNotes, releaseNotes }) => {
1224
- const auth = requirePlayStoreAuth();
1345
+ const auth = requirePlayStoreAuth(packageName);
1225
1346
  const result = await playstore.promoteRelease(auth, packageName, fromTrack, toTrack, versionCode, {
1226
1347
  status,
1227
1348
  userFraction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {