@yoonion/mimi-seed-mcp 0.19.3 → 0.19.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.
@@ -31,6 +31,14 @@ export interface AppStoreKey {
31
31
  keyId: string;
32
32
  privateKey: string;
33
33
  }
34
+ /**
35
+ * 최상위 ASC API 키를 교체하되 같은 파일에 저장된 선택 설정은 보존한다.
36
+ *
37
+ * 재인증은 primary key 교체이지 appstore.json 전체 초기화가 아니다. 특히
38
+ * vendorNumber 와 reportsKey 는 별도 화면/키에서 얻는 값이라 다시 묻지 않고
39
+ * 지우면 매출·분석 기능이 조용히 망가진다.
40
+ */
41
+ export declare function mergeAppStoreCredentials(existing: AppStoreCredentials | null, primary: AppStoreKey, vendorNumber?: string): AppStoreCredentials;
34
42
  export declare function getAppStoreCredentials(): AppStoreCredentials | null;
35
43
  export declare function saveAppStoreCredentials(creds: AppStoreCredentials): void;
36
44
  export declare function generateToken(creds: AppStoreCredentials): Promise<string>;
@@ -10,6 +10,20 @@ import { writeCredentialJson } from '../lib/atomic-write.js';
10
10
  // force a re-setup.
11
11
  const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'appstore.json');
12
12
  const LEGACY_CONFIG_PATH = path.join(os.homedir(), '.preseed', 'appstore.json');
13
+ /**
14
+ * 최상위 ASC API 키를 교체하되 같은 파일에 저장된 선택 설정은 보존한다.
15
+ *
16
+ * 재인증은 primary key 교체이지 appstore.json 전체 초기화가 아니다. 특히
17
+ * vendorNumber 와 reportsKey 는 별도 화면/키에서 얻는 값이라 다시 묻지 않고
18
+ * 지우면 매출·분석 기능이 조용히 망가진다.
19
+ */
20
+ export function mergeAppStoreCredentials(existing, primary, vendorNumber) {
21
+ const merged = { ...(existing ?? {}), ...primary };
22
+ const normalizedVendorNumber = vendorNumber?.trim();
23
+ if (normalizedVendorNumber)
24
+ merged.vendorNumber = normalizedVendorNumber;
25
+ return merged;
26
+ }
13
27
  export function getAppStoreCredentials() {
14
28
  const pathToRead = fs.existsSync(CONFIG_PATH)
15
29
  ? CONFIG_PATH
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { saveAppStoreCredentials, getAppStoreCredentials } from './auth.js';
2
+ import { getAppStoreCredentials, mergeAppStoreCredentials } from './auth.js';
3
+ import { verifyAndSaveAppStoreCredentials } from './setup.js';
3
4
  import readline from 'node:readline';
4
5
  import fs from 'node:fs';
5
6
  import { resolveLang } from '../lib/lang.js';
6
7
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
7
8
  const ko = {
8
9
  title: ' 🍎 Mimi Seed — App Store Connect 연결',
9
- already: (keyId) => ` ✅ 이미 연결됨 (Key ID: ${keyId})`,
10
- reconnect: ' 다시 설정할래? (y/N): ',
10
+ already: (keyId) => ` ✅ 설정 파일 있음 (Key ID: ${keyId}) — API 권한은 다시 검증합니다`,
11
+ reconnect: ' API Key도 다시 설정할래? (y/N): ',
11
12
  needTitle: ' App Store Connect에서 API Key를 만들어야 해:',
12
13
  step1: ' 1. "키 생성" 클릭',
13
14
  step2: ' 2. 이름: Mimi Seed, 역할: Admin',
@@ -15,16 +16,20 @@ const ko = {
15
16
  askIssuerId: ' Issuer ID: ',
16
17
  askKeyId: ' Key ID: ',
17
18
  askP8Path: ' .p8 파일 경로: ',
19
+ askVendorNumber: ' Vendor Number (선택, Enter로 기존 값 유지): ',
18
20
  noFile: (p) => ` ❌ 파일 없음: ${p}`,
19
- done: ' ✅ 연결 완료!',
21
+ verifying: ' Apple API로 자격증명을 검증하는 중...',
22
+ verifyFailed: (stage, message) => ` ❌ 검증 실패 (${stage}) — 저장하지 않았습니다.\n${message}`,
23
+ done: ' ✅ 연결 완료! (App Store API 검증 성공)',
24
+ vendorDone: ' ✅ Vendor Number 저장 완료! (기존 App Store API 키 검증 성공)',
20
25
  nextTitle: ' 이제 Claude Code 또는 Codex에서:',
21
26
  nextExample1: ' "내 앱스토어 앱 목록 보여줘"',
22
27
  nextExample2: ' "TestFlight 빌드 목록 보여줘"',
23
28
  };
24
29
  const en = {
25
30
  title: ' 🍎 Mimi Seed — Connect App Store Connect',
26
- already: (keyId) => ` ✅ Already connected (Key ID: ${keyId})`,
27
- reconnect: ' Set it up again? (y/N): ',
31
+ already: (keyId) => ` ✅ Configuration found (Key ID: ${keyId}) — API access will be verified again`,
32
+ reconnect: ' Replace the API Key too? (y/N): ',
28
33
  needTitle: ' You need to create an API Key in App Store Connect:',
29
34
  step1: ' 1. Click "Generate API Key"',
30
35
  step2: ' 2. Name: Mimi Seed, Access: Admin',
@@ -32,8 +37,12 @@ const en = {
32
37
  askIssuerId: ' Issuer ID: ',
33
38
  askKeyId: ' Key ID: ',
34
39
  askP8Path: ' Path to the .p8 file: ',
40
+ askVendorNumber: ' Vendor Number (optional, Enter keeps the saved value): ',
35
41
  noFile: (p) => ` ❌ File not found: ${p}`,
36
- done: ' ✅ Connected!',
42
+ verifying: ' Verifying the credentials with the Apple API...',
43
+ verifyFailed: (stage, message) => ` ❌ Verification failed (${stage}) — nothing was saved.\n${message}`,
44
+ done: ' ✅ Connected! (App Store API verification passed)',
45
+ vendorDone: ' ✅ Vendor Number saved! (existing App Store API key verification passed)',
37
46
  nextTitle: ' Now, in Claude Code or Codex:',
38
47
  nextExample1: ' "Show my App Store apps"',
39
48
  nextExample2: ' "List the TestFlight builds"',
@@ -41,15 +50,31 @@ const en = {
41
50
  const M = resolveLang() === 'en' ? en : ko;
42
51
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
43
52
  const ask = (q) => new Promise((r) => rl.question(q, r));
53
+ async function saveIfVerified(credentials) {
54
+ console.log(M.verifying);
55
+ const verification = await verifyAndSaveAppStoreCredentials(credentials);
56
+ if (verification.ok)
57
+ return true;
58
+ console.log(M.verifyFailed(verification.stage, verification.message));
59
+ process.exitCode = 1;
60
+ return false;
61
+ }
44
62
  async function main() {
45
63
  console.log('');
46
64
  console.log(M.title);
47
65
  console.log('');
48
66
  const existing = getAppStoreCredentials();
67
+ let vendorNumber = '';
49
68
  if (existing) {
50
69
  console.log(M.already(existing.keyId));
70
+ vendorNumber = await ask(M.askVendorNumber);
51
71
  const answer = await ask(M.reconnect);
52
72
  if (answer.toLowerCase() !== 'y') {
73
+ if (vendorNumber.trim()) {
74
+ const credentials = mergeAppStoreCredentials(existing, existing, vendorNumber);
75
+ if (await saveIfVerified(credentials))
76
+ console.log(M.vendorDone);
77
+ }
53
78
  rl.close();
54
79
  return;
55
80
  }
@@ -64,6 +89,8 @@ async function main() {
64
89
  const issuerId = await ask(M.askIssuerId);
65
90
  const keyId = await ask(M.askKeyId);
66
91
  const p8Path = await ask(M.askP8Path);
92
+ if (!existing)
93
+ vendorNumber = await ask(M.askVendorNumber);
67
94
  const trimmedPath = p8Path.trim().replace(/^["']|["']$/g, '');
68
95
  if (!fs.existsSync(trimmedPath)) {
69
96
  console.log(M.noFile(trimmedPath));
@@ -71,11 +98,11 @@ async function main() {
71
98
  process.exit(1);
72
99
  }
73
100
  const privateKey = fs.readFileSync(trimmedPath, 'utf-8');
74
- saveAppStoreCredentials({
75
- issuerId: issuerId.trim(),
76
- keyId: keyId.trim(),
77
- privateKey,
78
- });
101
+ const credentials = mergeAppStoreCredentials(existing, { issuerId: issuerId.trim(), keyId: keyId.trim(), privateKey }, vendorNumber);
102
+ if (!(await saveIfVerified(credentials))) {
103
+ rl.close();
104
+ return;
105
+ }
79
106
  console.log('');
80
107
  console.log(M.done);
81
108
  console.log('');
@@ -0,0 +1,9 @@
1
+ import { type AppStoreCredentials } from './auth.js';
2
+ import { type AppStoreVerifyResult } from './tools.js';
3
+ interface SetupDependencies {
4
+ verify: (credentials: AppStoreCredentials) => Promise<AppStoreVerifyResult>;
5
+ save: (credentials: AppStoreCredentials) => void;
6
+ }
7
+ /** Apple API에서 실제로 쓸 수 있는 키인지 확인한 뒤에만 디스크에 저장한다. */
8
+ export declare function verifyAndSaveAppStoreCredentials(credentials: AppStoreCredentials, dependencies?: SetupDependencies): Promise<AppStoreVerifyResult>;
9
+ export {};
@@ -0,0 +1,13 @@
1
+ import { saveAppStoreCredentials, } from './auth.js';
2
+ import { verifyAppStoreCredentials, } from './tools.js';
3
+ const defaultDependencies = {
4
+ verify: verifyAppStoreCredentials,
5
+ save: saveAppStoreCredentials,
6
+ };
7
+ /** Apple API에서 실제로 쓸 수 있는 키인지 확인한 뒤에만 디스크에 저장한다. */
8
+ export async function verifyAndSaveAppStoreCredentials(credentials, dependencies = defaultDependencies) {
9
+ const result = await dependencies.verify(credentials);
10
+ if (result.ok)
11
+ dependencies.save(credentials);
12
+ return result;
13
+ }
@@ -17,7 +17,7 @@ export interface AppStoreVerifyResult {
17
17
  * 파일 존재만 보는 requireAppStoreCreds 와 달리, 잘못된 .p8/keyId/issuerId 를
18
18
  * "첫 호출 401" 로 늦게 터지기 전에 setup 단계에서 잡아준다.
19
19
  */
20
- export declare function verifyAppStoreCredentials(): Promise<AppStoreVerifyResult>;
20
+ export declare function verifyAppStoreCredentials(creds?: import("./auth.js").AppStoreCredentials | null): Promise<AppStoreVerifyResult>;
21
21
  export declare function listApps(): Promise<any>;
22
22
  export declare function getApp(appId: string): Promise<any>;
23
23
  export declare function listVersions(appId: string): Promise<any>;
@@ -36,8 +36,7 @@ export async function apiGet(path, params) {
36
36
  * 파일 존재만 보는 requireAppStoreCreds 와 달리, 잘못된 .p8/keyId/issuerId 를
37
37
  * "첫 호출 401" 로 늦게 터지기 전에 setup 단계에서 잡아준다.
38
38
  */
39
- export async function verifyAppStoreCredentials() {
40
- const creds = getAppStoreCredentials();
39
+ export async function verifyAppStoreCredentials(creds = getAppStoreCredentials()) {
41
40
  if (!creds) {
42
41
  return {
43
42
  ok: false,
@@ -8,7 +8,7 @@ export type BillingInfo = {
8
8
  /** 사람이 읽을 판정 — Blaze/Spark 오판을 막기 위해 문장으로 준다. */
9
9
  verdict: string;
10
10
  };
11
- export declare function getBillingInfo(auth: OAuth2Client, projectId: string): Promise<BillingInfo>;
11
+ export declare function getBillingInfo(auth: OAuth2Client, projectId: string, quotaProjectId?: string): Promise<BillingInfo>;
12
12
  /**
13
13
  * 결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.
14
14
  * 여기서 프로젝트가 여러 개면 계정 전체 예산은 의미가 없고 프로젝트 필터를 써야 한다.
@@ -44,12 +44,13 @@ export function normalizeBillingAccount(input) {
44
44
  const trimmed = input.trim();
45
45
  return trimmed.startsWith('billingAccounts/') ? trimmed : `billingAccounts/${trimmed}`;
46
46
  }
47
- export async function getBillingInfo(auth, projectId) {
48
- // 조회 대상 프로젝트 자신을 quota 주체로 쓴다 — 거기 Billing API 가 켜져 있어야 한다.
47
+ export async function getBillingInfo(auth, projectId, quotaProjectId = projectId) {
48
+ // 기본은 조회 대상 프로젝트를 quota 주체로 쓰되, Spark 프로젝트처럼 유료 API를
49
+ // 소비할 수 없는 대상은 별도의 Blaze quota 프로젝트를 지정할 수 있다.
49
50
  const res = await billing().projects.getBillingInfo({
50
51
  auth,
51
52
  name: `projects/${projectId}`,
52
- ...quotaHeaders(projectId),
53
+ ...quotaHeaders(quotaProjectId),
53
54
  });
54
55
  const enabled = res.data.billingEnabled ?? false;
55
56
  const account = res.data.billingAccountName ?? null;
@@ -32,8 +32,10 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
32
32
  projectId: string;
33
33
  namespace?: string;
34
34
  days?: number;
35
+ quotaProjectId?: string;
35
36
  }): Promise<{
36
37
  projectId: string;
38
+ quotaProjectId: string;
37
39
  namespace: string;
38
40
  pricingPolicy: {
39
41
  effectiveDate: string;
@@ -3,6 +3,7 @@ import { getBillingInfo } from '../billing/tools.js';
3
3
  const FREE_DAILY_FETCHES = 100_000;
4
4
  const FIRST_PAID_TIER_END = 10_000_000;
5
5
  const REMOTE_CONFIG_BASE = 'https://firebaseremoteconfig.googleapis.com/v1';
6
+ const FIREBASE_MANAGEMENT_BASE = 'https://firebase.googleapis.com/v1beta1';
6
7
  export function countStates(items) {
7
8
  const counts = {};
8
9
  for (const item of items) {
@@ -11,9 +12,12 @@ export function countStates(items) {
11
12
  }
12
13
  return counts;
13
14
  }
14
- async function optionalRequest(auth, url) {
15
+ async function optionalRequest(auth, url, quotaProjectId) {
15
16
  try {
16
- const response = await auth.request({ url });
17
+ const response = await auth.request({
18
+ url,
19
+ headers: { 'x-goog-user-project': quotaProjectId },
20
+ });
17
21
  return { available: true, data: response.data };
18
22
  }
19
23
  catch (error) {
@@ -23,7 +27,7 @@ async function optionalRequest(auth, url) {
23
27
  };
24
28
  }
25
29
  }
26
- async function optionalPagedRequest(auth, url, field) {
30
+ async function optionalPagedRequest(auth, url, field, quotaProjectId) {
27
31
  try {
28
32
  const items = [];
29
33
  let pageToken;
@@ -33,7 +37,10 @@ async function optionalPagedRequest(auth, url, field) {
33
37
  requestUrl.searchParams.set('pageSize', '100');
34
38
  if (pageToken)
35
39
  requestUrl.searchParams.set('pageToken', pageToken);
36
- const response = await auth.request({ url: requestUrl.toString() });
40
+ const response = await auth.request({
41
+ url: requestUrl.toString(),
42
+ headers: { 'x-goog-user-project': quotaProjectId },
43
+ });
37
44
  const data = response.data;
38
45
  items.push(...(data[field] ?? []));
39
46
  pageToken = data.nextPageToken;
@@ -68,7 +75,7 @@ export function remoteConfigUsageLevel(fetches) {
68
75
  return 'warning';
69
76
  return 'ok';
70
77
  }
71
- async function fetchDailyUsage(auth, projectId, days) {
78
+ async function fetchDailyUsage(auth, projectId, days, quotaProjectId) {
72
79
  try {
73
80
  const end = new Date();
74
81
  const start = new Date(end.getTime() - days * 86_400_000);
@@ -84,6 +91,8 @@ async function fetchDailyUsage(auth, projectId, days) {
84
91
  'aggregation.crossSeriesReducer': 'REDUCE_SUM',
85
92
  view: 'FULL',
86
93
  pageSize: Math.max(days + 2, 10),
94
+ }, {
95
+ headers: { 'x-goog-user-project': quotaProjectId },
87
96
  });
88
97
  const points = (response.data.timeSeries ?? [])
89
98
  .flatMap((series) => series.points ?? [])
@@ -109,15 +118,32 @@ async function fetchDailyUsage(auth, projectId, days) {
109
118
  export async function getRemoteConfigOverview(auth, input) {
110
119
  const namespace = input.namespace ?? 'firebase';
111
120
  const days = Math.min(Math.max(input.days ?? 7, 1), 30);
121
+ // User OAuth otherwise charges quota to the shared OAuth-client project. The
122
+ // resource project is the safest default; Spark projects can point this at a
123
+ // separate Blaze project with the relevant APIs enabled.
124
+ const quotaProjectId = input.quotaProjectId ?? input.projectId;
112
125
  const encodedProject = encodeURIComponent(input.projectId);
113
126
  const encodedNamespace = encodeURIComponent(namespace);
114
127
  const parent = `${REMOTE_CONFIG_BASE}/projects/${encodedProject}/namespaces/${encodedNamespace}`;
128
+ // The rollout beta endpoint rejects project IDs even though the REST path uses
129
+ // the generic {project} placeholder; live responses require projectNumber.
130
+ const projectNumber = optionalRequest(auth, `${FIREBASE_MANAGEMENT_BASE}/projects/${encodedProject}`, quotaProjectId);
131
+ const rolloutsPromise = projectNumber.then(async (result) => {
132
+ if (!result.available || !result.data?.projectNumber) {
133
+ return {
134
+ available: false,
135
+ error: result.error ?? 'Firebase project number is unavailable.',
136
+ };
137
+ }
138
+ const rolloutParent = `${REMOTE_CONFIG_BASE}/projects/${encodeURIComponent(result.data.projectNumber)}/namespaces/${encodedNamespace}`;
139
+ return optionalPagedRequest(auth, `${rolloutParent}/rollouts`, 'rollouts', quotaProjectId);
140
+ });
115
141
  const [template, experiments, rollouts, usage, billing] = await Promise.all([
116
- optionalRequest(auth, `${parent}/remoteConfig`),
117
- optionalPagedRequest(auth, `${parent}/experiments`, 'experiments'),
118
- optionalPagedRequest(auth, `${parent}/rollouts`, 'rollouts'),
119
- fetchDailyUsage(auth, input.projectId, days),
120
- getBillingInfo(auth, input.projectId)
142
+ optionalRequest(auth, `${parent}/remoteConfig`, quotaProjectId),
143
+ optionalPagedRequest(auth, `${parent}/experiments`, 'experiments', quotaProjectId),
144
+ rolloutsPromise,
145
+ fetchDailyUsage(auth, input.projectId, days, quotaProjectId),
146
+ getBillingInfo(auth, input.projectId, quotaProjectId)
121
147
  .then((data) => ({ available: true, data }))
122
148
  .catch((error) => ({
123
149
  available: false,
@@ -167,6 +193,7 @@ export async function getRemoteConfigOverview(auth, input) {
167
193
  .map(summarizeRollout);
168
194
  return {
169
195
  projectId: input.projectId,
196
+ quotaProjectId,
170
197
  namespace,
171
198
  pricingPolicy: {
172
199
  effectiveDate: '2026-09-01',
@@ -38,9 +38,10 @@ export function registerFirebaseTools(server) {
38
38
  projectId: z.string().describe('Firebase / Google Cloud 프로젝트 ID'),
39
39
  namespace: z.string().optional().describe('Remote Config namespace (기본: firebase)'),
40
40
  days: z.number().int().min(1).max(30).optional().describe('사용량 조회 일수 (기본: 7, 최대: 30)'),
41
- }, async ({ projectId, namespace, days }) => {
41
+ quotaProjectId: z.string().optional().describe('API quota/과금 프로젝트 ID. 기본은 projectId. Spark 프로젝트의 Monitoring 조회가 billing_required이면 Remote Config·Monitoring API가 활성화된 Blaze 프로젝트를 지정'),
42
+ }, async ({ projectId, namespace, days, quotaProjectId }) => {
42
43
  const auth = await requireAuth();
43
- return jsonResult(await getRemoteConfigOverview(auth, { projectId, namespace, days }));
44
+ return jsonResult(await getRemoteConfigOverview(auth, { projectId, namespace, days, quotaProjectId }));
44
45
  });
45
46
  server.tool('firebase_list_projects', '내 Firebase 프로젝트 목록 조회', {}, async () => {
46
47
  const auth = await requireAuth();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
4
4
  "description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {