@yoonion/mimi-seed-mcp 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,75 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Required Notice: Copyright 2026 Pryzm GG (https://mimi-seed.pryzm.gg)
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncommercial Purposes
34
+
35
+ Any noncommercial purpose is a permitted purpose.
36
+
37
+ ## Personal Uses
38
+
39
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
40
+
41
+ ## Noncommercial Organizations
42
+
43
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
44
+
45
+ ## Fair Use
46
+
47
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
48
+
49
+ ## No Other Rights
50
+
51
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
52
+
53
+ ## Patent Defense
54
+
55
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
56
+
57
+ ## Violations
58
+
59
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
60
+
61
+ ## No Liability
62
+
63
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
64
+
65
+ ## Definitions
66
+
67
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
68
+
69
+ **You** refers to the individual or entity agreeing to these terms.
70
+
71
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
72
+
73
+ **Your licenses** are all the licenses granted to you for the software under these terms.
74
+
75
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -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(선택).
package/dist/auth/cli.js CHANGED
@@ -3,7 +3,7 @@ import readline from 'node:readline';
3
3
  import open from 'open';
4
4
  import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
5
5
  import { AuthError, classifyError } from './errors.js';
6
- import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
6
+ import { getMcpOAuthClient } from './constants.js';
7
7
  const args = process.argv.slice(2);
8
8
  const hasFlag = (name) => args.includes(`--${name}`);
9
9
  const flagValue = (name) => {
@@ -158,7 +158,8 @@ async function cmdLogin() {
158
158
  let url;
159
159
  let wait;
160
160
  try {
161
- const r = startAuth(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET, {
161
+ const { clientId, clientSecret } = await getMcpOAuthClient();
162
+ const r = startAuth(clientId, clientSecret, {
162
163
  timeoutMs: timeoutSec * 1000,
163
164
  });
164
165
  url = r.url;
@@ -1,2 +1,4 @@
1
- export declare const MIMI_SEED_CLIENT_ID: string;
2
- export declare const MIMI_SEED_CLIENT_SECRET: string;
1
+ export declare function getMcpOAuthClient(): Promise<{
2
+ clientId: string;
3
+ clientSecret: string;
4
+ }>;
@@ -1,11 +1,17 @@
1
- // Mimi Seed 내장 OAuth 클라이언트
2
- // 유저가 직접 만들 필요 없음 — `npx -y @yoonion/mimi-seed-mcp mimi-seed-auth`
3
- // (또는 로컬 dev 중엔 `npm run auth`) 한 줄이면 끝
4
- // 환경변수 MIMI_SEED_GOOGLE_CLIENT_ID / MIMI_SEED_GOOGLE_CLIENT_SECRET 로 오버라이드 가능
5
- // (레거시 PRESEED_GOOGLE_CLIENT_ID / PRESEED_GOOGLE_CLIENT_SECRET 도 계속 인식)
6
- export const MIMI_SEED_CLIENT_ID = process.env.MIMI_SEED_GOOGLE_CLIENT_ID ??
7
- process.env.PRESEED_GOOGLE_CLIENT_ID ??
8
- '980022244769-b6qugfe22pvfadg6ccaq699pc7nuju70.apps.googleusercontent.com';
9
- export const MIMI_SEED_CLIENT_SECRET = process.env.MIMI_SEED_GOOGLE_CLIENT_SECRET ??
10
- process.env.PRESEED_GOOGLE_CLIENT_SECRET ??
11
- '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';
1
+ const WEB_BASE = process.env.MIMI_SEED_WEB_BASE ?? 'https://mimi-seed.pryzm.gg';
2
+ let _cached = null;
3
+ export async function getMcpOAuthClient() {
4
+ const id = process.env.MIMI_SEED_GOOGLE_CLIENT_ID ??
5
+ process.env.PRESEED_GOOGLE_CLIENT_ID;
6
+ const secret = process.env.MIMI_SEED_GOOGLE_CLIENT_SECRET ??
7
+ process.env.PRESEED_GOOGLE_CLIENT_SECRET;
8
+ if (id && secret)
9
+ return { clientId: id, clientSecret: secret };
10
+ if (_cached)
11
+ return _cached;
12
+ const res = await fetch(`${WEB_BASE}/api/mcp-auth-config`);
13
+ if (!res.ok)
14
+ throw new Error(`mcp-auth-config fetch failed (${res.status})`);
15
+ _cached = (await res.json());
16
+ return _cached;
17
+ }
@@ -4,7 +4,7 @@ import open from 'open';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
- import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
7
+ import { getMcpOAuthClient } from './constants.js';
8
8
  import { AuthError, classifyError } from './errors.js';
9
9
  const SCOPES = [
10
10
  'https://www.googleapis.com/auth/firebase',
@@ -285,10 +285,11 @@ export async function ensureFreshAccessToken(marginMs = 60_000) {
285
285
  },
286
286
  };
287
287
  }
288
- // refresh 시도 — credentials.json 우선, 없으면 내장 클라이언트 사용
288
+ // refresh 시도 — credentials.json 우선, 없으면 원격에서 클라이언트 조회
289
289
  const stored = getStoredCredentials();
290
- const clientId = stored?.clientId ?? MIMI_SEED_CLIENT_ID;
291
- const clientSecret = stored?.clientSecret ?? MIMI_SEED_CLIENT_SECRET;
290
+ const { clientId: defaultId, clientSecret: defaultSecret } = await getMcpOAuthClient();
291
+ const clientId = stored?.clientId ?? defaultId;
292
+ const clientSecret = stored?.clientSecret ?? defaultSecret;
292
293
  const client = createOAuth2Client(clientId, clientSecret);
293
294
  client.setCredentials({ refresh_token: tokens.refresh_token });
294
295
  try {
@@ -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 {
package/dist/index.js CHANGED
@@ -3,10 +3,10 @@ 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
- import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './auth/constants.js';
9
+ import { getMcpOAuthClient } from './auth/constants.js';
10
10
  import * as firebase from './firebase/tools.js';
11
11
  import * as admob from './admob/tools.js';
12
12
  import * as playstore from './playstore/tools.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,
@@ -1239,7 +1360,8 @@ server.tool('mimi_seed_auth_start', [
1239
1360
  '이후 playstore_*, firebase_*, admob_* 등 다른 MCP 도구 바로 호출 가능.',
1240
1361
  '토큰 만료(invalid_rapt) / 재인증 필요 시 사용. 10분 내 완료해야 함.',
1241
1362
  ].join(' '), {}, async () => {
1242
- const { url, wait } = startAuth(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET);
1363
+ const { clientId, clientSecret } = await getMcpOAuthClient();
1364
+ const { url, wait } = startAuth(clientId, clientSecret);
1243
1365
  // fire-and-forget — 토큰은 콜백 서버가 자동 저장
1244
1366
  wait.then(() => { }, (err) => { console.error('[mimi-seed auth]', err.message); });
1245
1367
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
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": {
@@ -10,7 +10,8 @@
10
10
  "mimi-seed-playstore-auth": "dist/auth/playstore-setup-cli.js"
11
11
  },
12
12
  "files": [
13
- "dist"
13
+ "dist",
14
+ "LICENSE"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsc",
@@ -38,7 +39,7 @@
38
39
  "publishConfig": {
39
40
  "access": "public"
40
41
  },
41
- "license": "MIT",
42
+ "license": "PolyForm-Noncommercial-1.0.0",
42
43
  "engines": {
43
44
  "node": ">=20"
44
45
  },