@yoonion/mimi-seed-mcp 0.13.8 → 0.13.10

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/README.md CHANGED
@@ -84,7 +84,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
84
84
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
85
85
  | CI/CD (GitHub Actions · GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
86
86
  | Jenkins (크리덴셜 + 잡) | 10 | `jenkins_create_credential` / `jenkins_upload_keystore` / `jenkins_create_job` / `jenkins_update_job` |
87
- | GA4 | 6 | `ga4_create_property` / `ga4_create_data_stream` / `ga4_run_report` |
87
+ | GA4 | 8 | `ga4_create_property` / `ga4_create_data_stream` / `ga4_plan_bigquery_link` / `ga4_create_bigquery_link` / `ga4_run_report` |
88
88
  | Search Console | 6 | `gsc_inspect_url` / `gsc_search_analytics` / `gsc_submit_sitemap` |
89
89
  | Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
90
90
  | Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
@@ -0,0 +1,2 @@
1
+ /** Open OAuth in the platform's default private/incognito browser window. */
2
+ export declare function openPrivateBrowser(url: string): Promise<void>;
@@ -0,0 +1,5 @@
1
+ import open, { apps } from 'open';
2
+ /** Open OAuth in the platform's default private/incognito browser window. */
3
+ export async function openPrivateBrowser(url) {
4
+ await open(url, { app: { name: apps.browserPrivate } });
5
+ }
package/dist/auth/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import readline from 'node:readline';
3
- import open from 'open';
4
3
  import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
5
4
  import { AuthError, classifyError } from './errors.js';
6
5
  import { getMcpOAuthClient } from './constants.js';
7
6
  import { AUTH_DOMAINS, DOMAIN_IDS, parseDomainList, summarizeGrantedDomains, } from './scopes.js';
7
+ import { openPrivateBrowser } from './browser.js';
8
8
  import { resolveLang } from '../lib/lang.js';
9
9
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
10
10
  // 여기 있는 건 전부 **터미널에 찍히는 사람용 문자열**이다. errors.ts 가 만드는
@@ -60,7 +60,7 @@ const ko = {
60
60
  serverStart: ' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback',
61
61
  serverFail: ' ❌ 콜백 서버 시작 실패',
62
62
  pasteUrl: ' 📋 아래 URL을 브라우저에 직접 붙여넣으세요:',
63
- openingBrowser: ' 🌐 기본 브라우저 자동 열기...',
63
+ openingBrowser: ' 🌐 시크릿 브라우저 자동 열기...',
64
64
  openingHint: ' (실패 시 --no-browser 로 URL 직접 받기)',
65
65
  openFail: (msg) => ` ⚠️ 브라우저 자동 열기 실패: ${msg}`,
66
66
  openManually: ' 📋 직접 열어주세요:',
@@ -124,7 +124,7 @@ const en = {
124
124
  serverStart: ' 🌐 Starting the OAuth callback server: http://localhost:9876/callback',
125
125
  serverFail: ' ❌ Failed to start the callback server',
126
126
  pasteUrl: ' 📋 Paste this URL into your browser:',
127
- openingBrowser: ' 🌐 Opening your default browser...',
127
+ openingBrowser: ' 🌐 Opening a private browser window...',
128
128
  openingHint: ' (if that fails, use --no-browser to get the URL)',
129
129
  openFail: (msg) => ` ⚠️ Could not open the browser: ${msg}`,
130
130
  openManually: ' 📋 Please open it yourself:',
@@ -337,7 +337,7 @@ async function cmdLogin() {
337
337
  else {
338
338
  err(M.openingBrowser);
339
339
  try {
340
- await open(url);
340
+ await openPrivateBrowser(url);
341
341
  err(M.openingHint);
342
342
  }
343
343
  catch (e) {
@@ -31,7 +31,7 @@ export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2
31
31
  /**
32
32
  * OAuth 플로우 시작.
33
33
  * URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
34
- * 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
34
+ * 호출자가 URL을 사용자에게 전달하거나 private 브라우저를 직접 연다.
35
35
  * `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
36
36
  * 재호출 시 기존 세션 자동 정리.
37
37
  *
@@ -47,7 +47,7 @@ export declare function startAuth(clientId: string, clientSecret: string, option
47
47
  wait: Promise<StoredTokens>;
48
48
  };
49
49
  /**
50
- * Interactive login — opens browser, waits for callback.
50
+ * Interactive login — opens a private browser window, waits for callback.
51
51
  * startAuth() 래퍼 — CLI에서 사용.
52
52
  */
53
53
  export declare function login(clientId: string, clientSecret: string, options?: {
@@ -1,11 +1,11 @@
1
1
  import { google } from 'googleapis';
2
2
  import http from 'node:http';
3
- import open from 'open';
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
6
5
  import os from 'node:os';
7
6
  import { getMcpOAuthClient } from './constants.js';
8
7
  import { AuthError, classifyError } from './errors.js';
8
+ import { openPrivateBrowser } from './browser.js';
9
9
  // 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
10
10
  import { scopesForDomains, mergeScopeStrings } from './scopes.js';
11
11
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
@@ -116,7 +116,7 @@ let activeAuthServer = null;
116
116
  /**
117
117
  * OAuth 플로우 시작.
118
118
  * URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
119
- * 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
119
+ * 호출자가 URL을 사용자에게 전달하거나 private 브라우저를 직접 연다.
120
120
  * `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
121
121
  * 재호출 시 기존 세션 자동 정리.
122
122
  *
@@ -138,7 +138,10 @@ export function startAuth(clientId, clientSecret, options = {}) {
138
138
  const authUrl = oauth2Client.generateAuthUrl({
139
139
  access_type: 'offline',
140
140
  scope: requestedScopes,
141
- prompt: 'consent',
141
+ // Private windows can still share cookies with an already-running private session.
142
+ // Force Google to show the account chooser so an unrelated signed-in account is
143
+ // never selected implicitly.
144
+ prompt: 'consent select_account',
142
145
  include_granted_scopes: true,
143
146
  });
144
147
  const wait = new Promise((resolve, reject) => {
@@ -268,13 +271,13 @@ export function startAuth(clientId, clientSecret, options = {}) {
268
271
  return { url: authUrl, wait };
269
272
  }
270
273
  /**
271
- * Interactive login — opens browser, waits for callback.
274
+ * Interactive login — opens a private browser window, waits for callback.
272
275
  * startAuth() 래퍼 — CLI에서 사용.
273
276
  */
274
277
  export async function login(clientId, clientSecret, options = {}) {
275
278
  const { url, wait } = startAuth(clientId, clientSecret, options);
276
- console.log('🔐 브라우저에서 Google 로그인 중...');
277
- open(url);
279
+ console.log('🔐 시크릿 브라우저에서 Google 계정 선택 중...');
280
+ await openPrivateBrowser(url);
278
281
  return wait;
279
282
  }
280
283
  /**
@@ -16,6 +16,8 @@ export type DataStreamPlatform = 'web' | 'android' | 'ios';
16
16
  export declare function normalizeAccountName(accountId: string): string;
17
17
  /** '123' | 'properties/123' → 'properties/123' */
18
18
  export declare function normalizePropertyName(propertyId: string): string;
19
+ /** 'my-project' | 'projects/my-project' → 'projects/my-project' */
20
+ export declare function normalizeCloudProjectName(projectId: string): string;
19
21
  /**
20
22
  * dataStreams.create 응답에서 클라이언트가 실제로 필요로 하는 식별자를 평탄화한다.
21
23
  * - web stream → measurementId (G-XXXX, gtag/web 연동용)
@@ -76,6 +78,42 @@ export declare function buildDataStreamBody(opts: {
76
78
  webStreamData?: undefined;
77
79
  androidAppStreamData?: undefined;
78
80
  };
81
+ export interface BigQueryLinkOptions {
82
+ projectId: string;
83
+ datasetLocation: string;
84
+ dailyExportEnabled?: boolean;
85
+ streamingExportEnabled?: boolean;
86
+ freshDailyExportEnabled?: boolean;
87
+ includeAdvertisingId?: boolean;
88
+ }
89
+ /** BigQueryLink 생성 요청 본문 조립 (순수 함수 — 테스트 대상). */
90
+ export declare function buildBigQueryLinkBody(opts: BigQueryLinkOptions): {
91
+ project: string;
92
+ datasetLocation: string;
93
+ dailyExportEnabled: boolean;
94
+ streamingExportEnabled: boolean;
95
+ freshDailyExportEnabled: boolean;
96
+ includeAdvertisingId: boolean;
97
+ };
98
+ export declare function flattenBigQueryLink(link: {
99
+ name?: string | null;
100
+ project?: string | null;
101
+ datasetLocation?: string | null;
102
+ createTime?: string | null;
103
+ dailyExportEnabled?: boolean | null;
104
+ streamingExportEnabled?: boolean | null;
105
+ freshDailyExportEnabled?: boolean | null;
106
+ includeAdvertisingId?: boolean | null;
107
+ }): {
108
+ name: string | null;
109
+ project: string | null;
110
+ datasetLocation: string | null;
111
+ createTime: string | null;
112
+ dailyExportEnabled: boolean;
113
+ streamingExportEnabled: boolean;
114
+ freshDailyExportEnabled: boolean;
115
+ includeAdvertisingId: boolean;
116
+ };
79
117
  /** 접근 가능한 GA 계정 + 각 계정의 property 요약. accountId/propertyId 를 찾는 시작점. */
80
118
  export declare function listAccountSummaries(auth: Ga4Auth): Promise<import("googleapis").analyticsadmin_v1beta.Schema$GoogleAnalyticsAdminV1betaAccountSummary[]>;
81
119
  /** 계정 하위 GA4 property 목록. accountId: '123' 또는 'accounts/123'. */
@@ -107,6 +145,83 @@ export declare function listDataStreams(auth: Ga4Auth, propertyId: string): Prom
107
145
  measurementId: string | null;
108
146
  firebaseAppId: string | null;
109
147
  }[]>;
148
+ /** GA4 property 에 연결된 BigQuery export 링크 목록. */
149
+ export declare function listBigQueryLinks(auth: Ga4Auth, propertyId: string): Promise<{
150
+ name: string | null;
151
+ project: string | null;
152
+ datasetLocation: string | null;
153
+ createTime: string | null;
154
+ dailyExportEnabled: boolean;
155
+ streamingExportEnabled: boolean;
156
+ freshDailyExportEnabled: boolean;
157
+ includeAdvertisingId: boolean;
158
+ }[]>;
159
+ /**
160
+ * 생성 전 계획. GA4 property 는 BigQuery 링크가 이미 있으면 중복 생성을 시도하지 않는다.
161
+ * 기존 링크가 있으면 대상 프로젝트가 같아도/달라도 원격 상태를 그대로 보여준다.
162
+ */
163
+ export declare function planBigQueryLink(auth: Ga4Auth, propertyId: string, opts: BigQueryLinkOptions): Promise<{
164
+ ready: boolean;
165
+ action: string;
166
+ property: string;
167
+ requestBody: {
168
+ project: string;
169
+ datasetLocation: string;
170
+ dailyExportEnabled: boolean;
171
+ streamingExportEnabled: boolean;
172
+ freshDailyExportEnabled: boolean;
173
+ includeAdvertisingId: boolean;
174
+ };
175
+ existingLinks: {
176
+ name: string | null;
177
+ project: string | null;
178
+ datasetLocation: string | null;
179
+ createTime: string | null;
180
+ dailyExportEnabled: boolean;
181
+ streamingExportEnabled: boolean;
182
+ freshDailyExportEnabled: boolean;
183
+ includeAdvertisingId: boolean;
184
+ }[];
185
+ }>;
186
+ /** 계획을 다시 검사한 뒤 BigQuery export 링크 생성. */
187
+ export declare function createBigQueryLink(auth: Ga4Auth, propertyId: string, opts: BigQueryLinkOptions): Promise<{
188
+ ready: boolean;
189
+ action: string;
190
+ property: string;
191
+ requestBody: {
192
+ project: string;
193
+ datasetLocation: string;
194
+ dailyExportEnabled: boolean;
195
+ streamingExportEnabled: boolean;
196
+ freshDailyExportEnabled: boolean;
197
+ includeAdvertisingId: boolean;
198
+ };
199
+ existingLinks: {
200
+ name: string | null;
201
+ project: string | null;
202
+ datasetLocation: string | null;
203
+ createTime: string | null;
204
+ dailyExportEnabled: boolean;
205
+ streamingExportEnabled: boolean;
206
+ freshDailyExportEnabled: boolean;
207
+ includeAdvertisingId: boolean;
208
+ }[];
209
+ created: boolean;
210
+ link?: undefined;
211
+ } | {
212
+ created: boolean;
213
+ property: string;
214
+ link: {
215
+ name: string | null;
216
+ project: string | null;
217
+ datasetLocation: string | null;
218
+ createTime: string | null;
219
+ dailyExportEnabled: boolean;
220
+ streamingExportEnabled: boolean;
221
+ freshDailyExportEnabled: boolean;
222
+ includeAdvertisingId: boolean;
223
+ };
224
+ }>;
110
225
  export interface RunReportParams {
111
226
  startDate: string;
112
227
  endDate: string;
package/dist/ga4/tools.js CHANGED
@@ -10,6 +10,7 @@ import { google } from 'googleapis';
10
10
  * (스코프 추가 후 기존 사용자는 1회 재로그인: `npx -y @yoonion/mimi-seed-mcp mimi-seed-auth`)
11
11
  */
12
12
  const admin = () => google.analyticsadmin('v1beta');
13
+ const adminAlpha = () => google.analyticsadmin('v1alpha');
13
14
  const data = () => google.analyticsdata('v1beta');
14
15
  /** Admin API(analyticsadmin — property/data stream 생성·조회)가 요구하는 스코프. */
15
16
  export const GA4_SCOPE = 'https://www.googleapis.com/auth/analytics.edit';
@@ -33,6 +34,11 @@ export function normalizePropertyName(propertyId) {
33
34
  const id = propertyId.trim();
34
35
  return id.startsWith('properties/') ? id : `properties/${id}`;
35
36
  }
37
+ /** 'my-project' | 'projects/my-project' → 'projects/my-project' */
38
+ export function normalizeCloudProjectName(projectId) {
39
+ const id = projectId.trim();
40
+ return id.startsWith('projects/') ? id : `projects/${id}`;
41
+ }
36
42
  /**
37
43
  * dataStreams.create 응답에서 클라이언트가 실제로 필요로 하는 식별자를 평탄화한다.
38
44
  * - web stream → measurementId (G-XXXX, gtag/web 연동용)
@@ -70,6 +76,29 @@ export function buildDataStreamBody(opts) {
70
76
  };
71
77
  }
72
78
  }
79
+ /** BigQueryLink 생성 요청 본문 조립 (순수 함수 — 테스트 대상). */
80
+ export function buildBigQueryLinkBody(opts) {
81
+ return {
82
+ project: normalizeCloudProjectName(opts.projectId),
83
+ datasetLocation: opts.datasetLocation.trim(),
84
+ dailyExportEnabled: opts.dailyExportEnabled ?? true,
85
+ streamingExportEnabled: opts.streamingExportEnabled ?? false,
86
+ freshDailyExportEnabled: opts.freshDailyExportEnabled ?? false,
87
+ includeAdvertisingId: opts.includeAdvertisingId ?? false,
88
+ };
89
+ }
90
+ export function flattenBigQueryLink(link) {
91
+ return {
92
+ name: link.name ?? null,
93
+ project: link.project ?? null,
94
+ datasetLocation: link.datasetLocation ?? null,
95
+ createTime: link.createTime ?? null,
96
+ dailyExportEnabled: link.dailyExportEnabled ?? false,
97
+ streamingExportEnabled: link.streamingExportEnabled ?? false,
98
+ freshDailyExportEnabled: link.freshDailyExportEnabled ?? false,
99
+ includeAdvertisingId: link.includeAdvertisingId ?? false,
100
+ };
101
+ }
73
102
  // ─── 계정/속성 디스커버리 ───
74
103
  /** 접근 가능한 GA 계정 + 각 계정의 property 요약. accountId/propertyId 를 찾는 시작점. */
75
104
  export async function listAccountSummaries(auth) {
@@ -114,6 +143,49 @@ export async function listDataStreams(auth, propertyId) {
114
143
  });
115
144
  return (res.data.dataStreams ?? []).map((d) => flattenDataStream(d));
116
145
  }
146
+ // ─── BigQuery export 링크 ───
147
+ /** GA4 property 에 연결된 BigQuery export 링크 목록. */
148
+ export async function listBigQueryLinks(auth, propertyId) {
149
+ const res = await adminAlpha().properties.bigQueryLinks.list({
150
+ auth,
151
+ parent: normalizePropertyName(propertyId),
152
+ pageSize: 200,
153
+ });
154
+ return (res.data.bigqueryLinks ?? []).map((link) => flattenBigQueryLink(link));
155
+ }
156
+ /**
157
+ * 생성 전 계획. GA4 property 는 BigQuery 링크가 이미 있으면 중복 생성을 시도하지 않는다.
158
+ * 기존 링크가 있으면 대상 프로젝트가 같아도/달라도 원격 상태를 그대로 보여준다.
159
+ */
160
+ export async function planBigQueryLink(auth, propertyId, opts) {
161
+ const property = normalizePropertyName(propertyId);
162
+ const requestBody = buildBigQueryLinkBody(opts);
163
+ const existingLinks = await listBigQueryLinks(auth, property);
164
+ return {
165
+ ready: existingLinks.length === 0,
166
+ action: existingLinks.length === 0 ? 'create' : 'no-op-existing-link',
167
+ property,
168
+ requestBody,
169
+ existingLinks,
170
+ };
171
+ }
172
+ /** 계획을 다시 검사한 뒤 BigQuery export 링크 생성. */
173
+ export async function createBigQueryLink(auth, propertyId, opts) {
174
+ const plan = await planBigQueryLink(auth, propertyId, opts);
175
+ if (!plan.ready) {
176
+ return { created: false, ...plan };
177
+ }
178
+ const res = await adminAlpha().properties.bigQueryLinks.create({
179
+ auth,
180
+ parent: plan.property,
181
+ requestBody: plan.requestBody,
182
+ });
183
+ return {
184
+ created: true,
185
+ property: plan.property,
186
+ link: flattenBigQueryLink(res.data),
187
+ };
188
+ }
117
189
  export async function runReport(auth, propertyId, params) {
118
190
  const res = await data().properties.runReport({
119
191
  auth,
@@ -71,6 +71,45 @@ export function registerGa4Tools(server) {
71
71
  const streams = await ga4.listDataStreams(auth, propertyId);
72
72
  return { content: [{ type: 'text', text: JSON.stringify(streams, null, 2) }] };
73
73
  });
74
+ const bigQueryLinkSchema = {
75
+ propertyId: z.string().trim().min(1).describe(PROPERTY_DESC),
76
+ projectId: z.string().trim().min(1).describe("Google Cloud 프로젝트 ID — 'my-project' 또는 'projects/my-project'"),
77
+ datasetLocation: z.string().trim().min(1).describe("생성할 BigQuery 데이터셋 위치 (예: 'asia-northeast3', 'US', 'EU'). 생성 후 변경 불가"),
78
+ dailyExportEnabled: z.boolean().optional().describe('일일 내보내기 활성화 (기본 true)'),
79
+ streamingExportEnabled: z.boolean().optional().describe('스트리밍 내보내기 활성화 (기본 false, 추가 비용 가능)'),
80
+ freshDailyExportEnabled: z.boolean().optional().describe('Fresh Daily 내보내기 활성화 (기본 false, 지원 속성만 가능)'),
81
+ includeAdvertisingId: z.boolean().optional().describe('모바일 광고 식별자 포함 (기본 false, 개인정보 정책 확인 필요)'),
82
+ };
83
+ server.tool('ga4_plan_bigquery_link', 'GA4 → BigQuery export 링크 생성 계획을 읽기 전용으로 점검. 기존 링크, 대상 프로젝트, 데이터셋 위치와 export 옵션을 반환하며 원격 상태를 변경하지 않는다.', bigQueryLinkSchema, async ({ propertyId, projectId, datasetLocation, dailyExportEnabled, streamingExportEnabled, freshDailyExportEnabled, includeAdvertisingId }) => {
84
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
85
+ const plan = await ga4.planBigQueryLink(auth, propertyId, {
86
+ projectId,
87
+ datasetLocation,
88
+ dailyExportEnabled,
89
+ streamingExportEnabled,
90
+ freshDailyExportEnabled,
91
+ includeAdvertisingId,
92
+ });
93
+ return { content: [{ type: 'text', text: JSON.stringify(plan, null, 2) }] };
94
+ });
95
+ server.tool('ga4_create_bigquery_link', 'GA4 → BigQuery export 링크 생성. confirm 생략/false 시 읽기 전용 계획만 반환하고, confirm: true 일 때만 계획을 재검사한 뒤 생성한다. 기존 링크가 있으면 no-op.', {
96
+ ...bigQueryLinkSchema,
97
+ confirm: z.boolean().optional().describe('true 명시 시에만 원격 링크 생성. 생략/false 면 계획만 반환'),
98
+ }, async ({ propertyId, projectId, datasetLocation, dailyExportEnabled, streamingExportEnabled, freshDailyExportEnabled, includeAdvertisingId, confirm }) => {
99
+ const auth = await requireAuth(ga4Raw.GA4_SCOPE);
100
+ const opts = {
101
+ projectId,
102
+ datasetLocation,
103
+ dailyExportEnabled,
104
+ streamingExportEnabled,
105
+ freshDailyExportEnabled,
106
+ includeAdvertisingId,
107
+ };
108
+ const result = confirm
109
+ ? await ga4.createBigQueryLink(auth, propertyId, opts)
110
+ : { preview: true, ...(await ga4.planBigQueryLink(auth, propertyId, opts)) };
111
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
112
+ });
74
113
  server.tool('ga4_run_report', 'GA4 Data API 리포트 (활성 사용자·이벤트 등). dimensions/metrics 는 쉼표 구분 GA4 API 이름.', {
75
114
  propertyId: z.string().describe(PROPERTY_DESC),
76
115
  startDate: z.string().describe("시작일 (YYYY-MM-DD 또는 'NdaysAgo', 'today')"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.8",
3
+ "version": "0.13.10",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
3
- "total": 183,
3
+ "total": 185,
4
4
  "domains": {
5
5
  "admob": {
6
6
  "label": "AdMob",
@@ -169,13 +169,15 @@
169
169
  "ga4": {
170
170
  "label": "Google Analytics 4",
171
171
  "credential": "Google OAuth",
172
- "summary": "계정/속성·데이터 스트림 관리, 리포트 실행",
172
+ "summary": "계정/속성·데이터 스트림·BigQuery export 링크 관리, 리포트 실행",
173
173
  "tools": [
174
174
  "ga4_list_account_summaries",
175
175
  "ga4_list_properties",
176
176
  "ga4_create_property",
177
177
  "ga4_create_data_stream",
178
178
  "ga4_list_data_streams",
179
+ "ga4_plan_bigquery_link",
180
+ "ga4_create_bigquery_link",
179
181
  "ga4_run_report"
180
182
  ]
181
183
  },