@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.
- package/assets/agent-guide.md +9 -1
- package/dist/auth/cli.js +33 -8
- package/dist/auth/google-auth.d.ts +26 -5
- package/dist/auth/google-auth.js +132 -47
- package/dist/auth/youtube-channel.d.ts +8 -0
- package/dist/auth/youtube-channel.js +26 -0
- package/dist/googleads/errors.d.ts +2 -0
- package/dist/googleads/errors.js +31 -0
- package/dist/googleads/tools.d.ts +7 -0
- package/dist/googleads/tools.js +89 -46
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +10 -10
- package/dist/registers/auth.js +25 -4
- package/dist/registers/googleads.js +5 -2
- package/dist/registers/video.js +11 -5
- package/dist/video/schemas.d.ts +12 -12
- package/dist/video/youtube-publish.d.ts +11 -0
- package/dist/video/youtube-publish.js +7 -0
- package/package.json +3 -1
- package/scripts/googleads-report.mjs +32 -0
|
@@ -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;
|
package/dist/googleads/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
96
|
-
campaign.
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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:
|
|
146
|
-
impressions:
|
|
181
|
+
clicks: metricNumber(r.metrics?.clicks),
|
|
182
|
+
impressions: metricNumber(r.metrics?.impressions),
|
|
147
183
|
cost: microsToCurrency(r.metrics?.costMicros),
|
|
148
|
-
conversions:
|
|
149
|
-
conversionsValue:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 =
|
|
224
|
+
const installs = metricNumber(r.metrics?.conversions);
|
|
185
225
|
if (existing) {
|
|
186
|
-
existing.clicks +=
|
|
187
|
-
existing.impressions +=
|
|
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 +=
|
|
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:
|
|
201
|
-
impressions:
|
|
240
|
+
clicks: metricNumber(r.metrics?.clicks),
|
|
241
|
+
impressions: metricNumber(r.metrics?.impressions),
|
|
202
242
|
cost,
|
|
203
243
|
installs,
|
|
204
|
-
installsValue:
|
|
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 = [
|
package/dist/registers/auth.js
CHANGED
|
@@ -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으로 자동 갱신 시도)', {
|
|
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
|
|
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)
|
|
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
|
},
|
package/dist/registers/video.js
CHANGED
|
@@ -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
|
-
'로컬 영상 파일을
|
|
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', [
|
package/dist/video/schemas.d.ts
CHANGED
|
@@ -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;
|