@yoonion/mimi-seed-mcp 0.21.2 → 0.21.3

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.
Files changed (31) hide show
  1. package/dist/googleads/config.d.ts +5 -2
  2. package/dist/googleads/config.js +22 -9
  3. package/dist/googleads/errors.js +25 -17
  4. package/dist/googleads/setup-cli.js +1 -1
  5. package/dist/googleads/tools.d.ts +8 -1
  6. package/dist/googleads/tools.js +19 -1
  7. package/dist/playstore/products.d.ts +1 -1
  8. package/dist/playstore/products.js +1 -1
  9. package/dist/registers/appstore/apps.d.ts +4 -0
  10. package/dist/registers/appstore/apps.js +43 -0
  11. package/dist/registers/appstore/customer-reviews.d.ts +4 -0
  12. package/dist/registers/appstore/customer-reviews.js +31 -0
  13. package/dist/registers/appstore/declarations.d.ts +4 -0
  14. package/dist/registers/appstore/declarations.js +163 -0
  15. package/dist/registers/appstore/media.d.ts +6 -0
  16. package/dist/registers/appstore/media.js +116 -0
  17. package/dist/registers/appstore/metadata.d.ts +11 -0
  18. package/dist/registers/appstore/metadata.js +120 -0
  19. package/dist/registers/appstore/products.d.ts +6 -0
  20. package/dist/registers/appstore/products.js +297 -0
  21. package/dist/registers/appstore/reports.d.ts +6 -0
  22. package/dist/registers/appstore/reports.js +77 -0
  23. package/dist/registers/appstore/review-submission.d.ts +6 -0
  24. package/dist/registers/appstore/review-submission.js +113 -0
  25. package/dist/registers/appstore/testflight.d.ts +6 -0
  26. package/dist/registers/appstore/testflight.js +193 -0
  27. package/dist/registers/appstore/versions.d.ts +8 -0
  28. package/dist/registers/appstore/versions.js +205 -0
  29. package/dist/registers/appstore.d.ts +8 -0
  30. package/dist/registers/appstore.js +37 -1274
  31. package/package.json +1 -1
@@ -3,8 +3,11 @@ export interface GoogleAdsConfig {
3
3
  customerId: string;
4
4
  loginCustomerId?: string;
5
5
  }
6
- /** 하이픈 제거 (API는 숫자만 허용) */
7
- export declare function normalizeCustomerId(id: string): string;
6
+ /**
7
+ * 하이픈 제거 (API는 숫자만 허용). 손으로 적은 설정 파일엔 따옴표 없는 숫자(`"customerId": 1234567890`)도
8
+ * 오므로 숫자도 받는다 — 안 그러면 `.replace` 가 던져 "설정 없음" 으로 읽힌다.
9
+ */
10
+ export declare function normalizeCustomerId(id: string | number): string;
8
11
  export declare function saveConfig(cfg: GoogleAdsConfig): void;
9
12
  export declare function loadConfig(): GoogleAdsConfig | null;
10
13
  export declare function requireConfig(): GoogleAdsConfig;
@@ -4,9 +4,12 @@ import os from 'node:os';
4
4
  import { writeCredentialJson } from '#core/atomic-write.js';
5
5
  const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
6
6
  const CONFIG_PATH = path.join(CONFIG_DIR, 'google-ads.json');
7
- /** 하이픈 제거 (API는 숫자만 허용) */
7
+ /**
8
+ * 하이픈 제거 (API는 숫자만 허용). 손으로 적은 설정 파일엔 따옴표 없는 숫자(`"customerId": 1234567890`)도
9
+ * 오므로 숫자도 받는다 — 안 그러면 `.replace` 가 던져 "설정 없음" 으로 읽힌다.
10
+ */
8
11
  export function normalizeCustomerId(id) {
9
- return id.replace(/-/g, '');
12
+ return String(id).replace(/-/g, '');
10
13
  }
11
14
  export function saveConfig(cfg) {
12
15
  const normalized = {
@@ -16,6 +19,14 @@ export function saveConfig(cfg) {
16
19
  };
17
20
  writeCredentialJson(CONFIG_PATH, normalized);
18
21
  }
22
+ /** 손으로 적은 ID 는 문자열이거나 따옴표 없는 정수다. 그 밖(빈 값 · 객체 · 소수)은 설정이 아니다. */
23
+ function readCustomerId(value) {
24
+ if (typeof value === 'string' && value.trim())
25
+ return normalizeCustomerId(value.trim());
26
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
27
+ return normalizeCustomerId(value);
28
+ return null;
29
+ }
19
30
  export function loadConfig() {
20
31
  if (!fs.existsSync(CONFIG_PATH))
21
32
  return null;
@@ -23,13 +34,15 @@ export function loadConfig() {
23
34
  const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
24
35
  // 읽기 시점에도 정규화 — 하이픈 포함 값이 손으로 적히거나 구버전이 쓴 경우에도
25
36
  // URL/login-customer-id 헤더가 항상 숫자만 포함하도록 보장 (write 경로 의존 제거).
26
- return {
27
- ...cfg,
28
- customerId: normalizeCustomerId(cfg.customerId),
29
- loginCustomerId: cfg.loginCustomerId
30
- ? normalizeCustomerId(cfg.loginCustomerId)
31
- : undefined,
32
- };
37
+ const customerId = readCustomerId(cfg.customerId);
38
+ if (typeof cfg.developerToken !== 'string' || !cfg.developerToken || !customerId)
39
+ return null;
40
+ // 비어 있지 않은 loginCustomerId 가 이상하면 조용히 버리지 않는다 — MCC 헤더 없이 호출하면 엉뚱한 권한 오류가 난다.
41
+ const hasLogin = cfg.loginCustomerId !== undefined && cfg.loginCustomerId !== null && cfg.loginCustomerId !== '';
42
+ const loginCustomerId = hasLogin ? readCustomerId(cfg.loginCustomerId) : undefined;
43
+ if (loginCustomerId === null)
44
+ return null;
45
+ return { ...cfg, developerToken: cfg.developerToken, customerId, loginCustomerId };
33
46
  }
34
47
  catch {
35
48
  return null;
@@ -1,29 +1,37 @@
1
1
  /** Preserve provider diagnostics without exposing authentication material. */
2
2
  export function googleAdsError(status, body, secrets) {
3
3
  let message = `Google Ads API ${status}`;
4
+ let parsed;
4
5
  try {
5
- const parsed = JSON.parse(body);
6
- const error = (Array.isArray(parsed) ? parsed[0] : parsed)?.error;
7
- if (typeof error?.message === 'string')
8
- message += `: ${error.message}`;
9
- for (const detail of Array.isArray(error?.details) ? error.details : []) {
10
- for (const item of Array.isArray(detail.errors) ? detail.errors : []) {
11
- const codes = item.errorCode && typeof item.errorCode === 'object'
12
- ? Object.entries(item.errorCode).map(([key, value]) => `${key}=${String(value)}`).join(', ')
13
- : '';
14
- message += `\n${codes}${typeof item.message === 'string' ? `: ${item.message}` : ''}`;
15
- }
16
- for (const item of Array.isArray(detail.fieldViolations) ? detail.fieldViolations : []) {
17
- message += `\n${String(item.field ?? '')}: ${String(item.description ?? '')}`;
18
- }
19
- if (typeof detail.requestId === 'string')
20
- message += `\nrequestId=${detail.requestId}`;
21
- }
6
+ parsed = JSON.parse(body);
22
7
  }
23
8
  catch {
24
9
  // HTML/proxy bodies can contain arbitrary data; do not echo them.
10
+ parsed = undefined;
25
11
  message += ': non-JSON response';
26
12
  }
13
+ // Only the parse decides "non-JSON": a JSON body with odd entries (`details: [null]`) is walked
14
+ // null-safely instead of throwing into the non-JSON branch and mislabelling a real provider error.
15
+ const error = (Array.isArray(parsed) ? parsed[0] : parsed)?.error;
16
+ if (typeof error?.message === 'string')
17
+ message += `: ${error.message}`;
18
+ for (const detail of Array.isArray(error?.details) ? error.details : []) {
19
+ for (const item of Array.isArray(detail?.errors) ? detail.errors : []) {
20
+ if (!item || typeof item !== 'object')
21
+ continue;
22
+ const codes = item?.errorCode && typeof item.errorCode === 'object'
23
+ ? Object.entries(item.errorCode).map(([key, value]) => `${key}=${String(value)}`).join(', ')
24
+ : '';
25
+ message += `\n${codes}${typeof item?.message === 'string' ? `: ${item.message}` : ''}`;
26
+ }
27
+ for (const item of Array.isArray(detail?.fieldViolations) ? detail.fieldViolations : []) {
28
+ if (!item || typeof item !== 'object')
29
+ continue;
30
+ message += `\n${String(item?.field ?? '')}: ${String(item?.description ?? '')}`;
31
+ }
32
+ if (typeof detail?.requestId === 'string')
33
+ message += `\nrequestId=${detail.requestId}`;
34
+ }
27
35
  for (const secret of secrets.filter(Boolean).sort((a, b) => b.length - a.length)) {
28
36
  message = message.split(secret).join('[REDACTED]');
29
37
  }
@@ -104,7 +104,7 @@ async function main() {
104
104
  console.log(M.probing);
105
105
  try {
106
106
  const customers = await listAccessibleCustomers(auth, cfg);
107
- console.log(M.probeOk(customers.length));
107
+ console.log(M.probeOk(customers.resourceNames.length));
108
108
  }
109
109
  catch (e) {
110
110
  const msg = e instanceof Error ? e.message : String(e);
@@ -5,7 +5,14 @@ export interface DateRange {
5
5
  startDate: string;
6
6
  endDate: string;
7
7
  }
8
- export declare function listAccessibleCustomers(auth: OAuth2Client, cfg: GoogleAdsConfig): Promise<any>;
8
+ /**
9
+ * 응답은 배열이 아니라 `{ resourceNames: ['customers/<id>', …] }` 다 — 계정이 없으면 protobuf JSON 이
10
+ * 필드를 생략하므로 빈 배열로 채운다. 반환 타입을 고정해 두지 않으면 호출부가 `.length` 를 읽어도
11
+ * 컴파일러가 못 잡는다(setup-cli 가 "계정 undefined개" 를 찍었다).
12
+ */
13
+ export declare function listAccessibleCustomers(auth: OAuth2Client, cfg: GoogleAdsConfig): Promise<{
14
+ resourceNames: string[];
15
+ }>;
9
16
  export declare function listCampaigns(auth: OAuth2Client, cfg: GoogleAdsConfig): Promise<{
10
17
  id: string | undefined;
11
18
  name: string | undefined;
@@ -98,6 +98,11 @@ function microsToCurrency(micros) {
98
98
  return value / 1_000_000;
99
99
  }
100
100
  // ─── 접근 가능한 고객 목록 (API 연결 확인용) ─────────────────────
101
+ /**
102
+ * 응답은 배열이 아니라 `{ resourceNames: ['customers/<id>', …] }` 다 — 계정이 없으면 protobuf JSON 이
103
+ * 필드를 생략하므로 빈 배열로 채운다. 반환 타입을 고정해 두지 않으면 호출부가 `.length` 를 읽어도
104
+ * 컴파일러가 못 잡는다(setup-cli 가 "계정 undefined개" 를 찍었다).
105
+ */
101
106
  export async function listAccessibleCustomers(auth, cfg) {
102
107
  const accessToken = await getAccessToken(auth);
103
108
  const url = `${BASE}/customers:listAccessibleCustomers`;
@@ -109,7 +114,20 @@ export async function listAccessibleCustomers(auth, cfg) {
109
114
  if (!res.ok) {
110
115
  throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
111
116
  }
112
- return JSON.parse(text);
117
+ // SyntaxError 메시지는 본문 앞부분을 그대로 싣는다 — search() 와 같이 프록시 본문을 노출하지 않는다.
118
+ let json;
119
+ try {
120
+ json = JSON.parse(text);
121
+ }
122
+ catch {
123
+ throw new Error('Google Ads returned invalid JSON.');
124
+ }
125
+ // search() 와 같이 200 + error 본문도 거절한다 — 안 그러면 setup 이 "계정 0개" 로 저장해 버린다.
126
+ const names = isRecord(json) && !('error' in json) ? json.resourceNames ?? [] : null;
127
+ if (!Array.isArray(names) || names.some((name) => typeof name !== 'string')) {
128
+ throw new Error('Google Ads returned an invalid accessible-customers response.');
129
+ }
130
+ return { resourceNames: names };
113
131
  }
114
132
  // ─── 캠페인 목록 ─────────────────────────────────────────────
115
133
  export async function listCampaigns(auth, cfg) {
@@ -38,7 +38,7 @@ export declare function updateSubscriptionListings(auth: OAuth2Client | JWT, pac
38
38
  productId: string;
39
39
  created: string[];
40
40
  updated: string[];
41
- listings: StoredListing[];
41
+ listings: import("googleapis/build/src/apis/androidpublisher/v3.js").androidpublisher_v3.Schema$SubscriptionListing[];
42
42
  }>;
43
43
  export declare function updatePurchaseOptionState(auth: OAuth2Client | JWT, packageName: string, productId: string, purchaseOptionId: string, action: 'activate' | 'deactivate'): Promise<{
44
44
  productId: string;
@@ -121,7 +121,7 @@ export async function updateSubscriptionListings(auth, packageName, productId, l
121
121
  productId,
122
122
  created,
123
123
  updated,
124
- listings: (res.data?.listings ?? merged),
124
+ listings: res.data?.listings ?? merged,
125
125
  };
126
126
  }
127
127
  // ─── 구매 옵션 상태 (DRAFT ↔ ACTIVE) ───
@@ -0,0 +1,4 @@
1
+ /** App Store Connect 앱 조회와 API 키 검증. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_list_apps · appstore_verify_credentials · appstore_get_app */
4
+ export declare function registerAppTools(server: ToolRegistrar): void;
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ import * as appstore from '../../appstore/tools.js';
3
+ import * as appstoreSales from '../../appstore/sales.js';
4
+ import { jsonResult, textResult } from '../../lib/mcp-response.js';
5
+ /** appstore_list_apps · appstore_verify_credentials · appstore_get_app */
6
+ export function registerAppTools(server) {
7
+ server.tool('appstore_list_apps', 'App Store Connect 앱 목록 조회', {}, async () => {
8
+ const apps = await appstore.listApps();
9
+ return jsonResult(apps);
10
+ });
11
+ server.tool('appstore_verify_credentials', [
12
+ 'App Store Connect API 키(appstore.json) 유효성 검증 — JWT 서명 + GET /apps 호출로',
13
+ 'creds/sign/auth/api 단계별 진단. 첫 도구 호출에서 401로 늦게 터지기 전에 setup 직후 확인용.',
14
+ '매출 리포트 접근 가능 여부도 함께 확인한다 — 그쪽만 요구 롤이 달라서',
15
+ '(Admin/Finance/Sales and Reports) 앱 메타데이터는 다 되는데 매출만 403 인 키가 흔하다.',
16
+ '인자 없음.',
17
+ ].join(' '), {}, async () => {
18
+ const r = await appstore.verifyAppStoreCredentials();
19
+ if (r.ok) {
20
+ // 키가 유효할 때만 물어본다 — 인증 자체가 깨졌으면 403/401 구분이 무의미하다.
21
+ const reports = await appstoreSales.probeReportsAccess();
22
+ const reportsIcon = reports.status === 'ok' ? '✓' : reports.status === 'forbidden' ? '✗' : '·';
23
+ return textResult([
24
+ '✓ App Store Connect 인증 유효',
25
+ r.appCount != null ? ` 접근 가능 앱: ${r.appCount}개` : '',
26
+ r.firstApp ? ` 예: ${r.firstApp.name ?? r.firstApp.id}` : '',
27
+ `${reportsIcon} 매출 리포트: ${reports.detail}`,
28
+ reports.status === 'forbidden'
29
+ ? ' → 키 롤은 나중에 못 바꾸는 경우가 있다. 그때는 위 롤로 새 키를 발급해 다시 등록할 것.'
30
+ : '',
31
+ ].filter(Boolean));
32
+ }
33
+ return textResult([
34
+ `✗ 검증 실패 (stage: ${r.stage}${r.httpStatus ? `, HTTP ${r.httpStatus}` : ''})`,
35
+ '',
36
+ r.message,
37
+ ]);
38
+ });
39
+ server.tool('appstore_get_app', 'App Store Connect 앱 상세 정보', { appId: z.string().describe('앱 ID (숫자)') }, async ({ appId }) => {
40
+ const app = await appstore.getApp(appId);
41
+ return jsonResult(app);
42
+ });
43
+ }
@@ -0,0 +1,4 @@
1
+ /** App Store 고객 리뷰 조회와 개발자 답변. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_list_reviews · appstore_reply_review */
4
+ export declare function registerCustomerReviewTools(server: ToolRegistrar): void;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import * as appstore from '../../appstore/tools.js';
3
+ import { jsonResult, textResult } from '../../lib/mcp-response.js';
4
+ /** appstore_list_reviews · appstore_reply_review */
5
+ export function registerCustomerReviewTools(server) {
6
+ server.tool('appstore_list_reviews', [
7
+ 'App Store 받은 고객 리뷰 조회 (최신순).',
8
+ 'response 필드에 개발자 답변 존재 여부와 내용이 함께 포함됨 (없으면 null).',
9
+ 'territory(예: KOR/USA — ISO 3166 alpha-3) / rating(1~5)으로 필터 가능.',
10
+ ].join(' '), {
11
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과)'),
12
+ limit: z.number().int().positive().max(200).optional().describe('가져올 개수 (기본 50, 최대 200)'),
13
+ territory: z.string().optional().describe("국가 코드 (예: 'KOR', 'USA' — alpha-3)"),
14
+ rating: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional().describe('별점 필터 (1~5)'),
15
+ }, async ({ appId, limit, territory, rating }) => {
16
+ const reviews = await appstore.listCustomerReviews(appId, { limit, territory, rating });
17
+ return jsonResult(reviews);
18
+ });
19
+ server.tool('appstore_reply_review', [
20
+ 'App Store 고객 리뷰에 개발자 답변을 등록 (또는 갱신).',
21
+ '동일 리뷰에 한 번만 답변 가능 — 기존 답변이 있으면 Apple이 새 응답으로 대체함.',
22
+ 'reviewId는 appstore_list_reviews 결과의 id.',
23
+ '답변 본문은 5970자 이내.',
24
+ ].join(' '), {
25
+ reviewId: z.string().describe('리뷰 ID (appstore_list_reviews 결과)'),
26
+ responseBody: z.string().describe('답변 본문 (5970자 이내)'),
27
+ }, async ({ reviewId, responseBody }) => {
28
+ const result = await appstore.createReviewResponse(reviewId, responseBody);
29
+ return textResult(`✅ 리뷰 ${reviewId}에 답변 등록됐어.\n\n${JSON.stringify(result, null, 2)}`);
30
+ });
31
+ }
@@ -0,0 +1,4 @@
1
+ /** 심사 제출 전 선언 — 연령 등급 설문, 수출 규정(암호화), 판매 지역. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_{get,update}_age_rating · appstore_declare_encryption · appstore_get_availability · appstore_set_territory_availability */
4
+ export declare function registerDeclarationTools(server: ToolRegistrar): void;
@@ -0,0 +1,163 @@
1
+ import { z } from 'zod';
2
+ import * as appstoreDeclarations from '../../appstore/declarations.js';
3
+ import { textResult } from '../../lib/mcp-response.js';
4
+ /** appstore_{get,update}_age_rating · appstore_declare_encryption · appstore_get_availability · appstore_set_territory_availability */
5
+ export function registerDeclarationTools(server) {
6
+ // ─── 심사 제출 전 선언 ───
7
+ // 비어 있으면 제출이 막히거나 심사에서 반려되는 항목들. Console 에서만 되는 줄 알았던 것들이다.
8
+ server.tool('appstore_get_age_rating', [
9
+ '앱의 연령 등급 설문(ageRatingDeclaration) 현재 값을 읽는다 — 읽기 전용.',
10
+ 'appInfo 에 딸린 단일 리소스이며, 편집 가능한 appInfo 를 자동으로 고른다.',
11
+ '수정 전에 이걸로 현재 답변을 먼저 확인할 것.',
12
+ ].join(' '), { appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과)') }, async ({ appId }) => {
13
+ const { appInfoId, declarationId, declaration } = await appstoreDeclarations.getAgeRating(appId);
14
+ const filled = Object.entries(declaration).filter(([, v]) => v !== undefined && v !== null);
15
+ return textResult([
16
+ `연령 등급 선언 (appInfo ${appInfoId}${declarationId ? ` · declaration ${declarationId}` : ' · 선언 없음'})`,
17
+ ...filled.map(([k, v]) => ` ${k}: ${v}`),
18
+ filled.length === 0 ? ' (아직 답변된 항목 없음)' : '',
19
+ ].filter(Boolean));
20
+ });
21
+ const FREQ = z.enum(['NONE', 'INFREQUENT_OR_MILD', 'FREQUENT_OR_INTENSE', 'INFREQUENT', 'FREQUENT']);
22
+ const freq = (what) => FREQ.optional().describe(`${what} — NONE / INFREQUENT_OR_MILD / FREQUENT_OR_INTENSE`);
23
+ server.tool('appstore_update_age_rating', [
24
+ '연령 등급 설문을 갱신한다 — PATCH /v1/ageRatingDeclarations/{id}.',
25
+ '넘긴 필드만 바뀐다 (부분 갱신). 전체를 다시 보낼 필요 없다.',
26
+ '미완성이면 심사 제출이 막히므로 첫 출시 전 반드시 채워야 한다.',
27
+ 'Play 의 콘텐츠 등급 설문과 달리 Apple 은 이렇게 API 로 답변할 수 있다.',
28
+ ].join(' '), {
29
+ appId: z.string().describe('App Store 앱 ID'),
30
+ violenceCartoonOrFantasy: freq('만화/판타지 폭력'),
31
+ violenceRealistic: freq('사실적 폭력'),
32
+ violenceRealisticProlongedGraphicOrSadistic: freq('지속적·잔혹한 사실적 폭력'),
33
+ profanityOrCrudeHumor: freq('욕설/저속한 유머'),
34
+ matureOrSuggestiveThemes: freq('성인/암시적 주제'),
35
+ horrorOrFearThemes: freq('공포/공포 유발 주제'),
36
+ sexualContentOrNudity: freq('성적 콘텐츠 또는 노출'),
37
+ sexualContentGraphicAndNudity: freq('노골적 성적 콘텐츠·노출'),
38
+ alcoholTobaccoOrDrugUseOrReferences: freq('음주/흡연/약물'),
39
+ medicalOrTreatmentInformation: freq('의료/치료 정보'),
40
+ gamblingSimulated: freq('모의 도박'),
41
+ contests: freq('경품/콘테스트'),
42
+ gunsOrOtherWeapons: freq('총기·무기'),
43
+ gambling: z.boolean().optional().describe('실제 도박 포함 여부'),
44
+ lootBox: z.boolean().optional().describe('확률형 아이템(루트박스) 포함 여부'),
45
+ unrestrictedWebAccess: z.boolean().optional().describe('제한 없는 웹 접근'),
46
+ userGeneratedContent: z.boolean().optional().describe('사용자 생성 콘텐츠'),
47
+ socialMedia: z.boolean().optional().describe('UGC를 피드·탐색 방식으로 재배포·확산·상호작용하는 소셜 미디어 기능'),
48
+ socialMediaAgeRestricted: z.boolean().optional().describe('소셜 미디어 기능을 13세 미만 사용자에게 비활성화'),
49
+ messagingAndChat: z.boolean().optional().describe('메시지/채팅 기능'),
50
+ advertising: z.boolean().optional().describe('광고 포함'),
51
+ healthOrWellnessTopics: z.boolean().optional().describe('건강/웰니스 주제'),
52
+ parentalControls: z.boolean().optional().describe('자녀 보호 기능 제공'),
53
+ ageAssurance: z.boolean().optional().describe('연령 확인 장치 제공'),
54
+ kidsAgeBand: z
55
+ .enum(['FIVE_AND_UNDER', 'SIX_TO_EIGHT', 'NINE_TO_ELEVEN'])
56
+ .optional()
57
+ .describe('키즈 카테고리 대상 연령대 (키즈 앱만)'),
58
+ ageRatingOverrideV2: z
59
+ .enum(['NONE', 'NINE_PLUS', 'THIRTEEN_PLUS', 'SIXTEEN_PLUS', 'EIGHTEEN_PLUS', 'UNRATED'])
60
+ .optional()
61
+ .describe('산출 등급을 더 높게 덮어쓸 때만'),
62
+ koreaAgeRatingOverride: z
63
+ .enum(['NONE', 'FIFTEEN_PLUS', 'NINETEEN_PLUS'])
64
+ .optional()
65
+ .describe('한국 등급 별도 지정 (게임물관리위원회 등급을 반영해야 할 때)'),
66
+ developerAgeRatingInfoUrl: z.string().optional().describe('등급 근거 안내 URL'),
67
+ }, async ({ appId, ...declaration }) => {
68
+ const { declarationId, declaration: after } = await appstoreDeclarations.updateAgeRating({
69
+ appId,
70
+ declaration: declaration,
71
+ });
72
+ const changed = Object.keys(declaration).filter((k) => declaration[k] !== undefined);
73
+ return textResult([
74
+ `✅ 연령 등급 갱신 (${changed.length}개 항목) — declaration ${declarationId}`,
75
+ ...changed.map((k) => ` ${k}: ${String(after[k])}`),
76
+ ]);
77
+ });
78
+ server.tool('appstore_declare_encryption', [
79
+ '수출 규정(암호화) 선언을 만들고 선택한 빌드에 연결한다 — POST /v1/appEncryptionDeclarations.',
80
+ 'Info.plist 의 ITSAppUsesNonExemptEncryption 이 있으면 보통 이 선언이 필요 없다 —',
81
+ '그게 없어서 ASC 가 "수출 규정 정보 누락"으로 제출을 막을 때 쓰는 경로다.',
82
+ '⚠️ 법적 신고 성격의 선언이다. 값은 반드시 사용자에게 확인받고 넣을 것.',
83
+ ].join(' '), {
84
+ appId: z.string().describe('App Store 앱 ID'),
85
+ appDescription: z.string().describe('앱이 사용하는 암호화에 대한 설명 (심사용)'),
86
+ containsProprietaryCryptography: z.boolean().describe('자체 개발한 암호화를 포함하는가'),
87
+ containsThirdPartyCryptography: z.boolean().describe('서드파티 암호화를 포함하는가'),
88
+ availableOnFrenchStore: z.boolean().describe('프랑스 스토어에 배포하는가 (프랑스는 별도 신고 규정)'),
89
+ buildIds: z
90
+ .array(z.string())
91
+ .optional()
92
+ .describe('이 선언을 연결할 빌드 ID들 (appstore_list_builds 결과). 생략 시 선언만 생성'),
93
+ }, async ({ appId, buildIds, ...attrs }) => {
94
+ const r = await appstoreDeclarations.declareEncryption({ appId, buildIds, ...attrs });
95
+ return textResult([
96
+ '✅ 수출 규정 선언 생성',
97
+ ` declarationId: ${r.declarationId}`,
98
+ ` 상태: ${r.state ?? '(응답에 없음)'}`,
99
+ ` 연결된 빌드: ${r.attachedBuilds}개`,
100
+ r.state === 'IN_REVIEW' ? ' Apple 검토 중 — 승인되면 빌드에 반영된다.' : '',
101
+ ].filter(Boolean));
102
+ });
103
+ server.tool('appstore_get_availability', [
104
+ '앱의 판매 지역 상태를 읽는다 — GET /v1/apps/{id}/appAvailabilityV2.',
105
+ '판매 중/중지 지역 수와 신규 지역 자동 포함 여부를 보여준다.',
106
+ 'includeTerritories=true 면 지역별 행(id·available·releaseDate)까지 — 이 id 가 변경 도구의 입력이다.',
107
+ ].join(' '), {
108
+ appId: z.string().describe('App Store 앱 ID'),
109
+ includeTerritories: z.boolean().optional().describe('지역 목록까지 반환 (기본 false)'),
110
+ }, async ({ appId, includeTerritories }) => {
111
+ const r = await appstoreDeclarations.getAvailability({ appId, includeTerritories });
112
+ const lines = [
113
+ `판매 지역 (availability ${r.availabilityId ?? '없음'})`,
114
+ ` 판매 중: ${r.availableCount}개 · 중지: ${r.unavailableCount}개`,
115
+ ` 신규 지역 자동 포함: ${r.availableInNewTerritories === undefined ? '알 수 없음' : r.availableInNewTerritories}`,
116
+ ];
117
+ if (r.territories) {
118
+ for (const t of r.territories) {
119
+ lines.push(` ${t.id}: ${t.available ? '판매' : '중지'}` +
120
+ (t.releaseDate ? ` · 출시일 ${t.releaseDate}` : '') +
121
+ (t.preOrderEnabled ? ' · 사전주문' : ''));
122
+ }
123
+ }
124
+ return textResult(lines.join('\n'));
125
+ });
126
+ server.tool('appstore_set_territory_availability', [
127
+ '지역별 판매 여부·출시일을 바꾼다 — PATCH /v1/territoryAvailabilities/{id}.',
128
+ 'id 는 appstore_get_availability(includeTerritories=true) 결과에서 가져올 것 — 지역 코드를 추측해 넣지 말 것.',
129
+ '한 지역이 실패해도 나머지는 계속 진행하고, 지역별 성공/실패를 그대로 보고한다.',
130
+ '⚠️ available=false 는 그 지역에서 앱을 내리는 동작이다.',
131
+ ].join(' '), {
132
+ territories: z
133
+ .array(z.object({
134
+ id: z.string().describe('territoryAvailability 리소스 id'),
135
+ available: z.boolean().optional().describe('판매 여부'),
136
+ releaseDate: z.string().optional().describe('출시일 (YYYY-MM-DD)'),
137
+ preOrderEnabled: z.boolean().optional().describe('사전주문 활성화'),
138
+ }))
139
+ .min(1)
140
+ .describe('변경할 지역 목록'),
141
+ confirm: z.boolean().optional().describe('true 명시 시에만 실제 변경. 생략/false 면 변경 예정 목록만 반환.'),
142
+ }, async ({ territories, confirm }) => {
143
+ if (!confirm) {
144
+ return textResult([
145
+ `🛑 dry-run — ${territories.length}개 지역, 아직 바꾸지 않았다.`,
146
+ ...territories.map((t) => ` ${t.id}: ` +
147
+ [
148
+ t.available === undefined ? '' : `판매=${t.available}`,
149
+ t.releaseDate ? `출시일=${t.releaseDate}` : '',
150
+ t.preOrderEnabled === undefined ? '' : `사전주문=${t.preOrderEnabled}`,
151
+ ].filter(Boolean).join(' · ')),
152
+ '',
153
+ '실행하려면 confirm: true 로 다시 호출.',
154
+ ]);
155
+ }
156
+ const results = await appstoreDeclarations.setTerritoryAvailability({ territories });
157
+ const ok = results.filter((r) => r.ok).length;
158
+ return textResult([
159
+ `지역 변경: 성공 ${ok} / 실패 ${results.length - ok}`,
160
+ ...results.filter((r) => !r.ok).map((r) => ` ✗ ${r.id}: ${r.error}`),
161
+ ]);
162
+ });
163
+ }
@@ -0,0 +1,6 @@
1
+ /** 제품 페이지 미디어 — 스크린샷 세트와 미리보기 동영상. */
2
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
3
+ /** appstore_list_screenshots · appstore_upload_screenshot · appstore_delete_screenshot(_set) */
4
+ export declare function registerScreenshotTools(server: ToolRegistrar): void;
5
+ /** appstore_list_previews · appstore_upload_preview · appstore_delete_preview */
6
+ export declare function registerPreviewTools(server: ToolRegistrar): void;
@@ -0,0 +1,116 @@
1
+ import { z } from 'zod';
2
+ import * as appstoreScreenshots from '../../appstore/screenshots.js';
3
+ import * as previews from '../../appstore/previews.js';
4
+ import { jsonResult, textResult } from '../../lib/mcp-response.js';
5
+ /** appstore_list_screenshots · appstore_upload_screenshot · appstore_delete_screenshot(_set) */
6
+ export function registerScreenshotTools(server) {
7
+ server.tool('appstore_list_screenshots', "App Store 스크린샷 셋 + 이미지 목록 조회 (로컬라이제이션 단위). 디스플레이 타입별로 그룹핑됨", { localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)') }, async ({ localizationId }) => {
8
+ const sets = await appstoreScreenshots.listScreenshotSets(localizationId);
9
+ return jsonResult(sets);
10
+ });
11
+ server.tool('appstore_upload_screenshot', [
12
+ "App Store 스크린샷 업로드 (자동 4단계: 셋 확보 → 예약 → 청크 업로드 → 커밋).",
13
+ "파일 경로는 로컬 절대경로. displayType은 Apple 공식 enum:",
14
+ "APP_IPHONE_69 (6.9\", 1290x2796) / APP_IPHONE_67 (6.7\", 1290x2796) /",
15
+ "APP_IPHONE_65 (6.5\", 1242x2688) / APP_IPHONE_61 (6.1\", 1170x2532) /",
16
+ "APP_IPHONE_58 (5.8\") / APP_IPHONE_55 (5.5\", 1242x2208) /",
17
+ "APP_IPAD_PRO_3GEN_129 (13\", 2064x2752) / APP_IPAD_PRO_3GEN_11 (11\", 1668x2388) /",
18
+ "APP_IPAD_PRO_129 (12.9\", 2048x2732) / APP_DESKTOP (2560x1600+).",
19
+ "해상도 틀리면 검수에서 리젝됨. 수정 가능한 버전 상태에서만 반영됨.",
20
+ ].join(' '), {
21
+ localizationId: z.string().describe('로컬라이제이션 ID'),
22
+ displayType: z.string().describe('Apple 스크린샷 디스플레이 타입 (예: APP_IPHONE_69)'),
23
+ filePath: z.string().describe('업로드할 이미지 파일의 절대경로'),
24
+ }, async ({ localizationId, displayType, filePath }) => {
25
+ const result = await appstoreScreenshots.uploadScreenshot(localizationId, displayType, filePath);
26
+ return textResult(`✅ 스크린샷 업로드 완료 (${displayType})\n\n${JSON.stringify(result, null, 2)}`);
27
+ });
28
+ server.tool('appstore_delete_screenshot', 'App Store 개별 스크린샷 삭제', { screenshotId: z.string().describe('스크린샷 ID (appstore_list_screenshots 결과)') }, async ({ screenshotId }) => {
29
+ const result = await appstoreScreenshots.deleteScreenshot(screenshotId);
30
+ return textResult(`✅ 스크린샷 삭제됨\n${JSON.stringify(result, null, 2)}`);
31
+ });
32
+ server.tool('appstore_delete_screenshot_set', 'App Store 스크린샷 셋 전체 삭제 (디스플레이 타입 교체 시 먼저 정리)', { setId: z.string().describe('스크린샷 셋 ID (appstore_list_screenshots 결과)') }, async ({ setId }) => {
33
+ const result = await appstoreScreenshots.deleteScreenshotSet(setId);
34
+ return textResult(`✅ 셋 삭제됨\n${JSON.stringify(result, null, 2)}`);
35
+ });
36
+ }
37
+ /** appstore_list_previews · appstore_upload_preview · appstore_delete_preview */
38
+ export function registerPreviewTools(server) {
39
+ // ─── 제품 페이지 미리보기 동영상 ───
40
+ // 스크린샷과 같은 4단계 업로드지만, 커밋 후 Apple 인코딩이 남는다 — 업로드 성공 ≠ 노출 가능.
41
+ const PREVIEW_TYPES = [
42
+ 'IPHONE_67', 'IPHONE_61', 'IPHONE_65', 'IPHONE_58', 'IPHONE_55', 'IPHONE_47', 'IPHONE_40', 'IPHONE_35',
43
+ 'IPAD_PRO_3GEN_129', 'IPAD_PRO_3GEN_11', 'IPAD_PRO_129', 'IPAD_105', 'IPAD_97',
44
+ 'DESKTOP', 'APPLE_TV', 'APPLE_VISION_PRO',
45
+ ];
46
+ server.tool('appstore_list_previews', [
47
+ '버전 로케일의 미리보기 동영상 세트를 조회한다 — 읽기 전용.',
48
+ '세트별 previewType 과 각 동영상의 처리 상태(assetDeliveryState)·포스터 프레임·videoUrl 을 보여준다.',
49
+ '업로드 직후 인코딩이 끝났는지 확인할 때도 이걸 쓴다.',
50
+ ].join(' '), {
51
+ localizationId: z
52
+ .string()
53
+ .describe('appStoreVersionLocalization ID (appstore_get_metadata 결과의 로케일별 id)'),
54
+ }, async ({ localizationId }) => {
55
+ const sets = await previews.listPreviewSets(localizationId);
56
+ if (sets.length === 0) {
57
+ return textResult('미리보기 세트 없음.');
58
+ }
59
+ const lines = [];
60
+ for (const s of sets) {
61
+ lines.push(`${s.previewType ?? '?'} (set ${s.id}) — ${s.previews.length}개`);
62
+ for (const p of s.previews) {
63
+ lines.push(` ${p.id}: ${p.fileName ?? '?'} · ${p.state ?? '상태 미상'}` +
64
+ (p.previewFrameTimeCode ? ` · 포스터 ${p.previewFrameTimeCode}` : '') +
65
+ (p.videoUrl ? ' · 재생 가능' : ''));
66
+ }
67
+ }
68
+ return textResult(lines.join('\n'));
69
+ });
70
+ server.tool('appstore_upload_preview', [
71
+ '제품 페이지 미리보기 **동영상**을 업로드한다 — appPreviewSets/appPreviews 4단계 업로드.',
72
+ 'previewType 세트가 없으면 만들고, 예약 → 조각 업로드 → 커밋까지 한 번에 처리한다.',
73
+ '⚠️ 커밋 후 Apple 인코딩이 남는다 — 성공 응답이 곧 노출 가능은 아니다. appstore_list_previews 로 확인할 것.',
74
+ 'Apple 제약: 길이 15~30초, previewType 별 해상도 고정, 로케일·타입당 최대 3개. 어기면 인코딩 단계에서 거부된다.',
75
+ 'previewFrameTimeCode(HH:MM:SS:FF)로 포스터 프레임을 지정할 수 있다.',
76
+ '파일은 절대경로로 넘긴다 (대용량이라 조각 업로드하며, 동영상 바이트는 대화에 싣지 않는다).',
77
+ ].join(' '), {
78
+ localizationId: z.string().describe('appStoreVersionLocalization ID'),
79
+ previewType: z.enum(PREVIEW_TYPES).describe('미리보기 타입 (기기군)'),
80
+ filePath: z.string().describe('동영상 파일 절대경로 (.mp4 / .mov / .m4v)'),
81
+ previewFrameTimeCode: z
82
+ .string()
83
+ .optional()
84
+ .describe('포스터 프레임 시각 HH:MM:SS:FF (예: 00:00:03:00)'),
85
+ }, async ({ localizationId, previewType, filePath, previewFrameTimeCode }) => {
86
+ const r = await previews.uploadPreview({ localizationId, previewType, filePath, previewFrameTimeCode });
87
+ return textResult([
88
+ '✅ 미리보기 업로드 완료 (인코딩 대기)',
89
+ ` id: ${r.id}`,
90
+ ` 파일: ${r.fileName} (${(r.fileSize / 1024 / 1024).toFixed(1)} MB)`,
91
+ ` 타입: ${r.previewType}`,
92
+ ` 상태: ${r.state ?? '처리 중'}`,
93
+ 'Apple 인코딩이 끝나야 노출된다 — appstore_list_previews 로 상태를 확인할 것.',
94
+ ]);
95
+ });
96
+ server.tool('appstore_delete_preview', [
97
+ '미리보기 동영상 또는 세트를 삭제한다 — DELETE /v1/appPreviews/{id} 또는 /appPreviewSets/{id}.',
98
+ '⚠️ 되돌릴 수 없다. 세트를 지우면 그 안의 동영상이 모두 사라진다. confirm 필요.',
99
+ ].join(' '), {
100
+ previewId: z.string().optional().describe('삭제할 미리보기 ID (set 과 둘 중 하나)'),
101
+ setId: z.string().optional().describe('삭제할 미리보기 세트 ID (안의 동영상 전부 삭제)'),
102
+ confirm: z.boolean().optional().describe('true 명시 시에만 삭제'),
103
+ }, async ({ previewId, setId, confirm }) => {
104
+ if (!previewId && !setId)
105
+ throw new Error('previewId 또는 setId 중 하나는 필요하다.');
106
+ if (!confirm) {
107
+ return textResult(previewId
108
+ ? `🛑 dry-run — 미리보기 ${previewId} 를 삭제할 참이다. confirm: true 로 다시 호출.`
109
+ : `🛑 dry-run — 세트 ${setId} 와 그 안의 동영상 전부를 삭제할 참이다. confirm: true 로 다시 호출.`);
110
+ }
111
+ const r = previewId
112
+ ? await previews.deletePreview(previewId)
113
+ : await previews.deletePreviewSet(setId);
114
+ return textResult(`✅ 삭제 완료 — ${r.id}`);
115
+ });
116
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 스토어 메타데이터 — 버전 로컬라이제이션(설명 · 키워드 · What's New), 심사 리뷰어 노트,
3
+ * appInfo 와 그 로컬라이제이션(앱 이름 · 부제 · 개인정보 URL).
4
+ */
5
+ import type { ToolRegistrar } from '../../lib/tool-registrar.js';
6
+ /** appstore_get_metadata · appstore_update_localization */
7
+ export declare function registerVersionLocalizationTools(server: ToolRegistrar): void;
8
+ /** appstore_update_whats_new · appstore_update_review_notes · appstore_get_review_notes */
9
+ export declare function registerWhatsNewAndReviewNoteTools(server: ToolRegistrar): void;
10
+ /** appstore_get_app_info · appstore_{list,update,create}_app_info_localization(s) */
11
+ export declare function registerAppInfoTools(server: ToolRegistrar): void;