@yoonion/mimi-seed-mcp 0.2.0 → 0.3.0

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/dist/index.js CHANGED
@@ -2,12 +2,18 @@
2
2
  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
- import { getAuthenticatedClient, getStoredTokens } from './auth/google-auth.js';
5
+ import { getAuthenticatedClient, getStoredTokens, startAuth } from './auth/google-auth.js';
6
+ import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './auth/constants.js';
6
7
  import * as firebase from './firebase/tools.js';
7
8
  import * as admob from './admob/tools.js';
8
9
  import * as playstore from './playstore/tools.js';
9
10
  import * as appstore from './appstore/tools.js';
11
+ import * as appstoreScreenshots from './appstore/screenshots.js';
10
12
  import * as iam from './iam/tools.js';
13
+ import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from './checks/risks.js';
14
+ import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from './checks/screenshots.js';
15
+ import { generateReleaseNotesFromCommits, formatGeneratedNotes } from './ai/notes.js';
16
+ import { generateReviewReply, formatReviewReply } from './ai/review.js';
11
17
  const server = new McpServer({
12
18
  name: 'mimi-seed',
13
19
  version: '0.1.0',
@@ -282,6 +288,65 @@ server.tool('playstore_list_tracks', 'Google Play 릴리스 트랙 현황 (프
282
288
  const tracks = await playstore.listTracks(auth, packageName);
283
289
  return { content: [{ type: 'text', text: JSON.stringify(tracks, null, 2) }] };
284
290
  });
291
+ server.tool('playstore_list_images', 'Google Play 리스팅 이미지 목록 조회 (imageType별)', {
292
+ packageName: z.string().describe('패키지명'),
293
+ language: z.string().describe('언어 코드 (예: ko-KR)'),
294
+ imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']).describe('이미지 타입'),
295
+ }, async ({ packageName, language, imageType }) => {
296
+ const auth = requireAuth();
297
+ const images = await playstore.listImages(auth, packageName, language, imageType);
298
+ return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
299
+ });
300
+ server.tool('playstore_upload_image', 'Google Play 리스팅 이미지 단일 업로드 (기존 이미지 유지). featureGraphic 1024x500 / icon 512x512 / phoneScreenshots 320~3840px', {
301
+ packageName: z.string().describe('패키지명'),
302
+ language: z.string().describe('언어 코드'),
303
+ imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
304
+ filePath: z.string().describe('업로드할 이미지 절대 경로'),
305
+ }, async ({ packageName, language, imageType, filePath }) => {
306
+ const auth = requireAuth();
307
+ const result = await playstore.uploadImage(auth, packageName, language, imageType, filePath);
308
+ return { content: [{ type: 'text', text: `✅ 업로드 완료\n${JSON.stringify(result, null, 2)}` }] };
309
+ });
310
+ server.tool('playstore_delete_all_images', 'Google Play 리스팅 특정 imageType의 이미지 전체 삭제 (교체 전 정리)', {
311
+ packageName: z.string().describe('패키지명'),
312
+ language: z.string().describe('언어 코드'),
313
+ imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
314
+ }, async ({ packageName, language, imageType }) => {
315
+ const auth = requireAuth();
316
+ const result = await playstore.deleteAllImages(auth, packageName, language, imageType);
317
+ return { content: [{ type: 'text', text: `✅ 전체 삭제\n${JSON.stringify(result, null, 2)}` }] };
318
+ });
319
+ server.tool('playstore_replace_images', 'Google Play 리스팅 이미지 일괄 교체 (한 edit 세션: deleteall → 순서대로 upload → commit). 스크린샷 5~8장 한 번에 교체 시 효율적. 업로드 순서가 스토어 노출 순서', {
320
+ packageName: z.string().describe('패키지명'),
321
+ language: z.string().describe('언어 코드'),
322
+ imageType: z.enum(['featureGraphic', 'icon', 'phoneScreenshots', 'promoGraphic', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvBanner', 'tvScreenshots', 'wearScreenshots']),
323
+ filePaths: z.array(z.string()).describe('업로드할 이미지 절대 경로 배열 (순서 = 노출 순서)'),
324
+ }, async ({ packageName, language, imageType, filePaths }) => {
325
+ const auth = requireAuth();
326
+ const result = await playstore.replaceImages(auth, packageName, language, imageType, filePaths);
327
+ return { content: [{ type: 'text', text: `✅ ${result.count}장 교체 완료\n${JSON.stringify(result, null, 2)}` }] };
328
+ });
329
+ server.tool('playstore_update_release_notes', "Google Play 트랙 릴리스의 '최근 변경사항'(releaseNotes) 업데이트. versionCode로 타겟 릴리스 지정. 다른 언어/release는 보존. 이미 라이브(completed) 상태도 noteOnly 편집 가능", {
330
+ packageName: z.string().describe('패키지명 (예: gg.pryzm.coffee)'),
331
+ track: z.enum(['production', 'beta', 'alpha', 'internal']).describe('릴리스 트랙'),
332
+ versionCode: z.string().describe('대상 versionCode (문자열, 예: "40")'),
333
+ language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
334
+ text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
335
+ }, async ({ packageName, track, versionCode, language, text }) => {
336
+ const auth = requireAuth();
337
+ const result = await playstore.updateReleaseNotes(auth, packageName, track, versionCode, language, text);
338
+ return { content: [{ type: 'text', text: `✅ ${packageName} ${track} v${versionCode} ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
339
+ });
340
+ server.tool('playstore_update_latest_release_notes', "Google Play 트랙의 최신 릴리스(versionCode 최대) '최근 변경사항' 업데이트 — versionCode를 모를 때 편의용", {
341
+ packageName: z.string().describe('패키지명'),
342
+ track: z.enum(['production', 'beta', 'alpha', 'internal']).describe('릴리스 트랙'),
343
+ language: z.string().describe('언어 코드 (예: ko-KR)'),
344
+ text: z.string().describe('릴리스 노트 본문 (500자 이내)'),
345
+ }, async ({ packageName, track, language, text }) => {
346
+ const auth = requireAuth();
347
+ const result = await playstore.updateLatestReleaseNotes(auth, packageName, track, language, text);
348
+ return { content: [{ type: 'text', text: `✅ ${packageName} ${track} (versionCodes=${JSON.stringify(result.updatedVersionCodes)}) ${language} 노트 반영\n\n${JSON.stringify(result, null, 2)}` }] };
349
+ });
285
350
  server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회', { packageName: z.string().describe('패키지명') }, async ({ packageName }) => {
286
351
  const auth = requireAuth();
287
352
  const reviews = await playstore.listReviews(auth, packageName);
@@ -500,6 +565,64 @@ server.tool('appstore_get_metadata', 'App Store 버전 메타데이터 (설명
500
565
  const localizations = await appstore.getVersionLocalizations(versionId);
501
566
  return { content: [{ type: 'text', text: JSON.stringify(localizations, null, 2) }] };
502
567
  });
568
+ server.tool('appstore_update_localization', "App Store 버전 로컬라이제이션(메타데이터) 수정 — localizationId 직접 지정. 이 버전의 새로운 기능(whatsNew), 설명(description), 키워드, 프로모션 텍스트를 편집. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
569
+ localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)'),
570
+ whatsNew: z.string().optional().describe('이 버전의 새로운 기능 (4000자 이내)'),
571
+ description: z.string().optional().describe('앱 설명 (4000자 이내)'),
572
+ keywords: z.string().optional().describe('키워드 (쉼표 구분, 100자 이내)'),
573
+ promotionalText: z.string().optional().describe('프로모션 텍스트 (170자 이내)'),
574
+ supportUrl: z.string().url().optional().describe('지원 URL'),
575
+ marketingUrl: z.string().url().optional().describe('마케팅 URL'),
576
+ }, async ({ localizationId, ...fields }) => {
577
+ const cleaned = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
578
+ if (Object.keys(cleaned).length === 0) {
579
+ throw new Error('수정할 필드를 하나 이상 지정해줘 (whatsNew, description, keywords, promotionalText, supportUrl, marketingUrl).');
580
+ }
581
+ const result = await appstore.updateVersionLocalization(localizationId, cleaned);
582
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
583
+ });
584
+ server.tool('appstore_list_screenshots', "App Store 스크린샷 셋 + 이미지 목록 조회 (로컬라이제이션 단위). 디스플레이 타입별로 그룹핑됨", { localizationId: z.string().describe('로컬라이제이션 ID (appstore_get_metadata 결과의 id)') }, async ({ localizationId }) => {
585
+ const sets = await appstoreScreenshots.listScreenshotSets(localizationId);
586
+ return { content: [{ type: 'text', text: JSON.stringify(sets, null, 2) }] };
587
+ });
588
+ server.tool('appstore_upload_screenshot', [
589
+ "App Store 스크린샷 업로드 (자동 4단계: 셋 확보 → 예약 → 청크 업로드 → 커밋).",
590
+ "파일 경로는 로컬 절대경로. displayType은 Apple 공식 enum:",
591
+ "APP_IPHONE_69 (6.9\", 1290x2796) / APP_IPHONE_67 (6.7\", 1290x2796) /",
592
+ "APP_IPHONE_65 (6.5\", 1242x2688) / APP_IPHONE_61 (6.1\", 1170x2532) /",
593
+ "APP_IPHONE_58 (5.8\") / APP_IPHONE_55 (5.5\", 1242x2208) /",
594
+ "APP_IPAD_PRO_3GEN_129 (13\", 2064x2752) / APP_IPAD_PRO_3GEN_11 (11\", 1668x2388) /",
595
+ "APP_IPAD_PRO_129 (12.9\", 2048x2732) / APP_DESKTOP (2560x1600+).",
596
+ "해상도 틀리면 검수에서 리젝됨. 수정 가능한 버전 상태에서만 반영됨.",
597
+ ].join(' '), {
598
+ localizationId: z.string().describe('로컬라이제이션 ID'),
599
+ displayType: z.string().describe('Apple 스크린샷 디스플레이 타입 (예: APP_IPHONE_69)'),
600
+ filePath: z.string().describe('업로드할 이미지 파일의 절대경로'),
601
+ }, async ({ localizationId, displayType, filePath }) => {
602
+ const result = await appstoreScreenshots.uploadScreenshot(localizationId, displayType, filePath);
603
+ return {
604
+ content: [{
605
+ type: 'text',
606
+ text: `✅ 스크린샷 업로드 완료 (${displayType})\n\n${JSON.stringify(result, null, 2)}`,
607
+ }],
608
+ };
609
+ });
610
+ server.tool('appstore_delete_screenshot', 'App Store 개별 스크린샷 삭제', { screenshotId: z.string().describe('스크린샷 ID (appstore_list_screenshots 결과)') }, async ({ screenshotId }) => {
611
+ const result = await appstoreScreenshots.deleteScreenshot(screenshotId);
612
+ return { content: [{ type: 'text', text: `✅ 스크린샷 삭제됨\n${JSON.stringify(result, null, 2)}` }] };
613
+ });
614
+ server.tool('appstore_delete_screenshot_set', 'App Store 스크린샷 셋 전체 삭제 (디스플레이 타입 교체 시 먼저 정리)', { setId: z.string().describe('스크린샷 셋 ID (appstore_list_screenshots 결과)') }, async ({ setId }) => {
615
+ const result = await appstoreScreenshots.deleteScreenshotSet(setId);
616
+ return { content: [{ type: 'text', text: `✅ 셋 삭제됨\n${JSON.stringify(result, null, 2)}` }] };
617
+ });
618
+ server.tool('appstore_update_whats_new', "App Store '이 버전의 새로운 기능' 편집 — versionId + locale만 주면 자동으로 로컬라이제이션을 찾아 PATCH. 가장 흔한 사용 케이스. 수정 가능한 상태(PREPARE_FOR_SUBMISSION 등)인 버전에서만 반영됨", {
619
+ versionId: z.string().describe('버전 ID (appstore_list_versions 결과)'),
620
+ locale: z.string().describe('로캘 (예: ko, en-US, ja)'),
621
+ whatsNew: z.string().describe("'이 버전의 새로운 기능' 텍스트 (4000자 이내)"),
622
+ }, async ({ versionId, locale, whatsNew }) => {
623
+ const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
624
+ return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
625
+ });
503
626
  server.tool('appstore_list_builds', 'TestFlight 빌드 목록', { appId: z.string().describe('앱 ID') }, async ({ appId }) => {
504
627
  const builds = await appstore.listBuilds(appId);
505
628
  return { content: [{ type: 'text', text: JSON.stringify(builds, null, 2) }] };
@@ -513,6 +636,30 @@ server.tool('appstore_get_app_info', 'App Store 앱 정보 (카테고리, 연령
513
636
  return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
514
637
  });
515
638
  // --- 인증 상태 ---
639
+ server.tool('mimi_seed_auth_start', [
640
+ 'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
641
+ '응답에 포함된 URL을 브라우저에서 열고 승인하면 localhost:9876으로 자동 콜백 → 토큰이 ~/.mimi-seed/tokens.json에 저장됨.',
642
+ '이후 playstore_*, firebase_*, admob_* 등 다른 MCP 도구 바로 호출 가능.',
643
+ '토큰 만료(invalid_rapt) / 재인증 필요 시 사용. 10분 내 완료해야 함.',
644
+ ].join(' '), {}, async () => {
645
+ const { url, wait } = startAuth(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET);
646
+ // fire-and-forget — 토큰은 콜백 서버가 자동 저장
647
+ wait.then(() => { }, (err) => { console.error('[mimi-seed auth]', err.message); });
648
+ return {
649
+ content: [{
650
+ type: 'text',
651
+ text: [
652
+ '🔐 Google 로그인 링크 (10분 유효):',
653
+ '',
654
+ url,
655
+ '',
656
+ '이 URL을 브라우저에서 열고 Google 계정으로 승인해줘.',
657
+ '완료되면 localhost:9876으로 자동 리다이렉트되고 토큰이 저장돼.',
658
+ '이후 바로 다른 MCP 도구(playstore_*, firebase_* 등) 호출 가능.',
659
+ ].join('\n'),
660
+ }],
661
+ };
662
+ });
516
663
  server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인', {}, async () => {
517
664
  const client = getAuthenticatedClient();
518
665
  if (!client) {
@@ -543,6 +690,118 @@ server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인', {}, a
543
690
  };
544
691
  });
545
692
  // ══════════════════════════════════════════════════
693
+ // 제출 위험 점검 (Submission Risk Check)
694
+ // ══════════════════════════════════════════════════
695
+ server.tool('playstore_check_submission_risks', [
696
+ 'Google Play 제출 전 위험 요소를 자동으로 점검합니다.',
697
+ '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
698
+ '점검 항목: 리스팅 완성도(제목/설명/짧은설명), 스크린샷 수, 아이콘, 빌드 존재 여부, 연락처.',
699
+ ].join(' '), {
700
+ packageName: z.string().describe('Android 패키지명 (예: com.example.myapp)'),
701
+ language: z.string().optional().describe('언어 코드 (기본: ko-KR)'),
702
+ }, async ({ packageName, language }) => {
703
+ const auth = requireAuth();
704
+ const risks = await checkPlayStoreRisks(auth, packageName, language ?? 'ko-KR');
705
+ const text = formatRisks(risks, 'Google Play');
706
+ return { content: [{ type: 'text', text }] };
707
+ });
708
+ server.tool('appstore_check_submission_risks', [
709
+ 'App Store 제출 전 위험 요소를 자동으로 점검합니다.',
710
+ '블로커(반드시 수정)와 경고(권장 수정)로 분류하여 반환합니다.',
711
+ '점검 항목: 메타데이터 완성도(설명/What\'s New/키워드), 스크린샷, TestFlight 빌드, 개인정보처리방침 URL.',
712
+ 'App Store 인증이 필요합니다 (appstore_* 도구 사용 가능 상태여야 함).',
713
+ ].join(' '), {
714
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id 필드)'),
715
+ }, async ({ appId }) => {
716
+ const risks = await checkAppStoreRisks(appId);
717
+ const text = formatRisks(risks, 'App Store');
718
+ return { content: [{ type: 'text', text }] };
719
+ });
720
+ // ══════════════════════════════════════════════════
721
+ // 스크린샷 해상도 검증 (Screenshot Validation)
722
+ // ══════════════════════════════════════════════════
723
+ server.tool('screenshot_validate', [
724
+ '로컬 스크린샷 파일의 해상도·파일크기·종횡비를 App Store / Play Store 규격과 비교 검증합니다.',
725
+ 'PNG/JPEG 파일 경로를 배열로 받아 각 파일의 통과 여부와 문제점을 반환합니다.',
726
+ 'platform: "ios" 또는 "android" (기본: ios)',
727
+ 'iOS displayType 예시: APP_IPHONE_69, APP_IPHONE_67, APP_IPHONE_65, APP_IPAD_PRO_3GEN_129',
728
+ 'Android imageType 예시: phoneScreenshots, sevenInchScreenshots, tenInchScreenshots, featureGraphic',
729
+ '업로드 전 미리 검사하여 리젝 방지에 활용하세요.',
730
+ ].join(' '), {
731
+ filePaths: z.array(z.string()).describe('검증할 이미지 파일 절대경로 배열'),
732
+ platform: z.enum(['ios', 'android']).optional().describe('플랫폼 (기본: ios)'),
733
+ displayType: z.string().optional().describe('iOS 디스플레이 타입 (예: APP_IPHONE_67)'),
734
+ imageType: z.string().optional().describe('Android 이미지 타입 (예: phoneScreenshots)'),
735
+ }, async ({ filePaths, platform, displayType, imageType }) => {
736
+ const plat = platform ?? 'ios';
737
+ if (plat === 'ios') {
738
+ const results = validateAppStoreScreenshots(filePaths, displayType);
739
+ const text = formatValidationResults(results, 'App Store');
740
+ return { content: [{ type: 'text', text }] };
741
+ }
742
+ else {
743
+ const results = validatePlayStoreScreenshots(filePaths, imageType ?? 'phoneScreenshots');
744
+ const text = formatValidationResults(results, 'Google Play');
745
+ return { content: [{ type: 'text', text }] };
746
+ }
747
+ });
748
+ // ══════════════════════════════════════════════════
749
+ // AI 릴리즈 노트 생성 (ANTHROPIC_API_KEY 필요)
750
+ // ══════════════════════════════════════════════════
751
+ server.tool('generate_release_notes_from_commits', [
752
+ 'git 커밋 내역을 받아 Claude AI로 앱 스토어용 릴리즈 노트를 생성합니다.',
753
+ '3가지 톤(간결/상세/마케팅)과 다국어 버전을 동시에 생성합니다.',
754
+ 'ANTHROPIC_API_KEY 환경변수가 필요합니다.',
755
+ 'commits: [{ message, author?, date? }] 형태로 전달하세요.',
756
+ 'locales: 다국어 생성할 로케일 배열 (예: ["ko", "en-US", "ja"])',
757
+ '생성 후 playstore_update_release_notes 또는 appstore_update_whats_new로 적용하세요.',
758
+ ].join(' '), {
759
+ commits: z.array(z.object({
760
+ message: z.string(),
761
+ hash: z.string().optional(),
762
+ author: z.string().optional(),
763
+ date: z.string().optional(),
764
+ })).describe('git 커밋 배열'),
765
+ appName: z.string().optional().describe('앱 이름 (프롬프트 맥락용)'),
766
+ locales: z.array(z.string()).optional().describe('다국어 로케일 목록 (예: ["ko", "en-US", "ja"])'),
767
+ }, async ({ commits, appName, locales }) => {
768
+ const result = await generateReleaseNotesFromCommits(commits, {
769
+ appName,
770
+ locales: locales ?? [],
771
+ });
772
+ const text = formatGeneratedNotes(result);
773
+ return { content: [{ type: 'text', text }] };
774
+ });
775
+ // ══════════════════════════════════════════════════
776
+ // AI 리뷰 답변 생성 (ANTHROPIC_API_KEY 필요)
777
+ // ══════════════════════════════════════════════════
778
+ server.tool('generate_review_reply', [
779
+ 'Play Store / App Store 리뷰에 대한 AI 답변 초안을 생성합니다.',
780
+ 'ANTHROPIC_API_KEY 환경변수가 필요합니다.',
781
+ 'tone: friendly(친근) / professional(정중) / empathetic(공감) / brief(간결) — 기본: friendly',
782
+ 'language: ko / en / ja / zh 등 — 기본: ko',
783
+ '⚠ 생성된 답변은 초안입니다. 게시 전 반드시 검토하세요.',
784
+ '답변 게시는 playstore_reply_to_review 도구를 사용하세요.',
785
+ ].join(' '), {
786
+ reviewText: z.string().describe('리뷰 원문'),
787
+ rating: z.number().min(1).max(5).optional().describe('별점 (1~5)'),
788
+ appName: z.string().optional().describe('앱 이름'),
789
+ tone: z.enum(['friendly', 'professional', 'empathetic', 'brief']).optional().describe('답변 톤'),
790
+ language: z.string().optional().describe('답변 언어 코드 (기본: ko)'),
791
+ developerName: z.string().optional().describe('개발자/팀 이름'),
792
+ }, async ({ reviewText, rating, appName, tone, language, developerName }) => {
793
+ const result = await generateReviewReply({
794
+ reviewText,
795
+ rating,
796
+ appName,
797
+ tone: tone ?? 'friendly',
798
+ language: language ?? 'ko',
799
+ developerName,
800
+ });
801
+ const text = formatReviewReply(result);
802
+ return { content: [{ type: 'text', text }] };
803
+ });
804
+ // ══════════════════════════════════════════════════
546
805
  // Start
547
806
  // ══════════════════════════════════════════════════
548
807
  async function main() {
@@ -1,4 +1,13 @@
1
1
  import type { OAuth2Client } from 'google-auth-library';
2
+ export type PlayImageType = 'featureGraphic' | 'icon' | 'phoneScreenshots' | 'promoGraphic' | 'sevenInchScreenshots' | 'tenInchScreenshots' | 'tvBanner' | 'tvScreenshots' | 'wearScreenshots';
3
+ /**
4
+ * Google Play Developer API (Android Publisher API v3) 래퍼
5
+ *
6
+ * 주의: 최초 앱 생성은 API로 불가 (Play Console에서만).
7
+ * 여기서는 기존 앱의 메타데이터, 빌드, 출시를 관리.
8
+ */
9
+ 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>;
2
11
  export declare function getAppDetails(auth: OAuth2Client, packageName: string): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
3
12
  export declare function getListing(auth: OAuth2Client, packageName: string, language?: string): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
4
13
  export declare function updateListing(auth: OAuth2Client, packageName: string, language: string, data: {
@@ -15,6 +24,36 @@ export declare function listTracks(auth: OAuth2Client, packageName: string): Pro
15
24
  releaseNotes: import("googleapis").androidpublisher_v3.Schema$LocalizedText[] | undefined;
16
25
  }[];
17
26
  }[]>;
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
+ /**
29
+ * 최신 release (versionCode 최대값)의 releaseNotes[language]를 교체/추가.
30
+ * versionCode를 모를 때 편의용.
31
+ */
32
+ export declare function updateLatestReleaseNotes(auth: OAuth2Client, packageName: string, track: string, language: string, text: string): Promise<{
33
+ updatedVersionCodes: string[] | null | undefined;
34
+ updatedReleaseName: string | null | undefined;
35
+ releases?: import("googleapis").androidpublisher_v3.Schema$TrackRelease[];
36
+ track?: string | null;
37
+ }>;
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<{
41
+ ok: boolean;
42
+ imageType: PlayImageType;
43
+ }>;
44
+ /**
45
+ * 한 edit 세션 내에서 기존 이미지 전체 삭제 + 새 이미지 순서대로 업로드 + commit.
46
+ * phoneScreenshots처럼 여러 장 교체 시 효율적 (단일 edit).
47
+ */
48
+ export declare function replaceImages(auth: OAuth2Client, packageName: string, language: string, imageType: PlayImageType, filePaths: string[]): Promise<{
49
+ imageType: PlayImageType;
50
+ count: number;
51
+ uploaded: {
52
+ id?: string | null;
53
+ url?: string | null;
54
+ sha256?: string | null;
55
+ }[];
56
+ }>;
18
57
  export declare function listReviews(auth: OAuth2Client, packageName: string): Promise<{
19
58
  reviewId: string | null | undefined;
20
59
  authorName: string | null | undefined;
@@ -27,13 +66,9 @@ export declare function listReviews(auth: OAuth2Client, packageName: string): Pr
27
66
  }[]>;
28
67
  export declare function replyToReview(auth: OAuth2Client, packageName: string, reviewId: string, replyText: string): Promise<import("googleapis").androidpublisher_v3.Schema$ReviewsReplyResponse>;
29
68
  export declare function listInAppProducts(auth: OAuth2Client, packageName: string): Promise<{
30
- sku: string | null | undefined;
31
- status: string | null | undefined;
32
- purchaseType: string | null | undefined;
33
- defaultPrice: import("googleapis").androidpublisher_v3.Schema$Price | undefined;
34
- listings: {
35
- [key: string]: import("googleapis").androidpublisher_v3.Schema$InAppProductListing;
36
- } | null | undefined;
69
+ productId: any;
70
+ listings: any;
71
+ purchaseOptions: any;
37
72
  }[]>;
38
73
  export declare function listSubscriptions(auth: OAuth2Client, packageName: string): Promise<{
39
74
  productId: string | null | undefined;
@@ -1,14 +1,24 @@
1
1
  import { google } from 'googleapis';
2
2
  import { JWT } from 'google-auth-library';
3
+ import fs from 'node:fs';
4
+ function mimeTypeFor(filePath) {
5
+ const ext = filePath.toLowerCase().split('.').pop() ?? '';
6
+ if (ext === 'png')
7
+ return 'image/png';
8
+ if (ext === 'jpg' || ext === 'jpeg')
9
+ return 'image/jpeg';
10
+ if (ext === 'webp')
11
+ return 'image/webp';
12
+ return 'application/octet-stream';
13
+ }
3
14
  /**
4
15
  * Google Play Developer API (Android Publisher API v3) 래퍼
5
16
  *
6
17
  * 주의: 최초 앱 생성은 API로 불가 (Play Console에서만).
7
18
  * 여기서는 기존 앱의 메타데이터, 빌드, 출시를 관리.
8
19
  */
9
- const publisher = () => google.androidpublisher('v3');
10
- // ─── withEdit 헬퍼 ───
11
- async function withEdit(auth, packageName, fn, commit = false) {
20
+ export const publisher = () => google.androidpublisher('v3');
21
+ export async function withEdit(auth, packageName, fn, commit = false) {
12
22
  const res = await publisher().edits.insert({ auth, packageName });
13
23
  const editId = res.data.id;
14
24
  if (!editId)
@@ -81,6 +91,135 @@ export async function listTracks(auth, packageName) {
81
91
  }));
82
92
  });
83
93
  }
94
+ // ─── 릴리스 노트 업데이트 ───
95
+ //
96
+ // track의 특정 release(versionCode 매칭)에 대해 releaseNotes[language]를
97
+ // 교체/추가. 다른 언어 노트와 다른 release entry는 보존. edit 세션으로
98
+ // tracks.get → 수정 → tracks.update → commit. 이미 라이브(completed) 상태인
99
+ // release도 releaseNotes만은 편집 가능.
100
+ export async function updateReleaseNotes(auth, packageName, track, versionCode, language, text) {
101
+ return withEdit(auth, packageName, async (editId) => {
102
+ const current = await publisher().edits.tracks.get({
103
+ auth, packageName, editId, track,
104
+ });
105
+ const releases = current.data.releases ?? [];
106
+ if (releases.length === 0) {
107
+ throw new Error(`${track} 트랙에 릴리스가 없어.`);
108
+ }
109
+ const target = releases.find((r) => (r.versionCodes ?? []).some((v) => String(v) === String(versionCode)));
110
+ if (!target) {
111
+ const available = releases.map((r) => ({
112
+ name: r.name,
113
+ versionCodes: r.versionCodes,
114
+ status: r.status,
115
+ }));
116
+ throw new Error(`versionCode "${versionCode}"를 ${track} 트랙에서 찾을 수 없어. 가능한 릴리스: ${JSON.stringify(available)}`);
117
+ }
118
+ const notes = target.releaseNotes ?? [];
119
+ const idx = notes.findIndex((n) => n.language === language);
120
+ if (idx >= 0)
121
+ notes[idx] = { language, text };
122
+ else
123
+ notes.push({ language, text });
124
+ target.releaseNotes = notes;
125
+ const updated = await publisher().edits.tracks.update({
126
+ auth, packageName, editId, track,
127
+ requestBody: { track, releases },
128
+ });
129
+ return updated.data;
130
+ }, true);
131
+ }
132
+ /**
133
+ * 최신 release (versionCode 최대값)의 releaseNotes[language]를 교체/추가.
134
+ * versionCode를 모를 때 편의용.
135
+ */
136
+ export async function updateLatestReleaseNotes(auth, packageName, track, language, text) {
137
+ return withEdit(auth, packageName, async (editId) => {
138
+ const current = await publisher().edits.tracks.get({
139
+ auth, packageName, editId, track,
140
+ });
141
+ const releases = current.data.releases ?? [];
142
+ if (releases.length === 0) {
143
+ throw new Error(`${track} 트랙에 릴리스가 없어.`);
144
+ }
145
+ const maxVc = (r) => Math.max(...(r.versionCodes ?? []).map((v) => Number(v)), 0);
146
+ const target = releases.reduce((best, r) => (maxVc(r) > maxVc(best) ? r : best));
147
+ const notes = target.releaseNotes ?? [];
148
+ const idx = notes.findIndex((n) => n.language === language);
149
+ if (idx >= 0)
150
+ notes[idx] = { language, text };
151
+ else
152
+ notes.push({ language, text });
153
+ target.releaseNotes = notes;
154
+ const updated = await publisher().edits.tracks.update({
155
+ auth, packageName, editId, track,
156
+ requestBody: { track, releases },
157
+ });
158
+ return {
159
+ ...updated.data,
160
+ updatedVersionCodes: target.versionCodes,
161
+ updatedReleaseName: target.name,
162
+ };
163
+ }, true);
164
+ }
165
+ // ─── 이미지 (feature graphic / phone screenshots / etc.) ───
166
+ export async function listImages(auth, packageName, language, imageType) {
167
+ return withEdit(auth, packageName, async (editId) => {
168
+ const res = await publisher().edits.images.list({
169
+ auth, packageName, editId, language, imageType,
170
+ });
171
+ return res.data.images ?? [];
172
+ });
173
+ }
174
+ export async function uploadImage(auth, packageName, language, imageType, filePath) {
175
+ if (!fs.existsSync(filePath))
176
+ throw new Error(`File not found: ${filePath}`);
177
+ return withEdit(auth, packageName, async (editId) => {
178
+ const res = await publisher().edits.images.upload({
179
+ auth, packageName, editId, language, imageType,
180
+ media: {
181
+ mimeType: mimeTypeFor(filePath),
182
+ body: fs.createReadStream(filePath),
183
+ },
184
+ });
185
+ return res.data.image;
186
+ }, true);
187
+ }
188
+ export async function deleteAllImages(auth, packageName, language, imageType) {
189
+ return withEdit(auth, packageName, async (editId) => {
190
+ await publisher().edits.images.deleteall({
191
+ auth, packageName, editId, language, imageType,
192
+ });
193
+ return { ok: true, imageType };
194
+ }, true);
195
+ }
196
+ /**
197
+ * 한 edit 세션 내에서 기존 이미지 전체 삭제 + 새 이미지 순서대로 업로드 + commit.
198
+ * phoneScreenshots처럼 여러 장 교체 시 효율적 (단일 edit).
199
+ */
200
+ export async function replaceImages(auth, packageName, language, imageType, filePaths) {
201
+ for (const p of filePaths) {
202
+ if (!fs.existsSync(p))
203
+ throw new Error(`File not found: ${p}`);
204
+ }
205
+ return withEdit(auth, packageName, async (editId) => {
206
+ await publisher().edits.images.deleteall({
207
+ auth, packageName, editId, language, imageType,
208
+ });
209
+ const uploaded = [];
210
+ for (const filePath of filePaths) {
211
+ const res = await publisher().edits.images.upload({
212
+ auth, packageName, editId, language, imageType,
213
+ media: {
214
+ mimeType: mimeTypeFor(filePath),
215
+ body: fs.createReadStream(filePath),
216
+ },
217
+ });
218
+ uploaded.push(res.data.image ?? {});
219
+ }
220
+ return { imageType, count: uploaded.length, uploaded };
221
+ }, true);
222
+ }
84
223
  // ─── 리뷰 조회 ───
85
224
  export async function listReviews(auth, packageName) {
86
225
  const res = await publisher().reviews.list({ auth, packageName });
@@ -107,16 +246,15 @@ export async function replyToReview(auth, packageName, reviewId, replyText) {
107
246
  }
108
247
  // ─── 인앱 상품 조회 ───
109
248
  export async function listInAppProducts(auth, packageName) {
110
- const res = await publisher().inappproducts.list({
249
+ const res = await publisher().monetization.onetimeproducts.list({
111
250
  auth,
112
251
  packageName,
252
+ pageSize: 100,
113
253
  });
114
- return (res.data.inappproduct ?? []).map((p) => ({
115
- sku: p.sku,
116
- status: p.status,
117
- purchaseType: p.purchaseType,
118
- defaultPrice: p.defaultPrice,
254
+ return (res.data.oneTimeProducts ?? []).map((p) => ({
255
+ productId: p.productId,
119
256
  listings: p.listings,
257
+ purchaseOptions: p.purchaseOptions,
120
258
  }));
121
259
  }
122
260
  // ─── 구독 조회 ───
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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": {
@@ -42,8 +42,9 @@
42
42
  "node": ">=20"
43
43
  },
44
44
  "dependencies": {
45
+ "@anthropic-ai/sdk": "^0.52.0",
45
46
  "@modelcontextprotocol/sdk": "^1.12.1",
46
- "googleapis": "^148.0.0",
47
+ "googleapis": "^171.4.0",
47
48
  "jsonwebtoken": "^9.0.3",
48
49
  "open": "^10.1.0",
49
50
  "zod": "^3.24.0"