@yoonion/mimi-seed-mcp 0.19.2 → 0.19.4

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.
@@ -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;
@@ -4,7 +4,7 @@ export interface BillingEvidence {
4
4
  module: string;
5
5
  version?: string;
6
6
  expression?: string;
7
- source: 'literal' | 'variable' | 'version_catalog' | 'unresolved';
7
+ source: 'literal' | 'variable' | 'version_catalog' | 'transitive' | 'unresolved';
8
8
  }
9
9
  export interface BillingComplianceResult {
10
10
  projectPath: string;
@@ -33,4 +33,8 @@ export interface BillingComplianceResult {
33
33
  automaticExecution: false;
34
34
  };
35
35
  }
36
+ export declare function billingVersionFromPom(pom: string): {
37
+ module: string;
38
+ version: string;
39
+ } | null;
36
40
  export declare function checkBillingCompliance(projectPath: string, now?: Date): Promise<BillingComplianceResult>;
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { fetchWithTimeout } from '../lib/http.js';
3
4
  const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
4
5
  const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
5
6
  const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
@@ -41,7 +42,8 @@ async function walk(root, maxDepth = 7) {
41
42
  else if (entry.isFile()
42
43
  && (entry.name === 'build.gradle'
43
44
  || entry.name === 'build.gradle.kts'
44
- || entry.name === 'libs.versions.toml')) {
45
+ || entry.name === 'libs.versions.toml'
46
+ || entry.name === 'package.json')) {
45
47
  result.push(path.join(dir, entry.name));
46
48
  }
47
49
  }
@@ -114,6 +116,127 @@ function majorOf(version) {
114
116
  const major = Number.parseInt(version.split('.')[0], 10);
115
117
  return Number.isFinite(major) ? major : null;
116
118
  }
119
+ export function billingVersionFromPom(pom) {
120
+ for (const match of pom.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
121
+ const block = match[1];
122
+ const group = block.match(/<groupId>\s*([^<]+)\s*<\/groupId>/)?.[1]?.trim();
123
+ const artifact = block.match(/<artifactId>\s*([^<]+)\s*<\/artifactId>/)?.[1]?.trim();
124
+ const version = block.match(/<version>\s*([^<]+)\s*<\/version>/)?.[1]?.trim();
125
+ if (group === 'com.android.billingclient' && /^billing(?:-ktx)?$/.test(artifact ?? '') && version) {
126
+ return { module: `${group}:${artifact}`, version };
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ async function nearestNodePackage(packageDir, root) {
132
+ let current = packageDir;
133
+ while (isWithin(root, current)) {
134
+ const candidate = path.join(current, 'node_modules', 'react-native-iap');
135
+ try {
136
+ if ((await fs.stat(candidate)).isDirectory())
137
+ return candidate;
138
+ }
139
+ catch {
140
+ // Keep walking toward the project root; workspaces commonly hoist node_modules.
141
+ }
142
+ if (current === root)
143
+ break;
144
+ current = path.dirname(current);
145
+ }
146
+ return null;
147
+ }
148
+ async function reactNativeIapEvidence(root, manifestFile, manifestText) {
149
+ let manifest;
150
+ try {
151
+ manifest = JSON.parse(manifestText);
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ const dependencyGroups = ['dependencies', 'devDependencies', 'optionalDependencies']
157
+ .map((key) => manifest[key])
158
+ .filter((value) => Boolean(value) && typeof value === 'object');
159
+ const declaredVersion = dependencyGroups
160
+ .map((group) => group['react-native-iap'])
161
+ .find((value) => typeof value === 'string');
162
+ if (!declaredVersion)
163
+ return null;
164
+ const relativeManifest = path.relative(root, manifestFile).replace(/\\/g, '/');
165
+ const installedDir = await nearestNodePackage(path.dirname(manifestFile), root);
166
+ if (!installedDir) {
167
+ return {
168
+ file: relativeManifest,
169
+ module: 'com.android.billingclient:billing',
170
+ expression: `react-native-iap ${declaredVersion} is declared but not installed; transitive Billing version is unresolved`,
171
+ source: 'unresolved',
172
+ };
173
+ }
174
+ const directCandidates = [
175
+ path.join(installedDir, 'android', 'build.gradle'),
176
+ path.join(installedDir, 'android', 'build.gradle.kts'),
177
+ ];
178
+ for (const candidate of directCandidates) {
179
+ try {
180
+ const text = await fs.readFile(candidate, 'utf8');
181
+ const direct = [...text.matchAll(LITERAL_DEPENDENCY)][0];
182
+ if (direct) {
183
+ return {
184
+ file: relativeManifest,
185
+ module: direct[0].slice(0, direct[0].lastIndexOf(':')),
186
+ version: direct[1],
187
+ expression: `react-native-iap ${declaredVersion} native dependency`,
188
+ source: 'transitive',
189
+ };
190
+ }
191
+ }
192
+ catch {
193
+ // Newer react-native-iap versions delegate Billing to the OpenIAP Maven artifact.
194
+ }
195
+ }
196
+ let openIapVersion;
197
+ try {
198
+ const versions = JSON.parse(await fs.readFile(path.join(installedDir, 'openiap-versions.json'), 'utf8'));
199
+ if (typeof versions.google === 'string')
200
+ openIapVersion = versions.google;
201
+ }
202
+ catch {
203
+ // Older releases may not use OpenIAP; fall through to an unresolved, safe result.
204
+ }
205
+ if (!openIapVersion || !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(openIapVersion)) {
206
+ return {
207
+ file: relativeManifest,
208
+ module: 'com.android.billingclient:billing',
209
+ expression: `react-native-iap ${declaredVersion} detected; transitive Billing version is unresolved`,
210
+ source: 'unresolved',
211
+ };
212
+ }
213
+ const coordinate = `io.github.hyochan.openiap:openiap-google:${openIapVersion}`;
214
+ try {
215
+ const pomUrl = `https://repo.maven.apache.org/maven2/io/github/hyochan/openiap/openiap-google/${openIapVersion}/openiap-google-${openIapVersion}.pom`;
216
+ const response = await fetchWithTimeout(pomUrl, {}, { timeoutMs: 15_000, maxAttempts: 2 });
217
+ if (response.ok) {
218
+ const resolved = billingVersionFromPom(await response.text());
219
+ if (resolved) {
220
+ return {
221
+ file: relativeManifest,
222
+ module: resolved.module,
223
+ version: resolved.version,
224
+ expression: `react-native-iap ${declaredVersion} -> ${coordinate}`,
225
+ source: 'transitive',
226
+ };
227
+ }
228
+ }
229
+ }
230
+ catch {
231
+ // Network failure must not turn a known IAP dependency into not_used.
232
+ }
233
+ return {
234
+ file: relativeManifest,
235
+ module: 'com.android.billingclient:billing',
236
+ expression: `react-native-iap ${declaredVersion} -> ${coordinate}; Maven Billing version lookup failed`,
237
+ source: 'unresolved',
238
+ };
239
+ }
117
240
  function policyAt(now) {
118
241
  const deadlineEnd = (date) => new Date(`${date}T23:59:59.999Z`);
119
242
  const next = BILLING_SUPPORT_SCHEDULE.find((row) => now <= deadlineEnd(row.submissionDeadline));
@@ -175,9 +298,16 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
175
298
  .map(([, variables]) => variables.get(key))
176
299
  .find((value) => value !== undefined);
177
300
  const evidence = [];
301
+ for (const [file, text] of texts) {
302
+ if (path.basename(file) !== 'package.json')
303
+ continue;
304
+ const transitive = await reactNativeIapEvidence(root, file, text);
305
+ if (transitive && !evidence.some((row) => row.expression === transitive.expression))
306
+ evidence.push(transitive);
307
+ }
178
308
  for (const [file, text] of texts) {
179
309
  const relative = path.relative(root, file).replace(/\\/g, '/');
180
- if (path.basename(file) === 'libs.versions.toml') {
310
+ if (path.basename(file) === 'libs.versions.toml' || path.basename(file) === 'package.json') {
181
311
  continue;
182
312
  }
183
313
  const catalog = catalogFor(file);
@@ -276,7 +406,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
276
406
  else if (unresolved || majors.length === 0) {
277
407
  status = 'unresolved';
278
408
  summary = 'A Billing dependency was found, but at least one version expression could not be resolved statically.';
279
- actions.push('Resolve the reported Gradle variable or version catalog alias and run the check again.');
409
+ actions.push('Resolve the reported Gradle/version catalog expression or install the declared IAP package, then run the check again.');
280
410
  }
281
411
  else if (majors.some((major) => major === policy.minimumSupportedMajor)) {
282
412
  status = 'warning';
@@ -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',
@@ -9,7 +9,7 @@ import { checkBillingCompliance } from '../checks/billing.js';
9
9
  export function registerChecksTools(server) {
10
10
  server.tool('android_check_billing_compliance', [
11
11
  '로컬 Android 저장소의 Google Play Billing Library 버전을 읽기 전용으로 탐지하고 제출 정책 마감과 비교합니다.',
12
- 'build.gradle, build.gradle.kts, gradle/libs.versions.toml의 literal·변수·version catalog를 검사합니다.',
12
+ 'build.gradle, build.gradle.kts, gradle/libs.versions.toml의 literal·변수·version catalog와 react-native-iap의 OpenIAP 전이 의존성을 검사합니다.',
13
13
  '코드를 자동 수정하지 않고 공식 Android CLI Skill 설치 명령과 업그레이드 프롬프트만 반환합니다.',
14
14
  ].join(' '), {
15
15
  projectPath: z.string().optional().describe('검사할 프로젝트 절대경로 (기본: MCP 프로세스 현재 디렉터리)'),
@@ -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.2",
3
+ "version": "0.19.4",
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": {