@yoonion/mimi-seed-mcp 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
66
66
 
67
67
  | 영역 | 도구 수 | 주요 도구 |
68
68
  |------|---------|-----------|
69
- | App Store Connect | 31 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_whats_new` / `appstore_create_version` |
69
+ | App Store Connect | 33 | `appstore_submit_for_review` / `appstore_upload_screenshot` / `appstore_update_product_review_note` / `appstore_upload_product_review_screenshot` |
70
70
  | Google Play | 28 | `playstore_submit_release` / `playstore_promote_release` / `playstore_replace_images` / `playstore_reply_review` / `playstore_verify_service_account` |
71
71
  | Firebase | 20 | `firebase_create_project` / `firebase_create_android_app` / `firebase_get_android_config` / `firebase_create_ios_app` |
72
72
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
@@ -84,7 +84,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
84
84
  | 인증 | 3 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` |
85
85
  | AI (Claude) | 2 | `generate_release_notes_from_commits` / `generate_review_reply` |
86
86
 
87
- > 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `playstore_create_subscription` · `appstore_create_inapp_purchase` (`@onesub/providers` 위임).
87
+ > 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `appstore_create_inapp_purchase` · `appstore_update_product_review_note` · `appstore_upload_product_review_screenshot` 등.
88
88
  > 전체 카탈로그(항상 최신): [`docs/domain/tool-catalog.md`](../../docs/domain/tool-catalog.md)
89
89
 
90
90
  ---
@@ -0,0 +1,28 @@
1
+ export type AppStoreProductType = 'subscription' | 'consumable' | 'non_consumable';
2
+ /** 기존 IAP/구독 상품의 App Review 노트를 수정한다. */
3
+ export declare function updateProductReviewNote(args: {
4
+ internalId: string;
5
+ productType: AppStoreProductType;
6
+ reviewNote: string;
7
+ }): Promise<{
8
+ internalId: string;
9
+ productType: AppStoreProductType;
10
+ state?: string;
11
+ }>;
12
+ /**
13
+ * 기존 IAP/구독 상품에 App Review 스크린샷을 reserve → upload → commit 한다.
14
+ * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다.
15
+ */
16
+ export declare function uploadProductReviewScreenshot(args: {
17
+ internalId: string;
18
+ productType: AppStoreProductType;
19
+ filePath: string;
20
+ }): Promise<{
21
+ id: string;
22
+ internalId: string;
23
+ productType: AppStoreProductType;
24
+ fileName: string;
25
+ fileSize: number;
26
+ state?: string;
27
+ verified: boolean;
28
+ }>;
@@ -0,0 +1,179 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getAuthHeaders } from './auth.js';
5
+ import { friendlyAppStoreError } from './errors.js';
6
+ const V1_BASE = 'https://api.appstoreconnect.apple.com/v1';
7
+ const V2_BASE = 'https://api.appstoreconnect.apple.com/v2';
8
+ async function authHeadersOrThrow() {
9
+ const headers = await getAuthHeaders();
10
+ if (!headers) {
11
+ throw new Error([
12
+ '❌ App Store Connect 인증이 필요해.',
13
+ '',
14
+ '터미널에서 실행:',
15
+ ' npx -p @yoonion/mimi-seed-mcp mimi-seed-appstore-auth',
16
+ ].join('\n'));
17
+ }
18
+ return headers;
19
+ }
20
+ async function apiRequest(base, resourcePath, authHeaders, init) {
21
+ const response = await fetch(`${base}${resourcePath}`, {
22
+ ...init,
23
+ headers: { ...authHeaders, ...(init.headers ?? {}) },
24
+ });
25
+ if (!response.ok) {
26
+ const body = await response.text();
27
+ throw friendlyAppStoreError(response.status, body);
28
+ }
29
+ const text = await response.text();
30
+ return (text ? JSON.parse(text) : { ok: true });
31
+ }
32
+ function productResource(productType) {
33
+ if (productType === 'subscription') {
34
+ return { base: V1_BASE, type: 'subscriptions', path: '/subscriptions' };
35
+ }
36
+ return { base: V2_BASE, type: 'inAppPurchases', path: '/inAppPurchases' };
37
+ }
38
+ function reviewScreenshotResource(productType) {
39
+ if (productType === 'subscription') {
40
+ return {
41
+ type: 'subscriptionAppStoreReviewScreenshots',
42
+ path: '/subscriptionAppStoreReviewScreenshots',
43
+ relationship: 'subscription',
44
+ relatedType: 'subscriptions',
45
+ };
46
+ }
47
+ return {
48
+ type: 'inAppPurchaseAppStoreReviewScreenshots',
49
+ path: '/inAppPurchaseAppStoreReviewScreenshots',
50
+ relationship: 'inAppPurchaseV2',
51
+ relatedType: 'inAppPurchases',
52
+ };
53
+ }
54
+ /** 기존 IAP/구독 상품의 App Review 노트를 수정한다. */
55
+ export async function updateProductReviewNote(args) {
56
+ const { internalId, productType, reviewNote } = args;
57
+ const resource = productResource(productType);
58
+ const authHeaders = await authHeadersOrThrow();
59
+ const result = await apiRequest(resource.base, `${resource.path}/${internalId}`, authHeaders, {
60
+ method: 'PATCH',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({
63
+ data: {
64
+ type: resource.type,
65
+ id: internalId,
66
+ attributes: { reviewNote },
67
+ },
68
+ }),
69
+ });
70
+ return { internalId, productType, state: result.data?.attributes?.state };
71
+ }
72
+ async function uploadChunks(buffer, operations) {
73
+ for (const operation of operations) {
74
+ const uploadUrl = new URL(operation.url);
75
+ if (uploadUrl.protocol !== 'https:') {
76
+ throw new Error(`HTTPS가 아닌 업로드 URL은 거부해: ${uploadUrl.protocol}`);
77
+ }
78
+ if (operation.offset < 0 ||
79
+ operation.length <= 0 ||
80
+ operation.offset + operation.length > buffer.length) {
81
+ throw new Error(`잘못된 업로드 청크 범위: offset=${operation.offset}, ` +
82
+ `length=${operation.length}, fileSize=${buffer.length}`);
83
+ }
84
+ const chunk = buffer.subarray(operation.offset, operation.offset + operation.length);
85
+ const headers = {};
86
+ for (const header of operation.requestHeaders)
87
+ headers[header.name] = header.value;
88
+ const response = await fetch(operation.url, {
89
+ method: operation.method,
90
+ headers,
91
+ body: new Uint8Array(chunk),
92
+ });
93
+ if (!response.ok) {
94
+ const body = await response.text();
95
+ throw new Error(`청크 업로드 실패 (offset=${operation.offset}, length=${operation.length}): ` +
96
+ `${response.status} ${body}`);
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * 기존 IAP/구독 상품에 App Review 스크린샷을 reserve → upload → commit 한다.
102
+ * App Store Connect API는 상품당 심사용 스크린샷 하나를 허용한다.
103
+ */
104
+ export async function uploadProductReviewScreenshot(args) {
105
+ const { internalId, productType, filePath } = args;
106
+ if (!path.isAbsolute(filePath)) {
107
+ throw new Error(`절대 경로가 필요해: ${filePath}`);
108
+ }
109
+ if (!fs.existsSync(filePath)) {
110
+ throw new Error(`파일이 존재하지 않아: ${filePath}`);
111
+ }
112
+ const buffer = fs.readFileSync(filePath);
113
+ const fileName = path.basename(filePath);
114
+ const fileSize = buffer.length;
115
+ if (!/\.(png|jpe?g)$/i.test(fileName)) {
116
+ throw new Error(`PNG/JPG 파일만 업로드할 수 있어: ${fileName}`);
117
+ }
118
+ if (fileSize === 0) {
119
+ throw new Error(`빈 파일은 업로드할 수 없어: ${fileName}`);
120
+ }
121
+ const checksum = crypto.createHash('md5').update(buffer).digest('hex');
122
+ const resource = reviewScreenshotResource(productType);
123
+ const authHeaders = await authHeadersOrThrow();
124
+ const reserved = await apiRequest(V1_BASE, resource.path, authHeaders, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ data: {
129
+ type: resource.type,
130
+ attributes: { fileName, fileSize },
131
+ relationships: {
132
+ [resource.relationship]: {
133
+ data: { type: resource.relatedType, id: internalId },
134
+ },
135
+ },
136
+ },
137
+ }),
138
+ });
139
+ const screenshotId = reserved.data.id;
140
+ const operations = reserved.data.attributes?.uploadOperations ?? [];
141
+ if (operations.length === 0) {
142
+ await apiRequest(V1_BASE, `${resource.path}/${screenshotId}`, authHeaders, {
143
+ method: 'DELETE',
144
+ }).catch(() => undefined);
145
+ throw new Error('uploadOperations가 비어있음 — Apple API 응답 형식 확인 필요.');
146
+ }
147
+ try {
148
+ await uploadChunks(buffer, operations);
149
+ await apiRequest(V1_BASE, `${resource.path}/${screenshotId}`, authHeaders, {
150
+ method: 'PATCH',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({
153
+ data: {
154
+ type: resource.type,
155
+ id: screenshotId,
156
+ attributes: { uploaded: true, sourceFileChecksum: checksum },
157
+ },
158
+ }),
159
+ });
160
+ }
161
+ catch (error) {
162
+ // 완료되지 않은 reservation이 남지 않도록 best-effort 정리.
163
+ await apiRequest(V1_BASE, `${resource.path}/${screenshotId}`, authHeaders, {
164
+ method: 'DELETE',
165
+ }).catch(() => undefined);
166
+ throw error;
167
+ }
168
+ let state;
169
+ let verified = false;
170
+ try {
171
+ const confirmed = await apiRequest(V1_BASE, `${resource.path}/${screenshotId}`, authHeaders, { method: 'GET' });
172
+ state = confirmed.data.attributes?.assetDeliveryState?.state;
173
+ verified = true;
174
+ }
175
+ catch {
176
+ // commit 성공 후 확인 GET만 실패하면 재업로드를 유도하지 않는다.
177
+ }
178
+ return { id: screenshotId, internalId, productType, fileName, fileSize, state, verified };
179
+ }
@@ -25,6 +25,12 @@ export interface PlayStatisticsQuery {
25
25
  export declare function withEdit<T>(auth: OAuth2Client | JWT, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean): Promise<T>;
26
26
  export declare function getStatistics(auth: OAuth2Client | JWT, packageName: string, query: PlayStatisticsQuery): Promise<unknown>;
27
27
  export declare function getAppDetails(auth: OAuth2Client | JWT, packageName: string): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
28
+ export declare function updateAppDetails(auth: OAuth2Client | JWT, packageName: string, data: {
29
+ contactEmail?: string;
30
+ contactPhone?: string;
31
+ contactWebsite?: string;
32
+ defaultLanguage?: string;
33
+ }): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
28
34
  export declare function getListing(auth: OAuth2Client | JWT, packageName: string, language?: string): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
29
35
  export declare function updateListing(auth: OAuth2Client | JWT, packageName: string, language: string, data: {
30
36
  title?: string;
@@ -111,6 +111,22 @@ export async function getAppDetails(auth, packageName) {
111
111
  return details.data;
112
112
  });
113
113
  }
114
+ // ─── 앱 세부정보(개발자 연락처·기본 언어) 수정 ───
115
+ //
116
+ // edits.details = 스토어 리스팅(제목·설명)과 별개인 개발자 연락처(이메일/전화/웹사이트)
117
+ // 와 기본 언어. patch 로 부분 갱신 — 넘긴 필드만 교체하고 나머지 연락처는 보존한다
118
+ // (update=PUT 은 전체 치환이라 미지정 필드가 지워질 수 있어 patch 사용). edit → commit.
119
+ export async function updateAppDetails(auth, packageName, data) {
120
+ return withEdit(auth, packageName, async (editId) => {
121
+ const updated = await publisher().edits.details.patch({
122
+ auth,
123
+ packageName,
124
+ editId,
125
+ requestBody: data,
126
+ });
127
+ return updated.data;
128
+ }, true);
129
+ }
114
130
  // ─── 스토어 리스팅 조회 ───
115
131
  export async function getListing(auth, packageName, language = 'ko-KR') {
116
132
  return withEdit(auth, packageName, async (editId) => {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import * as appstore from '../appstore/tools.js';
3
3
  import * as appstoreScreenshots from '../appstore/screenshots.js';
4
+ import * as appstoreProductReview from '../appstore/product-review.js';
4
5
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
5
6
  import { requireAppStoreCreds } from '../helpers.js';
6
7
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
@@ -440,6 +441,71 @@ export function registerAppstoreTools(server) {
440
441
  });
441
442
  return { content: [{ type: 'text', text: JSON.stringify(products, null, 2) }] };
442
443
  });
444
+ server.tool('appstore_update_product_review_note', '기존 App Store IAP/구독 상품의 App Review 노트를 수정. appstore_list_products의 productId/type을 사용.', {
445
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
446
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
447
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
448
+ reviewNote: z.string().max(4000).describe('Apple 심사용 노트 (4000자 이하, 빈 문자열은 초기화)'),
449
+ }, async ({ appId, productId, productType, reviewNote }) => {
450
+ const creds = requireAppStoreCreds();
451
+ const products = await listAppleProducts({
452
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
453
+ });
454
+ const product = products.find((item) => item.productId === productId && item.type === productType);
455
+ if (!product) {
456
+ return { content: [{ type: 'text', text: `상품을 찾을 수 없음: ${productId} (${productType})` }] };
457
+ }
458
+ const result = await appstoreProductReview.updateProductReviewNote({
459
+ internalId: product.internalId,
460
+ productType,
461
+ reviewNote,
462
+ });
463
+ return {
464
+ content: [{
465
+ type: 'text',
466
+ text: [
467
+ '✓ App Review 노트 수정 완료',
468
+ `productId: ${productId}`,
469
+ `internalId: ${result.internalId}`,
470
+ result.state ? `state: ${result.state}` : '',
471
+ ].filter(Boolean).join('\n'),
472
+ }],
473
+ };
474
+ });
475
+ server.tool('appstore_upload_product_review_screenshot', '기존 App Store IAP/구독 상품의 심사용 스크린샷을 reserve → upload → commit. 상품당 1장, 절대 파일 경로 필요.', {
476
+ appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
477
+ productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
478
+ productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
479
+ filePath: z.string().describe('업로드할 PNG/JPG의 절대 파일 경로'),
480
+ }, async ({ appId, productId, productType, filePath }) => {
481
+ const creds = requireAppStoreCreds();
482
+ const products = await listAppleProducts({
483
+ appId, keyId: creds.keyId, issuerId: creds.issuerId, privateKey: creds.privateKey,
484
+ });
485
+ const product = products.find((item) => item.productId === productId && item.type === productType);
486
+ if (!product) {
487
+ return { content: [{ type: 'text', text: `상품을 찾을 수 없음: ${productId} (${productType})` }] };
488
+ }
489
+ const result = await appstoreProductReview.uploadProductReviewScreenshot({
490
+ internalId: product.internalId,
491
+ productType,
492
+ filePath,
493
+ });
494
+ return {
495
+ content: [{
496
+ type: 'text',
497
+ text: [
498
+ '✓ App Review 스크린샷 업로드 완료',
499
+ `productId: ${productId}`,
500
+ `internalId: ${result.internalId}`,
501
+ `screenshotId: ${result.id}`,
502
+ `file: ${result.fileName} (${result.fileSize} bytes)`,
503
+ result.state ? `state: ${result.state}` : '',
504
+ result.verified ? '✓ commit 후 조회 확인' : '⚠ commit은 성공했지만 후속 조회는 확인하지 못함',
505
+ ].filter(Boolean).join('\n'),
506
+ }],
507
+ };
508
+ });
443
509
  server.tool('appstore_update_product', 'App Store IAP 상품의 reference name 변경. productId / 유형은 변경 불가.', {
444
510
  appId: z.string().optional().describe('App Store 앱 ID'),
445
511
  bundleId: z.string().optional().describe('번들 ID (appId 대신 사용 가능)'),
@@ -33,11 +33,24 @@ const playstore = new Proxy(playstoreRaw, {
33
33
  },
34
34
  });
35
35
  export function registerPlaystoreTools(server) {
36
- server.tool('playstore_get_app', 'Google Play 앱 상세 정보 조회', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
36
+ server.tool('playstore_get_app', 'Google Play 앱 세부정보 조회 — 개발자 연락처(이메일·전화·웹사이트)·기본 언어 등 (edits.details). 수정은 playstore_update_details.', { packageName: z.string().describe('패키지명 (예: com.findthem.app)') }, async ({ packageName }) => {
37
37
  const auth = requirePlayStoreAuth(packageName);
38
38
  const details = await playstore.getAppDetails(auth, packageName);
39
39
  return { content: [{ type: 'text', text: JSON.stringify(details, null, 2) }] };
40
40
  });
41
+ server.tool('playstore_update_details', 'Google Play 앱 세부정보(개발자 연락처·기본 언어) 수정 — 연락처 이메일/전화/웹사이트, 기본 언어. 스토어 리스팅(제목·설명)과는 별개이며, 넘긴 필드만 부분 갱신(edits.details.patch). ⚠️ Console 에서 같은 앱을 편집·미게시 중이면 충돌할 수 있음.', {
42
+ packageName: z.string().describe('패키지명 (예: gg.pryzm.penguinrun)'),
43
+ contactEmail: z.string().optional().describe('개발자 연락처 이메일 (스토어 등록정보에 공개)'),
44
+ contactPhone: z.string().optional().describe('개발자 연락처 전화번호 (선택)'),
45
+ contactWebsite: z.string().optional().describe('개발자 웹사이트 URL (예: https://penguin.pryzm.gg)'),
46
+ defaultLanguage: z.string().optional().describe('기본 언어 코드 (예: ko-KR, en-US)'),
47
+ }, async ({ packageName, contactEmail, contactPhone, contactWebsite, defaultLanguage }) => {
48
+ const auth = requirePlayStoreAuth(packageName);
49
+ const result = await playstore.updateAppDetails(auth, packageName, {
50
+ contactEmail, contactPhone, contactWebsite, defaultLanguage,
51
+ });
52
+ return { content: [{ type: 'text', text: `수정 완료:\n${JSON.stringify(result, null, 2)}` }] };
53
+ });
41
54
  server.tool('playstore_get_listing', 'Google Play 스토어 리스팅 조회 (제목, 설명문 등)', {
42
55
  packageName: z.string().describe('패키지명'),
43
56
  language: z.string().default('ko-KR').describe('언어 코드 (기본: ko-KR)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {