@yoonion/mimi-seed-mcp 0.13.5 → 0.13.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.
@@ -18,8 +18,17 @@ const CODE_HINTS = {
18
18
  // 버전 상태가 편집 불가 (READY_FOR_SALE / WAITING_FOR_REVIEW 등)
19
19
  ENTITY_STATE_INVALID: '버전 상태가 편집 가능 단계가 아니에요 (이미 심사중/출시됨). 새 versionString 으로 appstore_create_version 필요할 수 있어요.',
20
20
  // 필수 필드 누락
21
+ // ⚠️ Apple 이 실제로 보내는 코드는 점 표기다 (ENTITY_ERROR.ATTRIBUTE.REQUIRED).
22
+ // 아래 언더스코어 키들은 오래 매칭된 적이 없었다 — 점 표기를 정본으로 두고
23
+ // 언더스코어 변형은 혹시 모를 옛 응답용으로 남긴다 (2026-07-21 실측으로 발견).
24
+ 'ENTITY_ERROR.ATTRIBUTE.REQUIRED': '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
21
25
  ENTITY_ERROR_ATTRIBUTE_REQUIRED: '필수 필드가 빠졌어요 — source.pointer 위치 확인.',
22
- // 길이 / 형식 위반
26
+ // 길이 초과 detail 에 실제 상한이 숫자로 들어온다 ("Max number of characters is (55)").
27
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID.TOO_LONG': '길이 초과 — detail 의 괄호 숫자가 Apple 이 강제하는 실제 상한이에요. 그 값에 맞춰 줄이세요.',
28
+ // 심사 중이라 필드가 잠김. 상품이 WAITING_FOR_REVIEW/IN_REVIEW 면 현지화를 못 고친다.
29
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE': '지금 상태에선 못 고치는 필드예요 — 상품이 심사 중이면 현지화가 잠깁니다. '
30
+ + '심사 결과를 기다리거나 appstore_cancel_review 로 철회한 뒤 수정하세요.',
31
+ 'ENTITY_ERROR.ATTRIBUTE.INVALID': '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
23
32
  ENTITY_ERROR_ATTRIBUTE_INVALID: '필드 값이 정책 위반(길이/형식). source.pointer 위치 확인.',
24
33
  // 빌드 attach 시 빌드를 못 찾음
25
34
  NOT_FOUND: '대상 리소스를 찾을 수 없어요 — id/versionId/buildId 가 유효한지, 동일 앱 소속인지 확인.',
@@ -45,12 +54,30 @@ function parseAppleErrorBody(body) {
45
54
  return null;
46
55
  }
47
56
  }
57
+ /**
58
+ * 코드 → hint. 정확히 일치하는 게 없으면 점 표기를 뒤에서부터 잘라 상위 코드로 폴백한다.
59
+ * Apple 은 `ENTITY_ERROR.ATTRIBUTE.INVALID.TOO_LONG` 처럼 세분화된 코드를 계속 늘리는데,
60
+ * 그때마다 키를 추가하지 않아도 `...INVALID` 수준의 안내는 나가야 한다.
61
+ */
62
+ function resolveHint(code) {
63
+ if (!code)
64
+ return undefined;
65
+ if (CODE_HINTS[code])
66
+ return CODE_HINTS[code];
67
+ const parts = code.split('.');
68
+ for (let i = parts.length - 1; i > 0; i -= 1) {
69
+ const hint = CODE_HINTS[parts.slice(0, i).join('.')];
70
+ if (hint)
71
+ return hint;
72
+ }
73
+ return undefined;
74
+ }
48
75
  /** 단일 Apple error → 한 줄 사람용 표기. */
49
76
  function formatAppleError(e) {
50
77
  const code = e.code ?? 'UNKNOWN';
51
78
  const detail = e.detail ?? e.title ?? '(no detail)';
52
79
  const pointer = e.source?.pointer ?? e.source?.parameter;
53
- const hint = e.code ? CODE_HINTS[e.code] : undefined;
80
+ const hint = resolveHint(e.code);
54
81
  const parts = [`[${code}] ${detail}`];
55
82
  if (pointer)
56
83
  parts.push(`@ ${pointer}`);
@@ -29,6 +29,15 @@ function toLocalization(item) {
29
29
  state: item.attributes?.state,
30
30
  };
31
31
  }
32
+ /**
33
+ * 같은 로케일이 여러 건 올 수 있다 — 심사 통과한 APPROVED 판과 편집 중인
34
+ * PREPARE_FOR_SUBMISSION 판이 공존한다(실측: 구독 하나에 ko 가 2건).
35
+ * APPROVED 는 이미 확정된 판이라 그걸 잡아 고치면 엉뚱한 걸 건드린다.
36
+ * 편집 가능한 판이 있으면 그쪽을 고른다.
37
+ */
38
+ function pickEditable(matches) {
39
+ return matches.find((item) => item.state !== 'APPROVED') ?? matches[0];
40
+ }
32
41
  /** 상품에 등록된 현지화 목록. */
33
42
  export async function listProductLocalizations(args) {
34
43
  const { internalId, productType } = args;
@@ -50,8 +59,8 @@ export async function upsertProductLocalization(args) {
50
59
  }
51
60
  const resource = localizationResource(productType);
52
61
  const authHeaders = await authHeadersOrThrow();
53
- const existing = (await listProductLocalizations({ internalId, productType }))
54
- .find((item) => item.locale.toLowerCase() === locale.toLowerCase());
62
+ const existing = pickEditable((await listProductLocalizations({ internalId, productType }))
63
+ .filter((item) => item.locale.toLowerCase() === locale.toLowerCase()));
55
64
  if (existing) {
56
65
  // 넘긴 필드만 보낸다 — description 을 생략한 호출이 기존 설명을 지우면 안 된다.
57
66
  const attributes = {};
@@ -664,11 +664,30 @@ function productReviewItemRelationship(productType) {
664
664
  }
665
665
  return { key: 'inAppPurchaseV2', type: 'inAppPurchases' };
666
666
  }
667
+ /**
668
+ * **아직 제출 안 된** 묶음만 찾는다.
669
+ *
670
+ * findOpenReviewSubmission 을 그대로 쓰면 안 된다 — 그건 WAITING_FOR_REVIEW 까지
671
+ * 잡아오는데, 그건 이미 Apple 큐에 들어간 묶음이다. 거기에 항목을 밀어 넣으면
672
+ * 되든 안 되든 "추가됨" 이라고 보고하게 된다 (실측: 앱 하나에 WAITING_FOR_REVIEW
673
+ * 묶음이 2개 떠 있는 상태가 정상적으로 존재한다).
674
+ *
675
+ * 콘솔의 "제출 초안" 은 READY_FOR_REVIEW 로 보인다. 그게 우리가 담을 대상이다.
676
+ */
677
+ async function findDraftReviewSubmission(appId, platform) {
678
+ const data = await apiGet('/reviewSubmissions', {
679
+ 'filter[app]': appId,
680
+ 'filter[platform]': platform,
681
+ 'filter[state]': 'READY_FOR_REVIEW',
682
+ 'limit': '1',
683
+ });
684
+ return data?.data?.[0]?.id ?? null;
685
+ }
667
686
  export async function addProductToReviewSubmission(args) {
668
687
  const { appId, internalId, productType } = args;
669
688
  const platform = args.platform ?? 'IOS';
670
689
  const relationship = productReviewItemRelationship(productType);
671
- let submissionId = await findOpenReviewSubmission(appId, platform);
690
+ let submissionId = await findDraftReviewSubmission(appId, platform);
672
691
  const reusedSubmission = Boolean(submissionId);
673
692
  if (!submissionId) {
674
693
  const created = await apiPost('/reviewSubmissions', {
@@ -511,13 +511,19 @@ export function registerAppstoreTools(server) {
511
511
  });
512
512
  server.tool('appstore_update_product_localization', 'App Store IAP/구독 상품의 현지화(표시 이름·설명)를 로케일 단위로 upsert — 있으면 수정, 없으면 생성. ' +
513
513
  '현지화가 비면 상품이 MISSING_METADATA 에서 안 풀려 심사에 넣을 수 없다 (리뷰 노트·스크린샷과는 별개 리소스). ' +
514
- 'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. name 30자 / description 45자 제한.', {
514
+ 'locale 은 App Store 표기(ko, en-US, ja, zh-Hant)를 쓴다. ' +
515
+ '길이 상한은 리소스마다 다르고 Apple 이 강제한다 — 구독은 실측으로 name 35 / description 55 다. ' +
516
+ '초과하면 Apple 이 "Max number of characters is (N)" 으로 실제 상한을 알려주므로 그 값에 맞춰 줄이면 된다. ' +
517
+ '⚠️ 심사 중인 상품은 현지화가 잠겨 UNMODIFIABLE 로 거부된다 — 결과를 기다리거나 철회 후 수정한다.', {
515
518
  appId: z.string().describe('App Store 앱 ID (숫자형, appstore_list_apps 결과)'),
516
519
  productId: z.string().describe('상품 ID (appstore_list_products 결과)'),
517
520
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
518
521
  locale: z.string().describe('로케일 (예: ko, en-US, ja, zh-Hant)'),
519
- name: z.string().max(30).optional().describe('표시 이름 (30자 이하). 로케일 생성 필수'),
520
- description: z.string().max(45).optional().describe('설명 (45자 이하). 생략하면 기존 유지'),
522
+ // 실제 상한은 Apple 이 리소스별로 강제한다(구독 name 35 / description 55 실측).
523
+ // 여기서 좁게 잡으면 정상 문구를 클라이언트가 먼저 거부한다 — 실제로 45로 잡아
524
+ // 55자짜리 정상 설명을 막았다. 오타 수준만 걸러내는 넉넉한 상한만 둔다.
525
+ name: z.string().max(200).optional().describe('표시 이름. 새 로케일 생성 시 필수 (구독 실측 상한 35자)'),
526
+ description: z.string().max(500).optional().describe('설명. 생략하면 기존 값 유지 (구독 실측 상한 55자)'),
521
527
  }, async ({ appId, productId, productType, locale, name, description }) => {
522
528
  const creds = requireAppStoreCreds();
523
529
  const products = await listAppleProducts({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
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": {