@yoonion/mimi-seed-mcp 0.19.13 → 0.19.14

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.
@@ -1,5 +1,6 @@
1
1
  import type { OAuth2Client } from 'google-auth-library';
2
2
  import type { GoogleAdsConfig } from './config.js';
3
+ export declare const REPORT_METRIC_NOTE = "cost is in account currency, not micros. conversions are Google Ads conversions, not necessarily installs. Legacy installs/cpi aliases do not establish install CPI or cohort D7 ROAS.";
3
4
  export interface DateRange {
4
5
  startDate: string;
5
6
  endDate: string;
@@ -16,6 +17,8 @@ export declare function listCampaigns(auth: OAuth2Client, cfg: GoogleAdsConfig):
16
17
  dailyBudget: number;
17
18
  }[]>;
18
19
  export declare function getCampaignReport(auth: OAuth2Client, cfg: GoogleAdsConfig, range: DateRange): Promise<{
20
+ currencyCode: any;
21
+ timeZone: any;
19
22
  id: any;
20
23
  name: any;
21
24
  status: any;
@@ -26,6 +29,7 @@ export declare function getCampaignReport(auth: OAuth2Client, cfg: GoogleAdsConf
26
29
  cost: number;
27
30
  conversions: number;
28
31
  conversionsValue: number;
32
+ costPerConversion: number | null;
29
33
  cpi: number;
30
34
  ctr: number;
31
35
  avgCpc: number;
@@ -38,6 +42,9 @@ export declare function getUacReport(auth: OAuth2Client, cfg: GoogleAdsConfig, r
38
42
  clicks: number;
39
43
  impressions: number;
40
44
  cost: number;
45
+ conversions: number;
46
+ conversionsValue: number;
47
+ costPerConversion: number | null;
41
48
  installs: number;
42
49
  installsValue: number;
43
50
  cpi: number;
@@ -1,9 +1,34 @@
1
1
  import { fetchWithTimeout } from '../lib/http.js';
2
+ import { googleAdsError } from './errors.js';
2
3
  // Google Ads API는 ~13개월 주기로 sunset (항상 최신 3개 major만 유지).
3
4
  // v24 = 2026-06 기준 현행 major. 새 major 출시 시 갱신 필요.
4
5
  // https://developers.google.com/google-ads/api/docs/sunset-dates
5
6
  const API_VERSION = 'v24';
6
7
  const BASE = `https://googleads.googleapis.com/${API_VERSION}`;
8
+ export const REPORT_METRIC_NOTE = 'cost is in account currency, not micros. conversions are Google Ads conversions, not necessarily installs. Legacy installs/cpi aliases do not establish install CPI or cohort D7 ROAS.';
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ function metricNumber(value) {
13
+ // Protobuf JSON can omit zero-valued scalars, but explicit malformed values are not zero.
14
+ if (value === undefined)
15
+ return 0;
16
+ if ((typeof value !== 'number' && typeof value !== 'string') ||
17
+ (typeof value === 'string' && !/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) ||
18
+ !Number.isFinite(Number(value)))
19
+ throw new Error('Google Ads returned an invalid numeric metric.');
20
+ return Number(value);
21
+ }
22
+ function validateDateRange(range) {
23
+ for (const value of [range.startDate, range.endDate]) {
24
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
25
+ !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) {
26
+ throw new Error('Google Ads dates must be valid YYYY-MM-DD calendar dates.');
27
+ }
28
+ }
29
+ if (range.startDate > range.endDate)
30
+ throw new Error('Google Ads startDate must not exceed endDate.');
31
+ }
7
32
  // ─── 내부 헬퍼 ───────────────────────────────────────────────
8
33
  async function getAccessToken(auth) {
9
34
  const token = await auth.getAccessToken();
@@ -28,8 +53,10 @@ async function search(auth, cfg, query) {
28
53
  const url = `${BASE}/customers/${cfg.customerId}/googleAds:search`;
29
54
  const all = [];
30
55
  let pageToken;
56
+ const seenTokens = new Set();
31
57
  do {
32
- const body = { query, pageSize: 1000 };
58
+ // Google Ads controls page size; sending pageSize is rejected by current APIs.
59
+ const body = { query };
33
60
  if (pageToken)
34
61
  body.pageToken = pageToken;
35
62
  const res = await fetchWithTimeout(url, {
@@ -39,27 +66,36 @@ async function search(auth, cfg, query) {
39
66
  });
40
67
  const text = await res.text();
41
68
  if (!res.ok) {
42
- let msg = `Google Ads API ${res.status}`;
43
- try {
44
- const err = JSON.parse(text);
45
- const detail = err?.error?.message ?? err?.[0]?.error?.message ?? text;
46
- msg += `: ${detail}`;
47
- }
48
- catch {
49
- msg += `: ${text.slice(0, 300)}`;
50
- }
51
- throw new Error(msg);
69
+ throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
70
+ }
71
+ let json;
72
+ try {
73
+ json = JSON.parse(text);
74
+ }
75
+ catch {
76
+ throw new Error('Google Ads returned invalid JSON.');
77
+ }
78
+ if (!isRecord(json) || 'error' in json ||
79
+ (json.results !== undefined && !Array.isArray(json.results)) ||
80
+ (json.nextPageToken !== undefined && typeof json.nextPageToken !== 'string') ||
81
+ (json.results ?? []).some((row) => !isRecord(row) || !isRecord(row.campaign))) {
82
+ throw new Error('Google Ads returned an invalid search response.');
52
83
  }
53
- const json = JSON.parse(text);
54
84
  all.push(...(json.results ?? []));
55
85
  pageToken = json.nextPageToken ?? undefined;
86
+ if (pageToken) {
87
+ if (seenTokens.has(pageToken))
88
+ throw new Error('Google Ads repeated a page token; refusing partial totals.');
89
+ seenTokens.add(pageToken);
90
+ }
56
91
  } while (pageToken);
57
92
  return all;
58
93
  }
59
94
  function microsToCurrency(micros) {
60
- if (micros == null)
61
- return 0;
62
- return Number(micros) / 1_000_000;
95
+ const value = metricNumber(micros);
96
+ if (!Number.isSafeInteger(value))
97
+ throw new Error('Google Ads returned unsafe or fractional currency micros.');
98
+ return value / 1_000_000;
63
99
  }
64
100
  // ─── 접근 가능한 고객 목록 (API 연결 확인용) ─────────────────────
65
101
  export async function listAccessibleCustomers(auth, cfg) {
@@ -71,15 +107,7 @@ export async function listAccessibleCustomers(auth, cfg) {
71
107
  });
72
108
  const text = await res.text();
73
109
  if (!res.ok) {
74
- let msg = `Google Ads API ${res.status}`;
75
- try {
76
- const err = JSON.parse(text);
77
- msg += `: ${err?.error?.message ?? text.slice(0, 300)}`;
78
- }
79
- catch {
80
- msg += `: ${text.slice(0, 300)}`;
81
- }
82
- throw new Error(msg);
110
+ throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
83
111
  }
84
112
  return JSON.parse(text);
85
113
  }
@@ -92,8 +120,8 @@ export async function listCampaigns(auth, cfg) {
92
120
  campaign.status,
93
121
  campaign.advertising_channel_type,
94
122
  campaign.advertising_channel_sub_type,
95
- campaign.start_date,
96
- campaign.end_date,
123
+ campaign.start_date_time,
124
+ campaign.end_date_time,
97
125
  campaign_budget.amount_micros
98
126
  FROM campaign
99
127
  WHERE campaign.status != 'REMOVED'
@@ -107,15 +135,19 @@ export async function listCampaigns(auth, cfg) {
107
135
  status: r.campaign?.status,
108
136
  channelType: r.campaign?.advertisingChannelType,
109
137
  channelSubType: r.campaign?.advertisingChannelSubType,
110
- startDate: r.campaign?.startDate,
111
- endDate: r.campaign?.endDate,
138
+ // Keep date-only response fields compatible while querying the supported schema.
139
+ startDate: r.campaign?.startDateTime?.slice(0, 10),
140
+ endDate: r.campaign?.endDateTime?.slice(0, 10),
112
141
  dailyBudget: microsToCurrency(r.campaignBudget?.amountMicros),
113
142
  }));
114
143
  }
115
144
  // ─── 캠페인 성과 리포트 ────────────────────────────────────────
116
145
  export async function getCampaignReport(auth, cfg, range) {
146
+ validateDateRange(range);
117
147
  const query = `
118
148
  SELECT
149
+ customer.currency_code,
150
+ customer.time_zone,
119
151
  campaign.id,
120
152
  campaign.name,
121
153
  campaign.status,
@@ -130,30 +162,37 @@ export async function getCampaignReport(auth, cfg, range) {
130
162
  metrics.ctr,
131
163
  metrics.average_cpc
132
164
  FROM campaign
133
- WHERE campaign.status != 'REMOVED'
134
- AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
165
+ WHERE segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
135
166
  ORDER BY metrics.cost_micros DESC
136
- LIMIT 500
137
167
  `;
138
168
  const rows = await search(auth, cfg, query);
169
+ for (const row of rows) {
170
+ if (!isRecord(row.metrics))
171
+ throw new Error('Google Ads report row is missing metrics.');
172
+ }
139
173
  return rows.map((r) => ({
174
+ currencyCode: r.customer?.currencyCode,
175
+ timeZone: r.customer?.timeZone,
140
176
  id: r.campaign?.id,
141
177
  name: r.campaign?.name,
142
178
  status: r.campaign?.status,
143
179
  channelType: r.campaign?.advertisingChannelType,
144
180
  channelSubType: r.campaign?.advertisingChannelSubType,
145
- clicks: Number(r.metrics?.clicks ?? 0),
146
- impressions: Number(r.metrics?.impressions ?? 0),
181
+ clicks: metricNumber(r.metrics?.clicks),
182
+ impressions: metricNumber(r.metrics?.impressions),
147
183
  cost: microsToCurrency(r.metrics?.costMicros),
148
- conversions: Number(r.metrics?.conversions ?? 0),
149
- conversionsValue: Number(r.metrics?.conversionsValue ?? 0),
150
- cpi: microsToCurrency(r.metrics?.costPerConversion),
151
- ctr: Number(r.metrics?.ctr ?? 0),
152
- avgCpc: microsToCurrency(r.metrics?.averageCpc),
184
+ conversions: metricNumber(r.metrics?.conversions),
185
+ conversionsValue: metricNumber(r.metrics?.conversionsValue),
186
+ costPerConversion: metricNumber(r.metrics?.conversions) > 0
187
+ ? metricNumber(r.metrics?.costPerConversion) / 1_000_000 : null,
188
+ cpi: metricNumber(r.metrics?.costPerConversion) / 1_000_000,
189
+ ctr: metricNumber(r.metrics?.ctr),
190
+ avgCpc: metricNumber(r.metrics?.averageCpc) / 1_000_000,
153
191
  }));
154
192
  }
155
193
  // ─── UAC(앱 캠페인) 리포트 ─────────────────────────────────────
156
194
  export async function getUacReport(auth, cfg, range) {
195
+ validateDateRange(range);
157
196
  const query = `
158
197
  SELECT
159
198
  campaign.id,
@@ -173,21 +212,22 @@ export async function getUacReport(auth, cfg, range) {
173
212
  AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
174
213
  AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
175
214
  ORDER BY segments.date DESC, metrics.cost_micros DESC
176
- LIMIT 500
177
215
  `;
178
216
  const rows = await search(auth, cfg, query);
179
217
  const byId = new Map();
180
218
  for (const r of rows) {
219
+ if (!isRecord(r.metrics) || !r.campaign?.id)
220
+ throw new Error('Google Ads UAC row is missing campaign or metrics.');
181
221
  const id = String(r.campaign?.id ?? '');
182
222
  const existing = byId.get(id);
183
223
  const cost = microsToCurrency(r.metrics?.costMicros);
184
- const installs = Number(r.metrics?.conversions ?? 0);
224
+ const installs = metricNumber(r.metrics?.conversions);
185
225
  if (existing) {
186
- existing.clicks += Number(r.metrics?.clicks ?? 0);
187
- existing.impressions += Number(r.metrics?.impressions ?? 0);
226
+ existing.clicks += metricNumber(r.metrics?.clicks);
227
+ existing.impressions += metricNumber(r.metrics?.impressions);
188
228
  existing.cost += cost;
189
229
  existing.installs += installs;
190
- existing.installsValue += Number(r.metrics?.conversionsValue ?? 0);
230
+ existing.installsValue += metricNumber(r.metrics?.conversionsValue);
191
231
  if (r.segments?.date)
192
232
  existing.dates.push(r.segments.date);
193
233
  }
@@ -197,11 +237,11 @@ export async function getUacReport(auth, cfg, range) {
197
237
  name: r.campaign?.name ?? '',
198
238
  status: r.campaign?.status ?? '',
199
239
  subType: r.campaign?.advertisingChannelSubType ?? '',
200
- clicks: Number(r.metrics?.clicks ?? 0),
201
- impressions: Number(r.metrics?.impressions ?? 0),
240
+ clicks: metricNumber(r.metrics?.clicks),
241
+ impressions: metricNumber(r.metrics?.impressions),
202
242
  cost,
203
243
  installs,
204
- installsValue: Number(r.metrics?.conversionsValue ?? 0),
244
+ installsValue: metricNumber(r.metrics?.conversionsValue),
205
245
  dates: r.segments?.date ? [r.segments.date] : [],
206
246
  });
207
247
  }
@@ -214,6 +254,9 @@ export async function getUacReport(auth, cfg, range) {
214
254
  clicks: c.clicks,
215
255
  impressions: c.impressions,
216
256
  cost: c.cost,
257
+ conversions: c.installs,
258
+ conversionsValue: c.installsValue,
259
+ costPerConversion: c.installs > 0 ? c.cost / c.installs : null,
217
260
  installs: c.installs,
218
261
  installsValue: c.installsValue,
219
262
  cpi: c.installs > 0 ? c.cost / c.installs : 0,
package/dist/helpers.d.ts CHANGED
@@ -8,7 +8,7 @@ export { ensureFreshAccessToken };
8
8
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
9
9
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
10
10
  */
11
- export declare function requireAuth(requiredScope?: string): Promise<import("google-auth-library").OAuth2Client>;
11
+ export declare function requireAuth(requiredScope?: string, profile?: string): Promise<import("google-auth-library").OAuth2Client>;
12
12
  export declare const PLAY_AUTH_HINT: string;
13
13
  export declare const APPSTORE_AUTH_HINT: string;
14
14
  /**
package/dist/helpers.js CHANGED
@@ -4,13 +4,13 @@ import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore
4
4
  import { getAppStoreCredentials } from './appstore/auth.js';
5
5
  export { ensureFreshAccessToken };
6
6
  const REAUTH_CMD = ' npx -y @yoonion/mimi-seed-mcp mimi-seed-auth';
7
- function formatAuthError(p) {
7
+ function formatAuthError(p, profile) {
8
8
  // 재로그인 안내는 needsReauth 인 경우에만 — CONFIG_FETCH_FAILED 처럼 재로그인이
9
9
  // 해법이 아닌 에러에 무조건 붙이면 같은 실패를 반복하게 만든다.
10
10
  return [
11
11
  `❌ [${p.code}] ${p.message}`,
12
12
  p.hint ? `→ ${p.hint}` : '',
13
- ...(p.needsReauth ? ['', '터미널에서 재로그인:', REAUTH_CMD] : []),
13
+ ...(p.needsReauth ? ['', '터미널에서 재로그인:', profile === undefined ? REAUTH_CMD : `${REAUTH_CMD} --profile ${profile}`] : []),
14
14
  ]
15
15
  .filter((l) => l !== '')
16
16
  .join('\n');
@@ -28,10 +28,10 @@ function formatAuthError(p) {
28
28
  *
29
29
  * OAuth 토큰에만 적용된다 — SA JWT 는 자체 scopes 로 토큰을 받으므로 이 검사 대상이 아니다.
30
30
  */
31
- function assertStoredScope(requiredScope) {
31
+ function assertStoredScope(requiredScope, profile) {
32
32
  if (!requiredScope)
33
33
  return;
34
- const scopeStr = getStoredTokens()?.scope;
34
+ const scopeStr = getStoredTokens(profile)?.scope;
35
35
  const missing = scopeStr === undefined
36
36
  ? !isPreTrackingScope(requiredScope)
37
37
  : !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
@@ -42,7 +42,7 @@ function assertStoredScope(requiredScope) {
42
42
  code: 'INSUFFICIENT_SCOPE',
43
43
  message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 그 권한이 없어.`,
44
44
  hint: domainArg
45
- ? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
45
+ ? `mimi-seed-auth${profile === undefined ? '' : ` --profile ${profile}`} --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
46
46
  : 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
47
47
  retriable: false,
48
48
  needsReauth: true,
@@ -56,12 +56,12 @@ function assertStoredScope(requiredScope) {
56
56
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
57
57
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
58
58
  */
59
- export async function requireAuth(requiredScope) {
60
- const result = await ensureFreshAccessToken();
59
+ export async function requireAuth(requiredScope, profile) {
60
+ const result = await ensureFreshAccessToken(undefined, profile);
61
61
  if (result.status === 'unauthenticated' || result.status === 'expired_refresh_failed') {
62
- throw new Error(formatAuthError(result.error));
62
+ throw new Error(formatAuthError(result.error, profile));
63
63
  }
64
- const client = getAuthenticatedClient();
64
+ const client = getAuthenticatedClient(profile);
65
65
  if (!client) {
66
66
  throw new Error(formatAuthError({
67
67
  code: 'UNAUTHENTICATED',
@@ -71,7 +71,7 @@ export async function requireAuth(requiredScope) {
71
71
  needsReauth: true,
72
72
  }));
73
73
  }
74
- assertStoredScope(requiredScope);
74
+ assertStoredScope(requiredScope, profile);
75
75
  return client;
76
76
  }
77
77
  export const PLAY_AUTH_HINT = [
@@ -1,3 +1,5 @@
1
+ import { GOOGLE_PROFILE_ID, listGoogleProfiles, getAuthenticatedClient, getGoogleAuthAttempt } from '../auth/google-auth.js';
2
+ import { verifyYouTubeChannel, YOUTUBE_CHANNEL_ID } from '../auth/youtube-channel.js';
1
3
  import { z } from 'zod';
2
4
  import { getMcpOAuthClient } from '../auth/constants.js';
3
5
  import { classifyError } from '../auth/errors.js';
@@ -281,12 +283,14 @@ export function registerAuthTools(server) {
281
283
  'domains 로 필요한 권한 도메인만 골라 요청 가능 (미지정 시 전체). 재로그인 시 기존 부여 권한은 유지되고 새 권한만 추가됨(incremental).',
282
284
  '토큰 만료(invalid_rapt) / 재인증 필요 시 사용. 10분 내 완료해야 함.',
283
285
  ].join(' '), {
286
+ profile: z.string().regex(GOOGLE_PROFILE_ID).optional().describe('계정/채널별 Google 로그인 프로필. 생략하면 기존 기본 로그인.'),
287
+ expectedChannelId: z.string().regex(YOUTUBE_CHANNEL_ID).optional().describe('이 채널로 로그인했는지 검증한 뒤 저장. profile 필수. Google 로그인에서 해당 개인/브랜드 채널을 선택하세요.'),
284
288
  domains: z
285
289
  .array(z.enum(DOMAIN_IDS))
286
290
  .optional()
287
291
  .describe(`요청할 권한 도메인 서브셋 (미지정 시 전체). 가능한 값: ${DOMAIN_IDS.join(', ')}. ` +
288
292
  '예: ["ga4","googleads"] — 기존에 부여된 다른 도메인 권한은 유지된다.'),
289
- }, async ({ domains }) => {
293
+ }, async ({ domains, profile, expectedChannelId }) => {
290
294
  // 설정 조회 실패를 분류된 안내로 — raw throw 는 MCP 클라이언트에 마커 문자열만 노출된다.
291
295
  let clientId;
292
296
  let clientSecret;
@@ -297,7 +301,7 @@ export function registerAuthTools(server) {
297
301
  const p = classifyError(e, { phase: 'login' });
298
302
  return textResult(`❌ [${p.code}] ${p.message}${p.hint ? `\n→ ${p.hint}` : ''}`);
299
303
  }
300
- const { url, wait } = startAuth(clientId, clientSecret, { domains });
304
+ const { url, wait } = startAuth(clientId, clientSecret, { domains, profile, expectedChannelId });
301
305
  // fire-and-forget — 토큰은 콜백 서버가 자동 저장
302
306
  wait.then(() => { }, (err) => { console.error('[mimi-seed auth]', err.message); });
303
307
  const scopeLine = domains?.length
@@ -312,6 +316,8 @@ export function registerAuthTools(server) {
312
316
  url,
313
317
  '',
314
318
  scopeLine,
319
+ `로그인 프로필: ${profile ?? '(기본)'} / 대상 채널: ${expectedChannelId ?? '(로그인 후 확인)'}`,
320
+ 'YouTube는 Google 로그인에서 선택한 개인/브랜드 채널에 연결됩니다. 완료 후 mimi_seed_auth_status(profile=...)로 실제 채널을 확인하세요.',
315
321
  '',
316
322
  '이 URL을 브라우저에서 열고 Google 계정으로 승인해줘.',
317
323
  '완료되면 localhost:9876으로 자동 리다이렉트되고 토큰이 저장돼.',
@@ -320,10 +326,24 @@ export function registerAuthTools(server) {
320
326
  }],
321
327
  };
322
328
  });
323
- server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
329
+ server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {
330
+ profile: z.string().regex(GOOGLE_PROFILE_ID).optional().describe('확인할 Google 로그인 프로필. 저장된 프로필 목록도 반환.'),
331
+ }, async ({ profile }) => {
332
+ const profiles = listGoogleProfiles();
333
+ const loginAttempt = getGoogleAuthAttempt(profile);
334
+ if (profile !== undefined) {
335
+ const result = await ensureFreshAccessToken(undefined, profile);
336
+ if (result.status === 'unauthenticated' || result.status === 'expired_refresh_failed') {
337
+ return textResult(JSON.stringify({ profile, status: result.status, error: result.error, loginAttempt, profiles }));
338
+ }
339
+ const client = getAuthenticatedClient(profile);
340
+ const channel = result.tokens.youtubeChannel && client
341
+ ? await verifyYouTubeChannel(client, result.tokens.youtubeChannel.id) : null;
342
+ return textResult(JSON.stringify({ profile, status: result.status, youtubeChannel: channel, loginAttempt, profiles }));
343
+ }
324
344
  const r = await ensureFreshAccessToken();
325
345
  const refreshHint = formatLastRefreshHint(getTokensLastRefreshMs());
326
- const refreshLine = ` 마지막 갱신: ${refreshHint.label}`;
346
+ const refreshLine = ` 마지막 갱신: ${refreshHint.label}\nGoogle profiles: ${JSON.stringify(profiles)}`;
327
347
  const recommendation = refreshHint.recommendation ? `\n\n${refreshHint.recommendation}` : '';
328
348
  // 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께
329
349
  // 보여준다. 유효/갱신된 상태에서만 쓰므로 그 두 arm 에서만 계산한다(불필요한 토큰
@@ -349,6 +369,7 @@ export function registerAuthTools(server) {
349
369
  type: 'text',
350
370
  text: `❌ [${r.error.code}] ${r.error.message}\n` +
351
371
  (r.error.hint ? `→ ${r.error.hint}\n\n` : '\n') +
372
+ `Google profiles: ${JSON.stringify(profiles)}\n` +
352
373
  '터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
353
374
  }],
354
375
  };
@@ -32,7 +32,7 @@ export function registerGoogleAdsTools(server) {
32
32
  const campaigns = await googleads.listCampaigns(auth, cfg);
33
33
  return textResult(JSON.stringify(campaigns, null, 2));
34
34
  });
35
- server.tool('googleads_get_campaign_report', '기간별 캠페인 성과 리포트 (클릭, 노출, 비용, 전환수, CPI, CTR)', {
35
+ server.tool('googleads_get_campaign_report', '기간별 캠페인 성과 리포트 (클릭, 노출, 계정 통화 비용, 전환수, 전환당 비용). 설치 CPI나 코호트 ROAS가 아님.', {
36
36
  startDate: z.string().describe('시작일 (YYYY-MM-DD)'),
37
37
  endDate: z.string().describe('종료일 (YYYY-MM-DD)'),
38
38
  }, async ({ startDate, endDate }) => {
@@ -48,6 +48,7 @@ export function registerGoogleAdsTools(server) {
48
48
  type: 'text',
49
49
  text: JSON.stringify({
50
50
  period: { startDate, endDate },
51
+ metricNote: googleads.REPORT_METRIC_NOTE,
51
52
  summary: {
52
53
  totalCost: Math.round(totalCost * 100) / 100,
53
54
  totalClicks,
@@ -60,7 +61,7 @@ export function registerGoogleAdsTools(server) {
60
61
  }],
61
62
  };
62
63
  });
63
- server.tool('googleads_get_uac_report', '앱 캠페인(UAC) 리포트 — 앱 설치 캠페인별 설치수, CPI, ROAS 집계', {
64
+ server.tool('googleads_get_uac_report', '앱 캠페인(UAC) 비용·전환 리포트. legacy installs/cpi 필드는 일반 전환 기반 별칭이며 설치수·설치 CPI·D7 ROAS로 단정하지 말 것.', {
64
65
  startDate: z.string().describe('시작일 (YYYY-MM-DD)'),
65
66
  endDate: z.string().describe('종료일 (YYYY-MM-DD)'),
66
67
  }, async ({ startDate, endDate }) => {
@@ -74,9 +75,11 @@ export function registerGoogleAdsTools(server) {
74
75
  type: 'text',
75
76
  text: JSON.stringify({
76
77
  period: { startDate, endDate },
78
+ metricNote: googleads.REPORT_METRIC_NOTE,
77
79
  summary: {
78
80
  totalCost: Math.round(totalCost * 100) / 100,
79
81
  totalInstalls,
82
+ totalConversions: totalInstalls,
80
83
  avgCpi: totalInstalls > 0 ? Math.round(totalCost / totalInstalls * 100) / 100 : null,
81
84
  campaignCount: report.length,
82
85
  },
@@ -1,3 +1,5 @@
1
+ import { GOOGLE_PROFILE_ID } from '../auth/google-auth.js';
2
+ import { YOUTUBE_CHANNEL_ID } from '../auth/youtube-channel.js';
1
3
  import { z } from 'zod';
2
4
  import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, savePlan, } from '../video/project.js';
3
5
  import { downloadStockAssets, generateImage, researchYouTube, searchStock, } from '../video/providers.js';
@@ -15,12 +17,14 @@ const httpUrl = z.string().url().refine((value) => {
15
17
  }, 'HTTP(S) URL만 허용합니다.');
16
18
  export function registerVideoTools(server) {
17
19
  server.tool('youtube_upload_video', [
18
- '로컬 영상 파일을 현재 Google OAuth 계정의 YouTube 채널에 업로드합니다.',
20
+ '로컬 영상 파일을 선택한 Google 로그인 프로필의 채널에 업로드합니다. expectedChannelId를 실제 인증 채널과 대조한 후 전송합니다.',
19
21
  '기본 공개 상태는 private이며 public/unlisted는 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
20
22
  '업로드 후 youtube_get_video_status로 처리 완료 여부와 실제 공개 상태를 확인하세요.',
21
23
  '요청이 타임아웃되면 업로드가 뒤늦게 완료될 수 있으므로 YouTube Studio를 먼저 확인하고 중복 재시도하지 마세요.',
22
24
  'YouTube OAuth 권한이 없으면 mimi_seed_auth_start(domains=["youtube"])로 증분 연결하세요.',
23
25
  ].join(' '), {
26
+ profile: z.string().regex(GOOGLE_PROFILE_ID).optional().describe('Google 로그인 프로필. 지정한 프로필만 사용하며 기본 계정으로 대체하지 않습니다.'),
27
+ expectedChannelId: z.string().regex(YOUTUBE_CHANNEL_ID).describe('업로드 대상 채널 ID. 인증된 채널과 다르면 업로드 중단.'),
24
28
  filePath: absolutePath.describe('업로드할 .mp4/.mov/.webm 영상 절대경로'),
25
29
  title: z.string().min(1).max(100).describe('YouTube 영상 제목'),
26
30
  description: z.string().max(5_000).optional().describe('영상 설명과 음원 출처/라이선스 표기'),
@@ -33,24 +37,26 @@ export function registerVideoTools(server) {
33
37
  shortsOnly: z.boolean().default(false).describe('true면 세로/정사각형·180초 이하 쇼츠 규격을 강제'),
34
38
  confirmVisible: z.boolean().default(false).describe('public/unlisted 게시에 대한 명시 확인'),
35
39
  }, async (input) => {
36
- const auth = await requireAuth(YOUTUBE_SCOPE);
40
+ const auth = await requireAuth(YOUTUBE_SCOPE, input.profile);
37
41
  return text(await uploadYouTubeVideo(auth, input));
38
42
  });
39
43
  server.tool('youtube_get_video_status', '내 YouTube 영상의 업로드 처리 상태와 현재 공개 상태를 조회합니다.', {
44
+ profile: z.string().regex(GOOGLE_PROFILE_ID).optional().describe('영상 소유 채널의 Google 로그인 프로필'),
40
45
  videoId: z.string().min(1).max(64).describe('YouTube video ID'),
41
- }, async ({ videoId }) => {
42
- const auth = await requireAuth(YOUTUBE_SCOPE);
46
+ }, async ({ videoId, profile }) => {
47
+ const auth = await requireAuth(YOUTUBE_SCOPE, profile);
43
48
  return text(await getYouTubeVideoStatus(auth, videoId));
44
49
  });
45
50
  server.tool('youtube_update_video_privacy', [
46
51
  '내 YouTube 영상의 공개 상태를 변경하고 실제 반영 상태를 다시 조회합니다.',
47
52
  'public/unlisted 변경은 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
48
53
  ].join(' '), {
54
+ profile: z.string().regex(GOOGLE_PROFILE_ID).optional().describe('영상 소유 채널의 Google 로그인 프로필'),
49
55
  videoId: z.string().min(1).max(64).describe('YouTube video ID'),
50
56
  privacyStatus: z.enum(['private', 'unlisted', 'public']),
51
57
  confirmVisible: z.boolean().default(false).describe('public/unlisted 변경에 대한 명시 확인'),
52
58
  }, async (input) => {
53
- const auth = await requireAuth(YOUTUBE_SCOPE);
59
+ const auth = await requireAuth(YOUTUBE_SCOPE, input.profile);
54
60
  return text(await updateYouTubeVideoPrivacy(auth, input));
55
61
  });
56
62
  server.tool('video_plan_from_story', [
@@ -76,8 +76,8 @@ export declare const projectSchema: z.ZodEffects<z.ZodObject<{
76
76
  searchQuery: string;
77
77
  }>, "many">;
78
78
  }, "strip", z.ZodTypeAny, {
79
- projectId: string;
80
79
  version: 1;
80
+ projectId: string;
81
81
  title: string;
82
82
  language: string;
83
83
  story: string;
@@ -99,8 +99,8 @@ export declare const projectSchema: z.ZodEffects<z.ZodObject<{
99
99
  searchQuery: string;
100
100
  }[];
101
101
  }, {
102
- projectId: string;
103
102
  version: 1;
103
+ projectId: string;
104
104
  title: string;
105
105
  language: string;
106
106
  story: string;
@@ -122,8 +122,8 @@ export declare const projectSchema: z.ZodEffects<z.ZodObject<{
122
122
  searchQuery: string;
123
123
  }[];
124
124
  }>, {
125
- projectId: string;
126
125
  version: 1;
126
+ projectId: string;
127
127
  title: string;
128
128
  language: string;
129
129
  story: string;
@@ -145,8 +145,8 @@ export declare const projectSchema: z.ZodEffects<z.ZodObject<{
145
145
  searchQuery: string;
146
146
  }[];
147
147
  }, {
148
- projectId: string;
149
148
  version: 1;
149
+ projectId: string;
150
150
  title: string;
151
151
  language: string;
152
152
  story: string;
@@ -314,8 +314,8 @@ export declare const assetManifestSchema: z.ZodObject<{
314
314
  attribution?: string | undefined;
315
315
  }>, "many">;
316
316
  }, "strip", z.ZodTypeAny, {
317
- projectId: string;
318
317
  version: 1;
318
+ projectId: string;
319
319
  assets: {
320
320
  id: string;
321
321
  path: string;
@@ -332,8 +332,8 @@ export declare const assetManifestSchema: z.ZodObject<{
332
332
  attribution?: string | undefined;
333
333
  }[];
334
334
  }, {
335
- projectId: string;
336
335
  version: 1;
336
+ projectId: string;
337
337
  assets: {
338
338
  id: string;
339
339
  path: string;
@@ -449,8 +449,8 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
449
449
  position?: "lower" | "lower-middle" | "center" | undefined;
450
450
  }>>;
451
451
  }, "strip", z.ZodTypeAny, {
452
- projectId: string;
453
452
  version: 1;
453
+ projectId: string;
454
454
  width: number;
455
455
  height: number;
456
456
  createdAt: string;
@@ -474,8 +474,8 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
474
474
  position?: "lower" | "lower-middle" | "center" | undefined;
475
475
  } | undefined;
476
476
  }, {
477
- projectId: string;
478
477
  version: 1;
478
+ projectId: string;
479
479
  width: number;
480
480
  height: number;
481
481
  createdAt: string;
@@ -499,8 +499,8 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
499
499
  position?: "lower" | "lower-middle" | "center" | undefined;
500
500
  } | undefined;
501
501
  }>, {
502
- projectId: string;
503
502
  version: 1;
503
+ projectId: string;
504
504
  width: number;
505
505
  height: number;
506
506
  createdAt: string;
@@ -524,8 +524,8 @@ export declare const timelineSchema: z.ZodEffects<z.ZodObject<{
524
524
  position?: "lower" | "lower-middle" | "center" | undefined;
525
525
  } | undefined;
526
526
  }, {
527
- projectId: string;
528
527
  version: 1;
528
+ projectId: string;
529
529
  width: number;
530
530
  height: number;
531
531
  createdAt: string;
@@ -561,8 +561,8 @@ export declare const renderJobSchema: z.ZodObject<{
561
561
  exitCode: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
562
562
  error: z.ZodOptional<z.ZodString>;
563
563
  }, "strip", z.ZodTypeAny, {
564
+ status: "failed" | "completed" | "queued" | "running";
564
565
  id: string;
565
- status: "completed" | "queued" | "running" | "failed";
566
566
  createdAt: string;
567
567
  updatedAt: string;
568
568
  projectDir: string;
@@ -572,8 +572,8 @@ export declare const renderJobSchema: z.ZodObject<{
572
572
  pid?: number | undefined;
573
573
  exitCode?: number | null | undefined;
574
574
  }, {
575
+ status: "failed" | "completed" | "queued" | "running";
575
576
  id: string;
576
- status: "completed" | "queued" | "running" | "failed";
577
577
  createdAt: string;
578
578
  updatedAt: string;
579
579
  projectDir: string;