@yoonion/mimi-seed-mcp 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/appstore/tools.d.ts +66 -0
- package/dist/appstore/tools.js +299 -0
- package/dist/auth/playstore-auth.d.ts +4 -0
- package/dist/auth/playstore-auth.js +37 -0
- package/dist/auth/playstore-setup-cli.d.ts +2 -0
- package/dist/auth/playstore-setup-cli.js +68 -0
- package/dist/index.js +282 -16
- package/dist/playstore/tools.d.ts +18 -17
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
56
56
|
| Firebase | `firebase_list_projects` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_enable_service` |
|
|
57
57
|
| AdMob | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
|
|
58
58
|
| Google Play | `playstore_list_tracks` / `playstore_update_listing` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
|
|
59
|
+
| 인앱 결제 | `playstore_create_onetime_product` / `playstore_create_subscription` / `appstore_create_inapp_purchase` / `appstore_create_subscription` |
|
|
59
60
|
| Google Cloud IAM | `iam_list_service_accounts` / `iam_create_service_account` / `iam_list_keys` / `iam_create_key` / `iam_add_iam_policy_binding` |
|
|
60
61
|
| App Store Connect | `appstore_list_apps` / `appstore_list_builds` / `appstore_upload_screenshot` / `appstore_update_whats_new` |
|
|
61
62
|
| 제출 위험 점검 | `playstore_check_submission_risks` / `appstore_check_submission_risks` |
|
package/dist/appstore/tools.d.ts
CHANGED
|
@@ -55,3 +55,69 @@ export declare function submitVersionForReview(versionId: string): Promise<{
|
|
|
55
55
|
itemAttached: boolean;
|
|
56
56
|
state: any;
|
|
57
57
|
}>;
|
|
58
|
+
export type AppleIapType = 'CONSUMABLE' | 'NON_CONSUMABLE' | 'NON_RENEWING_SUBSCRIPTION';
|
|
59
|
+
export interface CreateInAppPurchaseInput {
|
|
60
|
+
appId: string;
|
|
61
|
+
productId: string;
|
|
62
|
+
referenceName: string;
|
|
63
|
+
inAppPurchaseType: AppleIapType;
|
|
64
|
+
reviewNote?: string;
|
|
65
|
+
familySharable?: boolean;
|
|
66
|
+
locale?: string;
|
|
67
|
+
displayName: string;
|
|
68
|
+
description: string;
|
|
69
|
+
priceUsd: number;
|
|
70
|
+
baseTerritory?: string;
|
|
71
|
+
autoSubmit?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export type FailedStep = 'localization' | 'priceSchedule' | 'submission';
|
|
74
|
+
interface CreatedIapSummary {
|
|
75
|
+
iapId: string;
|
|
76
|
+
productId: string;
|
|
77
|
+
state?: string;
|
|
78
|
+
localizationId?: string;
|
|
79
|
+
priceScheduleId?: string;
|
|
80
|
+
submissionId?: string;
|
|
81
|
+
submissionState?: string;
|
|
82
|
+
failedStep?: FailedStep;
|
|
83
|
+
error?: string;
|
|
84
|
+
priceMatchWarning?: string;
|
|
85
|
+
consoleUrl: string;
|
|
86
|
+
}
|
|
87
|
+
export declare function createInAppPurchase(input: CreateInAppPurchaseInput): Promise<CreatedIapSummary>;
|
|
88
|
+
export interface CreateSubscriptionGroupInput {
|
|
89
|
+
appId: string;
|
|
90
|
+
referenceName: string;
|
|
91
|
+
}
|
|
92
|
+
export interface CreateSubscriptionInput {
|
|
93
|
+
appId: string;
|
|
94
|
+
groupReferenceName: string;
|
|
95
|
+
productId: string;
|
|
96
|
+
referenceName: string;
|
|
97
|
+
subscriptionPeriod: 'ONE_WEEK' | 'ONE_MONTH' | 'TWO_MONTHS' | 'THREE_MONTHS' | 'SIX_MONTHS' | 'ONE_YEAR';
|
|
98
|
+
reviewNote?: string;
|
|
99
|
+
familySharable?: boolean;
|
|
100
|
+
groupLevel?: number;
|
|
101
|
+
locale?: string;
|
|
102
|
+
displayName: string;
|
|
103
|
+
description: string;
|
|
104
|
+
priceUsd: number;
|
|
105
|
+
baseTerritory?: string;
|
|
106
|
+
autoSubmit?: boolean;
|
|
107
|
+
}
|
|
108
|
+
interface CreatedSubscriptionSummary {
|
|
109
|
+
subscriptionId: string;
|
|
110
|
+
groupId: string;
|
|
111
|
+
productId: string;
|
|
112
|
+
state?: string;
|
|
113
|
+
localizationId?: string;
|
|
114
|
+
pricesCreated?: boolean;
|
|
115
|
+
submissionId?: string;
|
|
116
|
+
submissionState?: string;
|
|
117
|
+
failedStep?: FailedStep;
|
|
118
|
+
error?: string;
|
|
119
|
+
priceMatchWarning?: string;
|
|
120
|
+
consoleUrl: string;
|
|
121
|
+
}
|
|
122
|
+
export declare function createAutoRenewableSubscription(input: CreateSubscriptionInput): Promise<CreatedSubscriptionSummary>;
|
|
123
|
+
export {};
|
package/dist/appstore/tools.js
CHANGED
|
@@ -374,3 +374,302 @@ export async function submitVersionForReview(versionId) {
|
|
|
374
374
|
state: submitted?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW',
|
|
375
375
|
};
|
|
376
376
|
}
|
|
377
|
+
// ─── 인앱 구매 (IAP) 생성 ───
|
|
378
|
+
// 2023년 출시된 IAP v2 API (POST /v2/inAppPurchases) 사용.
|
|
379
|
+
// 흐름: (1) IAP draft 생성 → (2) 로컬라이제이션 → (3) priceSchedule → (4) submission(선택).
|
|
380
|
+
// 가격은 territory별 pricePoint ID 기반 — IAP를 만든 뒤 GET /pricePoints로 조회해 매칭.
|
|
381
|
+
// 자동 제출(submission)은 권장 가이드라인 (스크린샷·리뷰 노트 등)이 충족돼야 통과.
|
|
382
|
+
const IAP_V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
|
|
383
|
+
async function apiPostV2(path, body) {
|
|
384
|
+
const headers = getAuthHeaders();
|
|
385
|
+
if (!headers) {
|
|
386
|
+
throw new Error('App Store Connect 인증 필요 — npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth');
|
|
387
|
+
}
|
|
388
|
+
const res = await fetch(`${IAP_V2_BASE}${path}`, {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
391
|
+
body: JSON.stringify(body),
|
|
392
|
+
});
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
const text = await res.text();
|
|
395
|
+
throw new Error(`App Store v2 ${res.status}: ${text}`);
|
|
396
|
+
}
|
|
397
|
+
const text = await res.text();
|
|
398
|
+
return text ? JSON.parse(text) : { ok: true };
|
|
399
|
+
}
|
|
400
|
+
async function findClosestPricePoint(resourceUrl, territory, targetPrice) {
|
|
401
|
+
// resourceUrl 예: '/inAppPurchases/{id}/pricePoints' 또는 '/subscriptions/{id}/pricePoints'
|
|
402
|
+
const data = await apiGet(resourceUrl, {
|
|
403
|
+
'filter[territory]': territory,
|
|
404
|
+
'limit': '200',
|
|
405
|
+
});
|
|
406
|
+
const points = (data?.data ?? []);
|
|
407
|
+
let best = null;
|
|
408
|
+
let bestDiff = Infinity;
|
|
409
|
+
for (const p of points) {
|
|
410
|
+
const priceStr = p.attributes?.customerPrice;
|
|
411
|
+
if (!priceStr)
|
|
412
|
+
continue;
|
|
413
|
+
const price = parseFloat(priceStr);
|
|
414
|
+
if (Number.isNaN(price))
|
|
415
|
+
continue;
|
|
416
|
+
const diff = Math.abs(price - targetPrice);
|
|
417
|
+
if (diff < bestDiff) {
|
|
418
|
+
bestDiff = diff;
|
|
419
|
+
best = { id: p.id, customerPrice: price };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return best;
|
|
423
|
+
}
|
|
424
|
+
const PRICE_MATCH_WARN_THRESHOLD = 0.10;
|
|
425
|
+
function priceMatchWarning(targetPrice, matched) {
|
|
426
|
+
const diff = Math.abs(matched - targetPrice);
|
|
427
|
+
if (diff < PRICE_MATCH_WARN_THRESHOLD)
|
|
428
|
+
return undefined;
|
|
429
|
+
return `요청 $${targetPrice} → 매칭 $${matched.toFixed(2)} (Apple tier 제약). 의도와 다르면 Console에서 수정.`;
|
|
430
|
+
}
|
|
431
|
+
export async function createInAppPurchase(input) {
|
|
432
|
+
const locale = input.locale ?? 'en-US';
|
|
433
|
+
const baseTerritory = input.baseTerritory ?? 'USA';
|
|
434
|
+
// 1) IAP draft 생성 (v2 endpoint)
|
|
435
|
+
const created = await apiPostV2('/inAppPurchases', {
|
|
436
|
+
data: {
|
|
437
|
+
type: 'inAppPurchases',
|
|
438
|
+
attributes: {
|
|
439
|
+
name: input.referenceName,
|
|
440
|
+
productId: input.productId,
|
|
441
|
+
inAppPurchaseType: input.inAppPurchaseType,
|
|
442
|
+
reviewNote: input.reviewNote,
|
|
443
|
+
familySharable: input.familySharable ?? false,
|
|
444
|
+
},
|
|
445
|
+
relationships: {
|
|
446
|
+
app: { data: { type: 'apps', id: input.appId } },
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
const iapId = created?.data?.id;
|
|
451
|
+
if (!iapId)
|
|
452
|
+
throw new Error(`IAP 생성 응답에 id 없음: ${JSON.stringify(created)}`);
|
|
453
|
+
const consoleUrl = `https://appstoreconnect.apple.com/apps/${input.appId}/distribution/iaps/${iapId}`;
|
|
454
|
+
const summary = {
|
|
455
|
+
iapId,
|
|
456
|
+
productId: input.productId,
|
|
457
|
+
state: created?.data?.attributes?.state,
|
|
458
|
+
consoleUrl,
|
|
459
|
+
};
|
|
460
|
+
// 2) 로컬라이제이션
|
|
461
|
+
try {
|
|
462
|
+
const loc = await apiPost('/inAppPurchaseLocalizations', {
|
|
463
|
+
data: {
|
|
464
|
+
type: 'inAppPurchaseLocalizations',
|
|
465
|
+
attributes: {
|
|
466
|
+
locale,
|
|
467
|
+
name: input.displayName,
|
|
468
|
+
description: input.description,
|
|
469
|
+
},
|
|
470
|
+
relationships: {
|
|
471
|
+
inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
summary.localizationId = loc?.data?.id;
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
summary.failedStep = 'localization';
|
|
479
|
+
summary.error = err.message;
|
|
480
|
+
return summary;
|
|
481
|
+
}
|
|
482
|
+
// 3) priceSchedule (territory pricePoint 매칭 후 생성)
|
|
483
|
+
try {
|
|
484
|
+
const matched = await findClosestPricePoint(`/inAppPurchases/${iapId}/pricePoints`, baseTerritory, input.priceUsd);
|
|
485
|
+
if (!matched) {
|
|
486
|
+
summary.failedStep = 'priceSchedule';
|
|
487
|
+
summary.error = `${baseTerritory}에서 가격 point를 찾지 못함 — Console에서 수동 설정 필요.`;
|
|
488
|
+
return summary;
|
|
489
|
+
}
|
|
490
|
+
summary.priceMatchWarning = priceMatchWarning(input.priceUsd, matched.customerPrice);
|
|
491
|
+
const priceRefId = '${INAPP_PRICE}';
|
|
492
|
+
const sched = await apiPost('/inAppPurchasePriceSchedules', {
|
|
493
|
+
data: {
|
|
494
|
+
type: 'inAppPurchasePriceSchedules',
|
|
495
|
+
relationships: {
|
|
496
|
+
inAppPurchase: { data: { type: 'inAppPurchases', id: iapId } },
|
|
497
|
+
baseTerritory: { data: { type: 'territories', id: baseTerritory } },
|
|
498
|
+
manualPrices: { data: [{ type: 'inAppPurchasePrices', id: priceRefId }] },
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
included: [
|
|
502
|
+
{
|
|
503
|
+
type: 'inAppPurchasePrices',
|
|
504
|
+
id: priceRefId,
|
|
505
|
+
attributes: { startDate: null },
|
|
506
|
+
relationships: {
|
|
507
|
+
inAppPurchasePricePoint: {
|
|
508
|
+
data: { type: 'inAppPurchasePricePoints', id: matched.id },
|
|
509
|
+
},
|
|
510
|
+
inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
|
|
511
|
+
territory: { data: { type: 'territories', id: baseTerritory } },
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
});
|
|
516
|
+
summary.priceScheduleId = sched?.data?.id;
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
summary.failedStep = 'priceSchedule';
|
|
520
|
+
summary.error = err.message;
|
|
521
|
+
return summary;
|
|
522
|
+
}
|
|
523
|
+
// 4) 자동 제출 (옵션) — 실패해도 draft는 유지
|
|
524
|
+
if (input.autoSubmit !== false) {
|
|
525
|
+
try {
|
|
526
|
+
const sub = await apiPost('/inAppPurchaseSubmissions', {
|
|
527
|
+
data: {
|
|
528
|
+
type: 'inAppPurchaseSubmissions',
|
|
529
|
+
relationships: {
|
|
530
|
+
inAppPurchaseV2: { data: { type: 'inAppPurchases', id: iapId } },
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
summary.submissionId = sub?.data?.id;
|
|
535
|
+
summary.submissionState = sub?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW';
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
summary.failedStep = 'submission';
|
|
539
|
+
summary.error = err.message;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return summary;
|
|
543
|
+
}
|
|
544
|
+
async function findOrCreateSubscriptionGroup(appId, referenceName) {
|
|
545
|
+
// 기존 그룹 검색
|
|
546
|
+
const existing = await apiGet(`/apps/${appId}/subscriptionGroups`, {
|
|
547
|
+
'fields[subscriptionGroups]': 'referenceName',
|
|
548
|
+
'limit': '200',
|
|
549
|
+
});
|
|
550
|
+
const found = (existing?.data ?? []).find((g) => g.attributes?.referenceName === referenceName);
|
|
551
|
+
if (found)
|
|
552
|
+
return found.id;
|
|
553
|
+
const created = await apiPost('/subscriptionGroups', {
|
|
554
|
+
data: {
|
|
555
|
+
type: 'subscriptionGroups',
|
|
556
|
+
attributes: { referenceName },
|
|
557
|
+
relationships: {
|
|
558
|
+
app: { data: { type: 'apps', id: appId } },
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
const id = created?.data?.id;
|
|
563
|
+
if (!id)
|
|
564
|
+
throw new Error(`subscriptionGroup 생성 실패: ${JSON.stringify(created)}`);
|
|
565
|
+
return id;
|
|
566
|
+
}
|
|
567
|
+
export async function createAutoRenewableSubscription(input) {
|
|
568
|
+
const locale = input.locale ?? 'en-US';
|
|
569
|
+
const baseTerritory = input.baseTerritory ?? 'USA';
|
|
570
|
+
// 1) group 찾거나 생성
|
|
571
|
+
const groupId = await findOrCreateSubscriptionGroup(input.appId, input.groupReferenceName);
|
|
572
|
+
// 2) subscription draft 생성
|
|
573
|
+
// groupLevel은 그룹 내 unique여야 함 — 미지정 시 attribute 자체를 안 보내고 Apple이 자동 부여하게.
|
|
574
|
+
const subAttributes = {
|
|
575
|
+
name: input.referenceName,
|
|
576
|
+
productId: input.productId,
|
|
577
|
+
subscriptionPeriod: input.subscriptionPeriod,
|
|
578
|
+
familySharable: input.familySharable ?? false,
|
|
579
|
+
};
|
|
580
|
+
if (input.reviewNote !== undefined)
|
|
581
|
+
subAttributes.reviewNote = input.reviewNote;
|
|
582
|
+
if (input.groupLevel !== undefined)
|
|
583
|
+
subAttributes.groupLevel = input.groupLevel;
|
|
584
|
+
const created = await apiPost('/subscriptions', {
|
|
585
|
+
data: {
|
|
586
|
+
type: 'subscriptions',
|
|
587
|
+
attributes: subAttributes,
|
|
588
|
+
relationships: {
|
|
589
|
+
group: { data: { type: 'subscriptionGroups', id: groupId } },
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
const subscriptionId = created?.data?.id;
|
|
594
|
+
if (!subscriptionId) {
|
|
595
|
+
throw new Error(`subscription 생성 응답에 id 없음: ${JSON.stringify(created)}`);
|
|
596
|
+
}
|
|
597
|
+
const consoleUrl = `https://appstoreconnect.apple.com/apps/${input.appId}/distribution/subscriptions/${subscriptionId}`;
|
|
598
|
+
const summary = {
|
|
599
|
+
subscriptionId,
|
|
600
|
+
groupId,
|
|
601
|
+
productId: input.productId,
|
|
602
|
+
state: created?.data?.attributes?.state,
|
|
603
|
+
consoleUrl,
|
|
604
|
+
};
|
|
605
|
+
// 3) localization
|
|
606
|
+
try {
|
|
607
|
+
const loc = await apiPost('/subscriptionLocalizations', {
|
|
608
|
+
data: {
|
|
609
|
+
type: 'subscriptionLocalizations',
|
|
610
|
+
attributes: {
|
|
611
|
+
locale,
|
|
612
|
+
name: input.displayName,
|
|
613
|
+
description: input.description,
|
|
614
|
+
},
|
|
615
|
+
relationships: {
|
|
616
|
+
subscription: { data: { type: 'subscriptions', id: subscriptionId } },
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
summary.localizationId = loc?.data?.id;
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
summary.failedStep = 'localization';
|
|
624
|
+
summary.error = err.message;
|
|
625
|
+
return summary;
|
|
626
|
+
}
|
|
627
|
+
// 4) price (subscriptionPrices — IAP의 priceSchedule보다 단순)
|
|
628
|
+
try {
|
|
629
|
+
const matched = await findClosestPricePoint(`/subscriptions/${subscriptionId}/pricePoints`, baseTerritory, input.priceUsd);
|
|
630
|
+
if (!matched) {
|
|
631
|
+
summary.failedStep = 'priceSchedule';
|
|
632
|
+
summary.error = `${baseTerritory}에서 가격 point를 찾지 못함 — Console에서 수동 설정 필요.`;
|
|
633
|
+
return summary;
|
|
634
|
+
}
|
|
635
|
+
summary.priceMatchWarning = priceMatchWarning(input.priceUsd, matched.customerPrice);
|
|
636
|
+
await apiPost('/subscriptionPrices', {
|
|
637
|
+
data: {
|
|
638
|
+
type: 'subscriptionPrices',
|
|
639
|
+
relationships: {
|
|
640
|
+
subscription: { data: { type: 'subscriptions', id: subscriptionId } },
|
|
641
|
+
subscriptionPricePoint: {
|
|
642
|
+
data: { type: 'subscriptionPricePoints', id: matched.id },
|
|
643
|
+
},
|
|
644
|
+
territory: { data: { type: 'territories', id: baseTerritory } },
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
summary.pricesCreated = true;
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
summary.failedStep = 'priceSchedule';
|
|
652
|
+
summary.error = err.message;
|
|
653
|
+
return summary;
|
|
654
|
+
}
|
|
655
|
+
// 5) auto-submit
|
|
656
|
+
if (input.autoSubmit !== false) {
|
|
657
|
+
try {
|
|
658
|
+
const sub = await apiPost('/subscriptionSubmissions', {
|
|
659
|
+
data: {
|
|
660
|
+
type: 'subscriptionSubmissions',
|
|
661
|
+
relationships: {
|
|
662
|
+
subscription: { data: { type: 'subscriptions', id: subscriptionId } },
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
summary.submissionId = sub?.data?.id;
|
|
667
|
+
summary.submissionState = sub?.data?.attributes?.state ?? 'WAITING_FOR_REVIEW';
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
summary.failedStep = 'submission';
|
|
671
|
+
summary.error = err.message;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return summary;
|
|
675
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { JWT } from 'google-auth-library';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
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;
|
|
9
|
+
try {
|
|
10
|
+
return fs.readFileSync(SA_PATH, 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
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 });
|
|
21
|
+
}
|
|
22
|
+
export function getServiceAccountClient() {
|
|
23
|
+
const json = getServiceAccountJson();
|
|
24
|
+
if (!json)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(json);
|
|
28
|
+
return new JWT({
|
|
29
|
+
email: parsed.client_email,
|
|
30
|
+
key: parsed.private_key,
|
|
31
|
+
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { saveServiceAccountJson, getServiceAccountJson } from './playstore-auth.js';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
6
|
+
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
7
|
+
async function main() {
|
|
8
|
+
console.log('');
|
|
9
|
+
console.log(' 🤖 Mimi Seed — Google Play 서비스 계정 연결');
|
|
10
|
+
console.log('');
|
|
11
|
+
const existing = getServiceAccountJson();
|
|
12
|
+
if (existing) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(existing);
|
|
15
|
+
console.log(` ✅ 이미 연결됨 (${parsed.client_email ?? '?'})`);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
console.log(' ⚠️ 저장된 서비스 계정이 있지만 파싱 오류');
|
|
19
|
+
}
|
|
20
|
+
const answer = await ask(' 다시 설정할래? (y/N): ');
|
|
21
|
+
if (answer.toLowerCase() !== 'y') {
|
|
22
|
+
rl.close();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
console.log(' Google Play 서비스 계정 키 JSON 파일이 필요해:');
|
|
27
|
+
console.log(' 1. Google Cloud Console → IAM & Admin → Service Accounts');
|
|
28
|
+
console.log(' 2. 서비스 계정 선택 → Keys → Add Key → Create new key → JSON');
|
|
29
|
+
console.log(' 3. 다운로드한 JSON 파일 경로를 입력해');
|
|
30
|
+
console.log('');
|
|
31
|
+
const jsonPath = await ask(' 서비스 계정 JSON 파일 경로: ');
|
|
32
|
+
const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
|
|
33
|
+
if (!fs.existsSync(trimmedPath)) {
|
|
34
|
+
console.log(` ❌ 파일 없음: ${trimmedPath}`);
|
|
35
|
+
rl.close();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const json = fs.readFileSync(trimmedPath, 'utf-8');
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(json);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
console.log(' ❌ JSON 파싱 실패 — 올바른 서비스 계정 키 파일인지 확인해줘');
|
|
45
|
+
rl.close();
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
if (parsed.type !== 'service_account' || !parsed.client_email || !parsed.private_key) {
|
|
49
|
+
console.log(' ❌ 서비스 계정 JSON 형식이 아니야.');
|
|
50
|
+
console.log(' type="service_account", client_email, private_key 필드가 있어야 해.');
|
|
51
|
+
rl.close();
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
saveServiceAccountJson(json);
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(` ✅ 저장 완료! (${parsed.client_email})`);
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(' 이제 Claude Code에서:');
|
|
59
|
+
console.log(' "내 Play 스토어 앱 리스팅 보여줘"');
|
|
60
|
+
console.log(' "Play 구독 상품 목록 보여줘"');
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(' ⚠️ Play Console 권한 확인:');
|
|
63
|
+
console.log(' Play Console → Users and permissions → 이 서비스 계정 추가');
|
|
64
|
+
console.log(' 앱 권한에서 "View financial data" + "Manage store listing" 체크');
|
|
65
|
+
console.log('');
|
|
66
|
+
rl.close();
|
|
67
|
+
}
|
|
68
|
+
main();
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,9 @@ 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, getStoredTokens, startAuth } from './auth/google-auth.js';
|
|
6
|
+
import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
|
|
7
|
+
import { getAppStoreCredentials } from './appstore/auth.js';
|
|
8
|
+
import { createGoogleOneTimePurchase, createGoogleSubscription, createAppleOneTimePurchase, createAppleSubscription } from '@onesub/providers';
|
|
6
9
|
import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './auth/constants.js';
|
|
7
10
|
import * as firebase from './firebase/tools.js';
|
|
8
11
|
import * as admob from './admob/tools.js';
|
|
@@ -35,6 +38,44 @@ function requireAuth() {
|
|
|
35
38
|
}
|
|
36
39
|
return client;
|
|
37
40
|
}
|
|
41
|
+
const PLAY_AUTH_HINT = [
|
|
42
|
+
'❌ Google Play 서비스 계정이 연결되지 않았어.',
|
|
43
|
+
'',
|
|
44
|
+
'터미널에서 이것만 실행하면 돼:',
|
|
45
|
+
'',
|
|
46
|
+
' npx -y @yoonion/mimi-seed-mcp mimi-seed-playstore-auth',
|
|
47
|
+
'',
|
|
48
|
+
'서비스 계정 JSON 파일 경로를 입력하면 저장 완료.',
|
|
49
|
+
'그 다음에 다시 물어봐줘.',
|
|
50
|
+
].join('\n');
|
|
51
|
+
const APPSTORE_AUTH_HINT = [
|
|
52
|
+
'❌ App Store Connect 인증이 설정되지 않았어.',
|
|
53
|
+
'',
|
|
54
|
+
'터미널에서 이것만 실행하면 돼:',
|
|
55
|
+
'',
|
|
56
|
+
' npx -y @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
|
|
57
|
+
'',
|
|
58
|
+
'Issuer ID, Key ID, .p8 파일 경로를 입력하면 저장 완료.',
|
|
59
|
+
'그 다음에 다시 물어봐줘.',
|
|
60
|
+
].join('\n');
|
|
61
|
+
function requirePlayStoreAuth() {
|
|
62
|
+
const client = getServiceAccountClient();
|
|
63
|
+
if (!client)
|
|
64
|
+
throw new Error(PLAY_AUTH_HINT);
|
|
65
|
+
return client;
|
|
66
|
+
}
|
|
67
|
+
function requireServiceAccountJson() {
|
|
68
|
+
const json = getServiceAccountJson();
|
|
69
|
+
if (!json)
|
|
70
|
+
throw new Error(PLAY_AUTH_HINT);
|
|
71
|
+
return json;
|
|
72
|
+
}
|
|
73
|
+
function requireAppStoreCreds() {
|
|
74
|
+
const creds = getAppStoreCredentials();
|
|
75
|
+
if (!creds)
|
|
76
|
+
throw new Error(APPSTORE_AUTH_HINT);
|
|
77
|
+
return creds;
|
|
78
|
+
}
|
|
38
79
|
// ══════════════════════════════════════════════════
|
|
39
80
|
// Firebase Tools
|
|
40
81
|
// ══════════════════════════════════════════════════
|
|
@@ -258,7 +299,7 @@ server.tool('admob_create_ad_unit', 'AdMob 광고 단위 생성 (v1beta — Limi
|
|
|
258
299
|
// Google Play Store Tools
|
|
259
300
|
// ══════════════════════════════════════════════════
|
|
260
301
|
server.tool('playstore_get_app', 'Google Play 앱 상세 정보 조회', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
|
|
261
|
-
const auth =
|
|
302
|
+
const auth = requirePlayStoreAuth();
|
|
262
303
|
const details = await playstore.getAppDetails(auth, packageName);
|
|
263
304
|
return { content: [{ type: 'text', text: JSON.stringify(details, null, 2) }] };
|
|
264
305
|
});
|
|
@@ -266,7 +307,7 @@ server.tool('playstore_get_listing', 'Google Play 스토어 리스팅 조회 (
|
|
|
266
307
|
packageName: z.string().describe('패키지명'),
|
|
267
308
|
language: z.string().default('ko-KR').describe('언어 코드 (기본: ko-KR)'),
|
|
268
309
|
}, async ({ packageName, language }) => {
|
|
269
|
-
const auth =
|
|
310
|
+
const auth = requirePlayStoreAuth();
|
|
270
311
|
const listing = await playstore.getListing(auth, packageName, language);
|
|
271
312
|
return { content: [{ type: 'text', text: JSON.stringify(listing, null, 2) }] };
|
|
272
313
|
});
|
|
@@ -277,14 +318,14 @@ server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정
|
|
|
277
318
|
shortDescription: z.string().optional().describe('짧은 설명 (80자 이내)'),
|
|
278
319
|
fullDescription: z.string().optional().describe('전체 설명 (4000자 이내)'),
|
|
279
320
|
}, async ({ packageName, language, title, shortDescription, fullDescription }) => {
|
|
280
|
-
const auth =
|
|
321
|
+
const auth = requirePlayStoreAuth();
|
|
281
322
|
const result = await playstore.updateListing(auth, packageName, language, {
|
|
282
323
|
title, shortDescription, fullDescription,
|
|
283
324
|
});
|
|
284
325
|
return { content: [{ type: 'text', text: `수정 완료:\n${JSON.stringify(result, null, 2)}` }] };
|
|
285
326
|
});
|
|
286
327
|
server.tool('playstore_list_tracks', 'Google Play 릴리스 트랙 현황 (프로덕션/베타/알파/내부)', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
287
|
-
const auth =
|
|
328
|
+
const auth = requirePlayStoreAuth();
|
|
288
329
|
const tracks = await playstore.listTracks(auth, packageName);
|
|
289
330
|
return { content: [{ type: 'text', text: JSON.stringify(tracks, null, 2) }] };
|
|
290
331
|
});
|
|
@@ -293,7 +334,7 @@ server.tool('playstore_list_images', 'Google Play 리스팅 이미지 목록 조
|
|
|
293
334
|
language: z.string().describe('언어 코드 (예: ko-KR)'),
|
|
294
335
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']).describe('이미지 타입'),
|
|
295
336
|
}, async ({ packageName, language, imageType }) => {
|
|
296
|
-
const auth =
|
|
337
|
+
const auth = requirePlayStoreAuth();
|
|
297
338
|
const images = await playstore.listImages(auth, packageName, language, imageType);
|
|
298
339
|
return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
|
|
299
340
|
});
|
|
@@ -303,7 +344,7 @@ server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일
|
|
|
303
344
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
304
345
|
filePath: z.string().describe('업로드할 이미지 절대 경로'),
|
|
305
346
|
}, async ({ packageName, language, imageType, filePath }) => {
|
|
306
|
-
const auth =
|
|
347
|
+
const auth = requirePlayStoreAuth();
|
|
307
348
|
const result = await playstore.uploadImage(auth, packageName, language, imageType, filePath);
|
|
308
349
|
return { content: [{ type: 'text', text: `✅ 업로드 완료\n${JSON.stringify(result, null, 2)}` }] };
|
|
309
350
|
});
|
|
@@ -312,7 +353,7 @@ server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageTy
|
|
|
312
353
|
language: z.string().describe('언어 코드'),
|
|
313
354
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
314
355
|
}, async ({ packageName, language, imageType }) => {
|
|
315
|
-
const auth =
|
|
356
|
+
const auth = requirePlayStoreAuth();
|
|
316
357
|
const result = await playstore.deleteAllImages(auth, packageName, language, imageType);
|
|
317
358
|
return { content: [{ type: 'text', text: `✅ 전체 삭제\n${JSON.stringify(result, null, 2)}` }] };
|
|
318
359
|
});
|
|
@@ -322,7 +363,7 @@ server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄
|
|
|
322
363
|
imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
|
|
323
364
|
filePaths: z.array(z.string()).describe('업로드할 이미지 절대 경로 배열 (순서 = 노출 순서)'),
|
|
324
365
|
}, async ({ packageName, language, imageType, filePaths }) => {
|
|
325
|
-
const auth =
|
|
366
|
+
const auth = requirePlayStoreAuth();
|
|
326
367
|
const result = await playstore.replaceImages(auth, packageName, language, imageType, filePaths);
|
|
327
368
|
return { content: [{ type: 'text', text: `✅ ${result.count}장 교체 완료\n${JSON.stringify(result, null, 2)}` }] };
|
|
328
369
|
});
|
|
@@ -333,7 +374,7 @@ server.tool('playstore_update_release_notes', "Google Play 트랙 릴리스의 '
|
|
|
333
374
|
language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
|
|
334
375
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
335
376
|
}, async ({ packageName, track, versionCode, language, text }) => {
|
|
336
|
-
const auth =
|
|
377
|
+
const auth = requirePlayStoreAuth();
|
|
337
378
|
const result = await playstore.updateReleaseNotes(auth, packageName, track, versionCode, language, text);
|
|
338
379
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode} ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
339
380
|
});
|
|
@@ -343,12 +384,12 @@ server.tool('playstore_update_latest_release_notes', "Google Play 트랙의 최
|
|
|
343
384
|
language: z.string().describe('언어 코드 (예: ko-KR)'),
|
|
344
385
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
345
386
|
}, async ({ packageName, track, language, text }) => {
|
|
346
|
-
const auth =
|
|
387
|
+
const auth = requirePlayStoreAuth();
|
|
347
388
|
const result = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
|
|
348
389
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(result.updatedVersionCodes)}) ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
349
390
|
});
|
|
350
391
|
server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
351
|
-
const auth =
|
|
392
|
+
const auth = requirePlayStoreAuth();
|
|
352
393
|
const reviews = await playstore.listReviews(auth, packageName);
|
|
353
394
|
return { content: [{ type: 'text', text: JSON.stringify(reviews, null, 2) }] };
|
|
354
395
|
});
|
|
@@ -357,20 +398,122 @@ server.tool('playstore_reply_review', 'Google Play 리뷰에 답변', {
|
|
|
357
398
|
reviewId: z.string().describe('리뷰 ID'),
|
|
358
399
|
replyText: z.string().describe('답변 내용'),
|
|
359
400
|
}, async ({ packageName, reviewId, replyText }) => {
|
|
360
|
-
const auth =
|
|
401
|
+
const auth = requirePlayStoreAuth();
|
|
361
402
|
const result = await playstore.replyToReview(auth, packageName, reviewId, replyText);
|
|
362
403
|
return { content: [{ type: 'text', text: `답변 완료:\n${JSON.stringify(result, null, 2)}` }] };
|
|
363
404
|
});
|
|
364
405
|
server.tool('playstore_list_inapp_products', 'Google Play 인앱 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
365
|
-
const auth =
|
|
406
|
+
const auth = requirePlayStoreAuth();
|
|
366
407
|
const products = await playstore.listInAppProducts(auth, packageName);
|
|
367
408
|
return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
|
|
368
409
|
});
|
|
369
410
|
server.tool('playstore_list_subscriptions', 'Google Play 구독 상품 목록', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
|
|
370
|
-
const auth =
|
|
411
|
+
const auth = requirePlayStoreAuth();
|
|
371
412
|
const subs = await playstore.listSubscriptions(auth, packageName);
|
|
372
413
|
return { content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }] };
|
|
373
414
|
});
|
|
415
|
+
server.tool('playstore_create_onetime_product', [
|
|
416
|
+
'Google Play에 일회성 인앱 상품(소비성 또는 비소비성)을 생성.',
|
|
417
|
+
'Play Console 권한: "Manage store presence" 필요. 생성 후 Console에서 활성화 필요.',
|
|
418
|
+
].join(' '), {
|
|
419
|
+
packageName: z.string().describe('패키지명 (예: com.example.app)'),
|
|
420
|
+
productId: z
|
|
421
|
+
.string()
|
|
422
|
+
.describe('상품 ID (소문자/숫자/언더스코어/점, 예: premium_unlock). 한 번 정하면 변경 불가.'),
|
|
423
|
+
name: z.string().describe('상품 이름 (스토어 노출 제목)'),
|
|
424
|
+
price: z.number().int().describe('주 통화 기준 가격 (최소 단위: USD/EUR이면 cents, KRW/JPY이면 원화 정수. 예: USD $4.99 → 499, KRW ₩5,900 → 5900)'),
|
|
425
|
+
currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
|
|
426
|
+
type: z
|
|
427
|
+
.enum(['consumable', 'non_consumable'])
|
|
428
|
+
.default('non_consumable')
|
|
429
|
+
.describe('상품 유형 (소비성/비소비성). 앱이 consumePurchase 호출하면 소비성.'),
|
|
430
|
+
extraRegions: z
|
|
431
|
+
.array(z.object({
|
|
432
|
+
currency: z.string().describe('ISO 4217 통화 코드 (예: KRW, JPY, GBP)'),
|
|
433
|
+
price: z.number().describe('가격 (최소 단위: KRW ₩1,100 → 1100, USD $0.99 → 99)'),
|
|
434
|
+
}))
|
|
435
|
+
.optional()
|
|
436
|
+
.describe('추가 지역별 명시 가격. 자동 환산이 부정확한 KRW/JPY 등에 직접 지정.'),
|
|
437
|
+
}, async (args) => {
|
|
438
|
+
const json = requireServiceAccountJson();
|
|
439
|
+
const result = await createGoogleOneTimePurchase({
|
|
440
|
+
packageName: args.packageName,
|
|
441
|
+
productId: args.productId,
|
|
442
|
+
name: args.name,
|
|
443
|
+
price: args.price,
|
|
444
|
+
currency: args.currency,
|
|
445
|
+
type: args.type,
|
|
446
|
+
...(args.extraRegions && { extraRegions: args.extraRegions }),
|
|
447
|
+
serviceAccountKey: json,
|
|
448
|
+
});
|
|
449
|
+
if (!result.success) {
|
|
450
|
+
return { content: [{ type: 'text', text: `❌ 상품 생성 실패: ${result.error}` }] };
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
content: [{
|
|
454
|
+
type: 'text',
|
|
455
|
+
text: [
|
|
456
|
+
`✓ Play 일회성 상품 생성 완료`,
|
|
457
|
+
`productId: ${result.productId}`,
|
|
458
|
+
`price: ${args.price} ${args.currency}`,
|
|
459
|
+
'',
|
|
460
|
+
'Play Console에서 활성화 확인:',
|
|
461
|
+
`https://play.google.com/console/u/0/developers/-/app/-/managed-products?package=${encodeURIComponent(args.packageName)}`,
|
|
462
|
+
].join('\n'),
|
|
463
|
+
}],
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
server.tool('playstore_create_subscription', [
|
|
467
|
+
'Google Play에 자동 갱신 구독을 생성하고 baseplan을 활성화.',
|
|
468
|
+
'구독 생성 → baseplan(가격·주기) 추가 → 자동 활성화.',
|
|
469
|
+
'이미 같은 productId가 있으면 생성 실패 (Play API 특성상 upsert 미지원).',
|
|
470
|
+
].join(' '), {
|
|
471
|
+
packageName: z.string().describe('패키지명'),
|
|
472
|
+
productId: z
|
|
473
|
+
.string()
|
|
474
|
+
.describe('구독 상품 ID (소문자/숫자/언더스코어/점, 예: premium_monthly)'),
|
|
475
|
+
name: z.string().describe('구독 제목 (스토어 노출)'),
|
|
476
|
+
price: z.number().int().describe('주 통화 기준 가격 (최소 단위: USD cents. 예: $4.99 → 499, ₩5,900 → 5900)'),
|
|
477
|
+
currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
|
|
478
|
+
period: z
|
|
479
|
+
.enum(['monthly', 'yearly'])
|
|
480
|
+
.describe('청구 주기'),
|
|
481
|
+
extraRegions: z
|
|
482
|
+
.array(z.object({
|
|
483
|
+
currency: z.string().describe('ISO 4217 통화 코드 (예: KRW, JPY)'),
|
|
484
|
+
price: z.number().describe('가격 (최소 단위)'),
|
|
485
|
+
}))
|
|
486
|
+
.optional()
|
|
487
|
+
.describe('추가 지역별 명시 가격'),
|
|
488
|
+
}, async (args) => {
|
|
489
|
+
const json = requireServiceAccountJson();
|
|
490
|
+
const result = await createGoogleSubscription({
|
|
491
|
+
packageName: args.packageName,
|
|
492
|
+
productId: args.productId,
|
|
493
|
+
name: args.name,
|
|
494
|
+
price: args.price,
|
|
495
|
+
currency: args.currency,
|
|
496
|
+
period: args.period,
|
|
497
|
+
...(args.extraRegions && { extraRegions: args.extraRegions }),
|
|
498
|
+
serviceAccountKey: json,
|
|
499
|
+
});
|
|
500
|
+
if (!result.success) {
|
|
501
|
+
return { content: [{ type: 'text', text: `❌ 구독 생성 실패: ${result.error}` }] };
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
content: [{
|
|
505
|
+
type: 'text',
|
|
506
|
+
text: [
|
|
507
|
+
`✓ Play 구독 생성 완료`,
|
|
508
|
+
`productId: ${result.productId}`,
|
|
509
|
+
`price: ${args.price} ${args.currency} / ${args.period}`,
|
|
510
|
+
'',
|
|
511
|
+
'Play Console:',
|
|
512
|
+
`https://play.google.com/console/u/0/developers/-/app/-/subscriptions?package=${encodeURIComponent(args.packageName)}`,
|
|
513
|
+
].join('\n'),
|
|
514
|
+
}],
|
|
515
|
+
};
|
|
516
|
+
});
|
|
374
517
|
server.tool('playstore_verify_service_account', [
|
|
375
518
|
"서비스 계정 JSON이 주어진 packageName에 대해 Play Developer API 호출 가능한지 + 'View financial data' 권한까지 있는지 검증.",
|
|
376
519
|
'onesub 같은 서버가 Play 영수증을 백그라운드로 검증하려면 OAuth 토큰 대신 서비스 계정 JSON이 필요 — 이 도구로 붙여넣기 전에 유효성 확인.',
|
|
@@ -688,6 +831,129 @@ server.tool('appstore_reply_review', [
|
|
|
688
831
|
const result = await appstore.createReviewResponse(reviewId, responseBody);
|
|
689
832
|
return { content: [{ type: 'text', text: `✅ 리뷰 ${reviewId}에 답변 등록됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
690
833
|
});
|
|
834
|
+
// --- App Store IAP / 구독 생성 ---
|
|
835
|
+
server.tool('appstore_create_inapp_purchase', [
|
|
836
|
+
'App Store에 일회성 인앱 구매(IAP)를 생성 — CONSUMABLE (소비성) / NON_CONSUMABLE (비소비성).',
|
|
837
|
+
'생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
|
|
838
|
+
].join(' '), {
|
|
839
|
+
appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id, 숫자형)'),
|
|
840
|
+
productId: z
|
|
841
|
+
.string()
|
|
842
|
+
.describe('상품 ID (글로벌 unique 권장: 예 com.example.coins_100)'),
|
|
843
|
+
name: z.string().describe('상품 이름 (스토어 노출, 최대 30자)'),
|
|
844
|
+
price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $0.99 → 99, ₩1,100 → 1100)'),
|
|
845
|
+
currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
|
|
846
|
+
type: z
|
|
847
|
+
.enum(['consumable', 'non_consumable'])
|
|
848
|
+
.default('non_consumable')
|
|
849
|
+
.describe('IAP 유형 (소비성/비소비성)'),
|
|
850
|
+
extraRegions: z
|
|
851
|
+
.array(z.object({
|
|
852
|
+
currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
|
|
853
|
+
price: z.number().describe('가격 (최소 단위)'),
|
|
854
|
+
}))
|
|
855
|
+
.optional()
|
|
856
|
+
.describe('추가 지역별 명시 가격'),
|
|
857
|
+
bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
|
|
858
|
+
}, async (args) => {
|
|
859
|
+
const creds = requireAppStoreCreds();
|
|
860
|
+
const result = await createAppleOneTimePurchase({
|
|
861
|
+
appId: args.appId,
|
|
862
|
+
bundleId: args.bundleId,
|
|
863
|
+
productId: args.productId,
|
|
864
|
+
name: args.name,
|
|
865
|
+
price: args.price,
|
|
866
|
+
currency: args.currency,
|
|
867
|
+
type: args.type,
|
|
868
|
+
...(args.extraRegions && { extraRegions: args.extraRegions }),
|
|
869
|
+
keyId: creds.keyId,
|
|
870
|
+
issuerId: creds.issuerId,
|
|
871
|
+
privateKey: creds.privateKey,
|
|
872
|
+
});
|
|
873
|
+
if (!result.success) {
|
|
874
|
+
const hint = result.errorType === 'DUPLICATE'
|
|
875
|
+
? '\n이미 같은 productId가 존재해. App Store Connect에서 확인해줘.'
|
|
876
|
+
: result.errorType === 'PRICE_NOT_FOUND'
|
|
877
|
+
? `\n가장 가까운 가격: ${JSON.stringify(result.priceNearest)}`
|
|
878
|
+
: '';
|
|
879
|
+
return { content: [{ type: 'text', text: `❌ IAP 생성 실패: ${result.error}${hint}` }] };
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
content: [{
|
|
883
|
+
type: 'text',
|
|
884
|
+
text: [
|
|
885
|
+
`✓ App Store IAP 생성 완료`,
|
|
886
|
+
`productId: ${result.productId}`,
|
|
887
|
+
`internalId: ${result.internalId}`,
|
|
888
|
+
result.priceSet ? `✓ 가격 설정됨` : `⚠ 가격 미설정 (가장 가까운 가격: ${JSON.stringify(result.priceNearest)})`,
|
|
889
|
+
result.extraRegionsSet?.length ? `✓ 추가 지역: ${result.extraRegionsSet.join(', ')}` : '',
|
|
890
|
+
'',
|
|
891
|
+
'스크린샷·리뷰 노트를 추가한 후 App Store Connect에서 심사 제출:',
|
|
892
|
+
`https://appstoreconnect.apple.com/apps/${args.appId}/distribution/iaps`,
|
|
893
|
+
].filter(Boolean).join('\n'),
|
|
894
|
+
}],
|
|
895
|
+
};
|
|
896
|
+
});
|
|
897
|
+
server.tool('appstore_create_subscription', [
|
|
898
|
+
'App Store에 자동 갱신 구독을 생성 — Subscription Group 자동 생성 포함.',
|
|
899
|
+
'생성 후 App Store Connect에서 스크린샷·리뷰 노트를 추가해야 심사 제출 가능.',
|
|
900
|
+
].join(' '), {
|
|
901
|
+
appId: z.string().describe('App Store 앱 ID'),
|
|
902
|
+
productId: z.string().describe('구독 productId (예: com.example.premium.monthly)'),
|
|
903
|
+
name: z.string().describe('구독 이름 (스토어 노출)'),
|
|
904
|
+
price: z.number().int().describe('가격 (최소 단위: USD cents. 예: $9.99 → 999, ₩9,900 → 9900)'),
|
|
905
|
+
currency: z.string().default('USD').describe('ISO 4217 통화 코드 (기본 USD)'),
|
|
906
|
+
period: z
|
|
907
|
+
.enum(['monthly', 'yearly'])
|
|
908
|
+
.describe('구독 주기'),
|
|
909
|
+
extraRegions: z
|
|
910
|
+
.array(z.object({
|
|
911
|
+
currency: z.string().describe('ISO 4217 통화 코드 (예: KRW)'),
|
|
912
|
+
price: z.number().describe('가격 (최소 단위)'),
|
|
913
|
+
}))
|
|
914
|
+
.optional()
|
|
915
|
+
.describe('추가 지역별 명시 가격'),
|
|
916
|
+
bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
|
|
917
|
+
}, async (args) => {
|
|
918
|
+
const creds = requireAppStoreCreds();
|
|
919
|
+
const result = await createAppleSubscription({
|
|
920
|
+
appId: args.appId,
|
|
921
|
+
bundleId: args.bundleId,
|
|
922
|
+
productId: args.productId,
|
|
923
|
+
name: args.name,
|
|
924
|
+
price: args.price,
|
|
925
|
+
currency: args.currency,
|
|
926
|
+
period: args.period,
|
|
927
|
+
...(args.extraRegions && { extraRegions: args.extraRegions }),
|
|
928
|
+
keyId: creds.keyId,
|
|
929
|
+
issuerId: creds.issuerId,
|
|
930
|
+
privateKey: creds.privateKey,
|
|
931
|
+
});
|
|
932
|
+
if (!result.success) {
|
|
933
|
+
const hint = result.errorType === 'DUPLICATE'
|
|
934
|
+
? '\n이미 같은 productId가 존재해. App Store Connect에서 확인해줘.'
|
|
935
|
+
: result.errorType === 'PRICE_NOT_FOUND'
|
|
936
|
+
? `\n가장 가까운 가격: ${JSON.stringify(result.priceNearest)}`
|
|
937
|
+
: '';
|
|
938
|
+
return { content: [{ type: 'text', text: `❌ 구독 생성 실패: ${result.error}${hint}` }] };
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
content: [{
|
|
942
|
+
type: 'text',
|
|
943
|
+
text: [
|
|
944
|
+
`✓ App Store 구독 생성 완료`,
|
|
945
|
+
`productId: ${result.productId}`,
|
|
946
|
+
`internalId: ${result.internalId}`,
|
|
947
|
+
result.priceSet ? `✓ 가격 설정됨` : `⚠ 가격 미설정 (가장 가까운 가격: ${JSON.stringify(result.priceNearest)})`,
|
|
948
|
+
result.extraRegionsSet?.length ? `✓ 추가 지역: ${result.extraRegionsSet.join(', ')}` : '',
|
|
949
|
+
result.localizationAdded ? '✓ KRW 한국어 로컬라이제이션 추가됨' : '',
|
|
950
|
+
'',
|
|
951
|
+
'App Store Connect에서 심사 제출:',
|
|
952
|
+
`https://appstoreconnect.apple.com/apps/${args.appId}/distribution/subscriptions`,
|
|
953
|
+
].filter(Boolean).join('\n'),
|
|
954
|
+
}],
|
|
955
|
+
};
|
|
956
|
+
});
|
|
691
957
|
// --- App Store 심사 제출 (Submit for Review) ---
|
|
692
958
|
server.tool('appstore_submit_for_review', [
|
|
693
959
|
'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
|
|
@@ -714,7 +980,7 @@ server.tool('playstore_submit_release', [
|
|
|
714
980
|
versionCode: z.string().describe('대상 versionCode (문자열)'),
|
|
715
981
|
status: z.enum(['draft', 'inProgress', 'completed', 'halted']).optional().describe('새 status (기본: completed)'),
|
|
716
982
|
}, async ({ packageName, track, versionCode, status }) => {
|
|
717
|
-
const auth =
|
|
983
|
+
const auth = requirePlayStoreAuth();
|
|
718
984
|
const result = await playstore.submitRelease(auth, packageName, track, versionCode, status);
|
|
719
985
|
return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode}: ${result.previousStatus} → ${result.newStatus}\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
720
986
|
});
|
|
@@ -740,7 +1006,7 @@ server.tool('playstore_promote_release', [
|
|
|
740
1006
|
text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
|
|
741
1007
|
})).optional().describe('대상 트랙용 릴리스 노트 (미지정 + copyReleaseNotes=true면 source 그대로)'),
|
|
742
1008
|
}, async ({ packageName, fromTrack, toTrack, versionCode, status, userFraction, releaseName, copyReleaseNotes, releaseNotes }) => {
|
|
743
|
-
const auth =
|
|
1009
|
+
const auth = requirePlayStoreAuth();
|
|
744
1010
|
const result = await playstore.promoteRelease(auth, packageName, fromTrack, toTrack, versionCode, {
|
|
745
1011
|
status,
|
|
746
1012
|
userFraction,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
import { JWT } from 'google-auth-library';
|
|
2
3
|
export type PlayImageType = 'featureGraphic' | 'icon' | 'phoneScreenshots' | 'promoGraphic' | 'sevenInchScreenshots' | 'tenInchScreenshots' | 'tvBanner' | 'tvScreenshots' | 'wearScreenshots';
|
|
3
4
|
/**
|
|
4
5
|
* Google Play Developer API (Android Publisher API v3) 래퍼
|
|
@@ -7,15 +8,15 @@ export type PlayImageType = 'featureGraphic' | 'icon' | 'phoneScreenshots' | 'pr
|
|
|
7
8
|
* 여기서는 기존 앱의 메타데이터, 빌드, 출시를 관리.
|
|
8
9
|
*/
|
|
9
10
|
export declare const publisher: () => import("googleapis").androidpublisher_v3.Androidpublisher;
|
|
10
|
-
export declare function withEdit<T>(auth: OAuth2Client, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean): Promise<T>;
|
|
11
|
-
export declare function getAppDetails(auth: OAuth2Client, packageName: string): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
|
|
12
|
-
export declare function getListing(auth: OAuth2Client, packageName: string, language?: string): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
|
|
13
|
-
export declare function updateListing(auth: OAuth2Client, packageName: string, language: string, data: {
|
|
11
|
+
export declare function withEdit<T>(auth: OAuth2Client | JWT, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean): Promise<T>;
|
|
12
|
+
export declare function getAppDetails(auth: OAuth2Client | JWT, packageName: string): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
|
|
13
|
+
export declare function getListing(auth: OAuth2Client | JWT, packageName: string, language?: string): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
|
|
14
|
+
export declare function updateListing(auth: OAuth2Client | JWT, packageName: string, language: string, data: {
|
|
14
15
|
title?: string;
|
|
15
16
|
shortDescription?: string;
|
|
16
17
|
fullDescription?: string;
|
|
17
18
|
}): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
|
|
18
|
-
export declare function listTracks(auth: OAuth2Client, packageName: string): Promise<{
|
|
19
|
+
export declare function listTracks(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
19
20
|
track: string | null | undefined;
|
|
20
21
|
releases: {
|
|
21
22
|
name: string | null | undefined;
|
|
@@ -24,20 +25,20 @@ export declare function listTracks(auth: OAuth2Client, packageName: string): Pro
|
|
|
24
25
|
releaseNotes: import("googleapis").androidpublisher_v3.Schema$LocalizedText[] | undefined;
|
|
25
26
|
}[];
|
|
26
27
|
}[]>;
|
|
27
|
-
export declare function updateReleaseNotes(auth: OAuth2Client, packageName: string, track: string, versionCode: string, language: string, text: string): Promise<import("googleapis").androidpublisher_v3.Schema$Track>;
|
|
28
|
+
export declare function updateReleaseNotes(auth: OAuth2Client | JWT, packageName: string, track: string, versionCode: string, language: string, text: string): Promise<import("googleapis").androidpublisher_v3.Schema$Track>;
|
|
28
29
|
/**
|
|
29
30
|
* 최신 release (versionCode 최대값)의 releaseNotes[language]를 교체/추가.
|
|
30
31
|
* versionCode를 모를 때 편의용.
|
|
31
32
|
*/
|
|
32
|
-
export declare function updateLatestReleaseNotes(auth: OAuth2Client, packageName: string, track: string, language: string, text: string): Promise<{
|
|
33
|
+
export declare function updateLatestReleaseNotes(auth: OAuth2Client | JWT, packageName: string, track: string, language: string, text: string): Promise<{
|
|
33
34
|
updatedVersionCodes: string[] | null | undefined;
|
|
34
35
|
updatedReleaseName: string | null | undefined;
|
|
35
36
|
releases?: import("googleapis").androidpublisher_v3.Schema$TrackRelease[];
|
|
36
37
|
track?: string | null;
|
|
37
38
|
}>;
|
|
38
|
-
export declare function listImages(auth: OAuth2Client, packageName: string, language: string, imageType: PlayImageType): Promise<import("googleapis").androidpublisher_v3.Schema$Image[]>;
|
|
39
|
-
export declare function uploadImage(auth: OAuth2Client, packageName: string, language: string, imageType: PlayImageType, filePath: string): Promise<import("googleapis").androidpublisher_v3.Schema$Image | undefined>;
|
|
40
|
-
export declare function deleteAllImages(auth: OAuth2Client, packageName: string, language: string, imageType: PlayImageType): Promise<{
|
|
39
|
+
export declare function listImages(auth: OAuth2Client | JWT, packageName: string, language: string, imageType: PlayImageType): Promise<import("googleapis").androidpublisher_v3.Schema$Image[]>;
|
|
40
|
+
export declare function uploadImage(auth: OAuth2Client | JWT, packageName: string, language: string, imageType: PlayImageType, filePath: string): Promise<import("googleapis").androidpublisher_v3.Schema$Image | undefined>;
|
|
41
|
+
export declare function deleteAllImages(auth: OAuth2Client | JWT, packageName: string, language: string, imageType: PlayImageType): Promise<{
|
|
41
42
|
ok: boolean;
|
|
42
43
|
imageType: PlayImageType;
|
|
43
44
|
}>;
|
|
@@ -45,7 +46,7 @@ export declare function deleteAllImages(auth: OAuth2Client, packageName: string,
|
|
|
45
46
|
* 한 edit 세션 내에서 기존 이미지 전체 삭제 + 새 이미지 순서대로 업로드 + commit.
|
|
46
47
|
* phoneScreenshots처럼 여러 장 교체 시 효율적 (단일 edit).
|
|
47
48
|
*/
|
|
48
|
-
export declare function replaceImages(auth: OAuth2Client, packageName: string, language: string, imageType: PlayImageType, filePaths: string[]): Promise<{
|
|
49
|
+
export declare function replaceImages(auth: OAuth2Client | JWT, packageName: string, language: string, imageType: PlayImageType, filePaths: string[]): Promise<{
|
|
49
50
|
imageType: PlayImageType;
|
|
50
51
|
count: number;
|
|
51
52
|
uploaded: {
|
|
@@ -54,7 +55,7 @@ export declare function replaceImages(auth: OAuth2Client, packageName: string, l
|
|
|
54
55
|
sha256?: string | null;
|
|
55
56
|
}[];
|
|
56
57
|
}>;
|
|
57
|
-
export declare function submitRelease(auth: OAuth2Client, packageName: string, track: string, versionCode: string, status?: 'completed' | 'draft' | 'inProgress' | 'halted'): Promise<{
|
|
58
|
+
export declare function submitRelease(auth: OAuth2Client | JWT, packageName: string, track: string, versionCode: string, status?: 'completed' | 'draft' | 'inProgress' | 'halted'): Promise<{
|
|
58
59
|
track: string;
|
|
59
60
|
versionCode: string;
|
|
60
61
|
previousStatus: string | null | undefined;
|
|
@@ -72,7 +73,7 @@ export interface PromoteReleaseOptions {
|
|
|
72
73
|
}>;
|
|
73
74
|
copyReleaseNotes?: boolean;
|
|
74
75
|
}
|
|
75
|
-
export declare function promoteRelease(auth: OAuth2Client, packageName: string, fromTrack: string, toTrack: string, versionCode: string, options?: PromoteReleaseOptions): Promise<{
|
|
76
|
+
export declare function promoteRelease(auth: OAuth2Client | JWT, packageName: string, fromTrack: string, toTrack: string, versionCode: string, options?: PromoteReleaseOptions): Promise<{
|
|
76
77
|
packageName: string;
|
|
77
78
|
fromTrack: string;
|
|
78
79
|
toTrack: string;
|
|
@@ -84,7 +85,7 @@ export declare function promoteRelease(auth: OAuth2Client, packageName: string,
|
|
|
84
85
|
committed: boolean;
|
|
85
86
|
result: import("googleapis").androidpublisher_v3.Schema$Track;
|
|
86
87
|
}>;
|
|
87
|
-
export declare function listReviews(auth: OAuth2Client, packageName: string): Promise<{
|
|
88
|
+
export declare function listReviews(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
88
89
|
reviewId: string | null | undefined;
|
|
89
90
|
authorName: string | null | undefined;
|
|
90
91
|
comments: {
|
|
@@ -94,13 +95,13 @@ export declare function listReviews(auth: OAuth2Client, packageName: string): Pr
|
|
|
94
95
|
deviceMetadata: string | null | undefined;
|
|
95
96
|
}[] | undefined;
|
|
96
97
|
}[]>;
|
|
97
|
-
export declare function replyToReview(auth: OAuth2Client, packageName: string, reviewId: string, replyText: string): Promise<import("googleapis").androidpublisher_v3.Schema$ReviewsReplyResponse>;
|
|
98
|
-
export declare function listInAppProducts(auth: OAuth2Client, packageName: string): Promise<{
|
|
98
|
+
export declare function replyToReview(auth: OAuth2Client | JWT, packageName: string, reviewId: string, replyText: string): Promise<import("googleapis").androidpublisher_v3.Schema$ReviewsReplyResponse>;
|
|
99
|
+
export declare function listInAppProducts(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
99
100
|
productId: any;
|
|
100
101
|
listings: any;
|
|
101
102
|
purchaseOptions: any;
|
|
102
103
|
}[]>;
|
|
103
|
-
export declare function listSubscriptions(auth: OAuth2Client, packageName: string): Promise<{
|
|
104
|
+
export declare function listSubscriptions(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
104
105
|
productId: string | null | undefined;
|
|
105
106
|
basePlans: import("googleapis").androidpublisher_v3.Schema$BasePlan[] | undefined;
|
|
106
107
|
listings: import("googleapis").androidpublisher_v3.Schema$SubscriptionListing[] | undefined;
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoonion/mimi-seed-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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": {
|
|
7
7
|
"mimi-seed-mcp": "dist/index.js",
|
|
8
8
|
"mimi-seed-auth": "dist/auth/cli.js",
|
|
9
|
-
"mimi-seed-appstore-auth": "dist/appstore/setup-cli.js"
|
|
9
|
+
"mimi-seed-appstore-auth": "dist/appstore/setup-cli.js",
|
|
10
|
+
"mimi-seed-playstore-auth": "dist/auth/playstore-setup-cli.js"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"dist"
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"dependencies": {
|
|
45
46
|
"@anthropic-ai/sdk": "^0.52.0",
|
|
46
47
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
48
|
+
"@onesub/providers": "^0.2.0",
|
|
47
49
|
"googleapis": "^171.4.0",
|
|
48
50
|
"jsonwebtoken": "^9.0.3",
|
|
49
51
|
"open": "^10.1.0",
|