@yoonion/mimi-seed-mcp 0.19.12 → 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/README.md +20 -0
- 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
package/README.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @yoonion/mimi-seed-mcp
|
|
2
2
|
|
|
3
|
+
Connected app-release operations for Claude Code, Codex and other MCP clients.
|
|
4
|
+
|
|
5
|
+
Start with a lightweight, account-free check in your app repository (Node 20+):
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx -y mimi-seed@latest check --local
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Review identifiers, Target API and detectable Play Billing evidence first. Then connect stores for
|
|
12
|
+
metadata, screenshot and uploaded-build checks. Local results do not guarantee store approval.
|
|
13
|
+
|
|
14
|
+
[Install the plugin for Claude Code or Codex](https://github.com/jeonghwanko/mimi-seed-sdk#30-second-setup)
|
|
15
|
+
· [English tool catalog](https://github.com/jeonghwanko/mimi-seed-sdk/blob/main/docs/domain/tool-catalog.md)
|
|
16
|
+
· [Release guides](https://mimi-seed.pryzm.gg/guides/expo-release-readiness)
|
|
17
|
+
|
|
18
|
+
Use `npx mimi-seed setup` to connect only the accounts you need, then open a fresh client session.
|
|
19
|
+
Licensed under PolyForm Noncommercial; commercial use requires a separate license.
|
|
20
|
+
|
|
21
|
+
## 한국어 가이드
|
|
22
|
+
|
|
3
23
|
**Mimi Seed** — Firebase · AdMob · Google Play · App Store Connect를 AI 콘솔에서 관리. Claude Code / Codex / Cursor / 기타 MCP 클라이언트에서 한 줄 등록으로 사용.
|
|
4
24
|
|
|
5
25
|
> 이 패키지는 Mimi Seed의 **로컬 MCP 서버**만 포함합니다. 웹 콘솔(Next.js 앱)은 <https://mimi-seed.pryzm.gg/tool>.
|
package/assets/agent-guide.md
CHANGED
|
@@ -84,7 +84,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
|
|
|
84
84
|
| Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_upload_playstore_sa` |
|
|
85
85
|
| Service account end-to-end | `select:iam_list_service_accounts,iam_create_service_account,iam_list_keys,iam_create_key,iam_add_iam_policy_binding,setup_playstore_connection,playstore_register_service_account,playstore_verify_service_account,playstore_list_service_accounts,playstore_delete_service_account` |
|
|
86
86
|
| Story → researched video | `select:video_save_plan,video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
|
|
87
|
-
| YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start` |
|
|
87
|
+
| YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start,mimi_seed_auth_status` |
|
|
88
88
|
|
|
89
89
|
---
|
|
90
90
|
|
|
@@ -301,3 +301,11 @@ domain, with the credential each domain needs — read it to answer "what can Mi
|
|
|
301
301
|
---
|
|
302
302
|
|
|
303
303
|
_Keep this guide in sync with `README.md`'s tool list and the `skills/` directory._
|
|
304
|
+
|
|
305
|
+
### YouTube account selection
|
|
306
|
+
|
|
307
|
+
For multiple accounts/channels, use `mimi_seed_auth_start` with a named `profile`, `domains:["youtube"]`,
|
|
308
|
+
and `expectedChannelId` from the user's target channel. Have the user select that Google/personal/Brand
|
|
309
|
+
channel during consent, then check `mimi_seed_auth_status` with the same profile. Pass both `profile` and
|
|
310
|
+
required `expectedChannelId` to `youtube_upload_video`; use that profile for status/privacy calls too.
|
|
311
|
+
A missing profile never falls back to the default account. A channel mismatch stops before media transfer.
|
package/dist/auth/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import readline from 'node:readline';
|
|
3
|
-
import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
|
|
3
|
+
import { startAuth, googleProfilePath, listGoogleProfiles, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
|
|
4
4
|
import { AuthError, classifyError } from './errors.js';
|
|
5
5
|
import { getMcpOAuthClient } from './constants.js';
|
|
6
6
|
import { AUTH_DOMAINS, DOMAIN_IDS, parseDomainList, summarizeGrantedDomains, } from './scopes.js';
|
|
@@ -23,6 +23,8 @@ const ko = {
|
|
|
23
23
|
옵션:
|
|
24
24
|
--domains <ids> 요청할 권한 도메인 (쉼표 구분, 미지정 시 전체).
|
|
25
25
|
가능한 값: ${DOMAIN_IDS.join(', ')}
|
|
26
|
+
--profile <id> Google login profile (isolated account/channel)
|
|
27
|
+
--channel <id> Verify YouTube channel before saving (requires --profile)
|
|
26
28
|
--no-browser URL 자동 오픈 안 함 (직접 복붙)
|
|
27
29
|
--timeout <초> 콜백 대기 시간 (기본 600)
|
|
28
30
|
--force 기존 토큰 무시하고 강제 재로그인
|
|
@@ -87,6 +89,8 @@ const en = {
|
|
|
87
89
|
Options:
|
|
88
90
|
--domains <ids> Permission domains to request (comma-separated; all when omitted).
|
|
89
91
|
Available: ${DOMAIN_IDS.join(', ')}
|
|
92
|
+
--profile <id> Google login profile (isolated account/channel)
|
|
93
|
+
--channel <id> Verify YouTube channel before saving (requires --profile)
|
|
90
94
|
--no-browser Do not open the URL automatically (copy-paste it yourself)
|
|
91
95
|
--timeout <sec> How long to wait for the callback (default 600)
|
|
92
96
|
--force Ignore the existing token and force a re-login
|
|
@@ -139,6 +143,8 @@ const en = {
|
|
|
139
143
|
};
|
|
140
144
|
const M = resolveLang() === 'en' ? en : ko;
|
|
141
145
|
const args = process.argv.slice(2);
|
|
146
|
+
let profile;
|
|
147
|
+
let expectedChannelId;
|
|
142
148
|
const hasFlag = (name) => args.includes(`--${name}`);
|
|
143
149
|
const flagValue = (name) => {
|
|
144
150
|
const i = args.indexOf(`--${name}`);
|
|
@@ -174,7 +180,10 @@ function printAuthError(p) {
|
|
|
174
180
|
}
|
|
175
181
|
/** 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께 출력. */
|
|
176
182
|
function printGrantedDomains() {
|
|
177
|
-
const
|
|
183
|
+
const channel = getStoredTokens(profile)?.youtubeChannel;
|
|
184
|
+
if (channel)
|
|
185
|
+
err(`YouTube: ${channel.title} (${channel.id})`);
|
|
186
|
+
const summary = summarizeGrantedDomains(getStoredTokens(profile)?.scope);
|
|
178
187
|
if (!summary.known) {
|
|
179
188
|
err(M.domainsUnknown);
|
|
180
189
|
return;
|
|
@@ -186,8 +195,9 @@ function printGrantedDomains() {
|
|
|
186
195
|
async function cmdStatus() {
|
|
187
196
|
err('');
|
|
188
197
|
err(M.statusTitle);
|
|
198
|
+
err(`Google profiles: ${JSON.stringify(listGoogleProfiles())}`);
|
|
189
199
|
err('');
|
|
190
|
-
const r = await ensureFreshAccessToken();
|
|
200
|
+
const r = await ensureFreshAccessToken(undefined, profile);
|
|
191
201
|
switch (r.status) {
|
|
192
202
|
case 'fresh':
|
|
193
203
|
err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
|
|
@@ -215,7 +225,7 @@ async function cmdRefresh() {
|
|
|
215
225
|
err('');
|
|
216
226
|
err(M.refreshTrying);
|
|
217
227
|
err('');
|
|
218
|
-
const r = await ensureFreshAccessToken(0); // 무조건 갱신 시도
|
|
228
|
+
const r = await ensureFreshAccessToken(0, profile); // 무조건 갱신 시도
|
|
219
229
|
switch (r.status) {
|
|
220
230
|
case 'fresh':
|
|
221
231
|
err(M.refreshNotNeeded(fmtRemaining(r.msUntilExpiry)));
|
|
@@ -241,7 +251,7 @@ async function cmdLogout() {
|
|
|
241
251
|
const fs = await import('node:fs');
|
|
242
252
|
const path = await import('node:path');
|
|
243
253
|
const os = await import('node:os');
|
|
244
|
-
const tokenPath = path.join(os.homedir(), '.mimi-seed', 'tokens.json');
|
|
254
|
+
const tokenPath = profile !== undefined ? googleProfilePath(profile) : path.join(os.homedir(), '.mimi-seed', 'tokens.json');
|
|
245
255
|
err('');
|
|
246
256
|
if (fs.existsSync(tokenPath)) {
|
|
247
257
|
fs.rmSync(tokenPath, { force: true });
|
|
@@ -279,11 +289,11 @@ async function cmdLogin() {
|
|
|
279
289
|
err('');
|
|
280
290
|
// 1) 기존 토큰이 있으면 silent refresh 먼저 시도.
|
|
281
291
|
// --domains 는 "권한을 추가로 부여하겠다"는 명시적 의도라 이 단축 경로를 건너뛴다.
|
|
282
|
-
if (!force && !domains) {
|
|
283
|
-
const existing = getStoredTokens();
|
|
292
|
+
if (!force && !domains && !expectedChannelId) {
|
|
293
|
+
const existing = getStoredTokens(profile);
|
|
284
294
|
if (existing) {
|
|
285
295
|
err(M.loginChecking);
|
|
286
|
-
const r = await ensureFreshAccessToken();
|
|
296
|
+
const r = await ensureFreshAccessToken(undefined, profile);
|
|
287
297
|
if (r.status === 'fresh' || r.status === 'refreshed') {
|
|
288
298
|
const label = r.status === 'fresh' ? M.loginValid : M.loginRefreshed;
|
|
289
299
|
err(M.loginAlready(label, fmtRemaining(r.msUntilExpiry)));
|
|
@@ -311,6 +321,8 @@ async function cmdLogin() {
|
|
|
311
321
|
try {
|
|
312
322
|
const { clientId, clientSecret } = await getMcpOAuthClient();
|
|
313
323
|
const r = startAuth(clientId, clientSecret, {
|
|
324
|
+
profile,
|
|
325
|
+
expectedChannelId,
|
|
314
326
|
timeoutMs: timeoutSec * 1000,
|
|
315
327
|
domains,
|
|
316
328
|
});
|
|
@@ -384,6 +396,19 @@ async function main() {
|
|
|
384
396
|
printHelp();
|
|
385
397
|
process.exit(0);
|
|
386
398
|
}
|
|
399
|
+
const readOption = (name) => {
|
|
400
|
+
const eq = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
401
|
+
const value = eq ? eq.slice(name.length + 3) : flagValue(name);
|
|
402
|
+
if ((hasFlag(name) || eq !== undefined) && !value)
|
|
403
|
+
throw new Error(`--${name} requires a value.`);
|
|
404
|
+
return value;
|
|
405
|
+
};
|
|
406
|
+
profile = readOption('profile');
|
|
407
|
+
expectedChannelId = readOption('channel');
|
|
408
|
+
if (profile !== undefined)
|
|
409
|
+
googleProfilePath(profile);
|
|
410
|
+
if (expectedChannelId && !profile)
|
|
411
|
+
throw new Error('--channel requires --profile.');
|
|
387
412
|
let code;
|
|
388
413
|
if (hasFlag('status'))
|
|
389
414
|
code = await cmdStatus();
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { type AuthErrorPayload } from './errors.js';
|
|
2
2
|
import { type AuthDomainId } from './scopes.js';
|
|
3
3
|
export type { AuthDomainId } from './scopes.js';
|
|
4
|
+
export declare const GOOGLE_PROFILE_ID: RegExp;
|
|
5
|
+
export declare function googleProfilePath(profile: string): string;
|
|
6
|
+
/** Only safe metadata; never return tokens or OAuth client secrets to MCP callers. */
|
|
7
|
+
export declare function listGoogleProfiles(): {
|
|
8
|
+
profile: string;
|
|
9
|
+
connected: boolean;
|
|
10
|
+
youtubeChannel: {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
} | null;
|
|
14
|
+
}[];
|
|
4
15
|
export interface StoredTokens {
|
|
16
|
+
youtubeChannel?: {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
};
|
|
5
20
|
access_token: string;
|
|
6
21
|
refresh_token: string;
|
|
7
22
|
token_type: string;
|
|
@@ -9,25 +24,29 @@ export interface StoredTokens {
|
|
|
9
24
|
/** 공백 구분 부여 스코프. 신규 도구(GA4 등) pre-flight 스코프 검사에 사용. 구 토큰은 undefined. */
|
|
10
25
|
scope?: string;
|
|
11
26
|
}
|
|
12
|
-
export declare function getStoredCredentials(): {
|
|
27
|
+
export declare function getStoredCredentials(profile?: string): {
|
|
13
28
|
clientId: string;
|
|
14
29
|
clientSecret: string;
|
|
15
30
|
} | null;
|
|
16
31
|
export declare function saveCredentials(clientId: string, clientSecret: string): void;
|
|
17
|
-
export declare function getStoredTokens(): StoredTokens | null;
|
|
32
|
+
export declare function getStoredTokens(profile?: string): StoredTokens | null;
|
|
18
33
|
/**
|
|
19
34
|
* tokens.json mtime — 마지막 refresh 시각의 근사값.
|
|
20
35
|
* (saveTokens 가 매번 writeFileSync 으로 갱신하므로 mtime ≈ 마지막 갱신/저장.)
|
|
21
36
|
* Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
|
|
22
37
|
* auth_status 응답 enrichment 에 사용.
|
|
23
38
|
*/
|
|
24
|
-
export declare function getTokensLastRefreshMs(): number | null;
|
|
39
|
+
export declare function getTokensLastRefreshMs(profile?: string): number | null;
|
|
25
40
|
export declare function createOAuth2Client(clientId: string, clientSecret: string): import("google-auth-library").OAuth2Client;
|
|
26
41
|
/**
|
|
27
42
|
* Get authenticated OAuth2 client.
|
|
28
43
|
* Returns null if not authenticated yet.
|
|
29
44
|
*/
|
|
30
|
-
export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2Client> | null;
|
|
45
|
+
export declare function getAuthenticatedClient(profile?: string): ReturnType<typeof createOAuth2Client> | null;
|
|
46
|
+
export declare function getGoogleAuthAttempt(profile?: string): {
|
|
47
|
+
status: "pending" | "succeeded" | "failed";
|
|
48
|
+
expectedChannelId?: string;
|
|
49
|
+
} | null;
|
|
31
50
|
/**
|
|
32
51
|
* OAuth 플로우 시작.
|
|
33
52
|
* URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
|
|
@@ -42,6 +61,8 @@ export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2
|
|
|
42
61
|
export declare function startAuth(clientId: string, clientSecret: string, options?: {
|
|
43
62
|
timeoutMs?: number;
|
|
44
63
|
domains?: readonly AuthDomainId[];
|
|
64
|
+
profile?: string;
|
|
65
|
+
expectedChannelId?: string;
|
|
45
66
|
}): {
|
|
46
67
|
url: string;
|
|
47
68
|
wait: Promise<StoredTokens>;
|
|
@@ -75,4 +96,4 @@ export type RefreshStatus = {
|
|
|
75
96
|
* 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
|
|
76
97
|
* "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
|
|
77
98
|
*/
|
|
78
|
-
export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;
|
|
99
|
+
export declare function ensureFreshAccessToken(marginMs?: number, profile?: string): Promise<RefreshStatus>;
|
package/dist/auth/google-auth.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { google } from '../lib/googleapis-lite.js';
|
|
2
2
|
import http from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { verifyYouTubeChannel, YOUTUBE_CHANNEL_ID } from './youtube-channel.js';
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import os from 'node:os';
|
|
6
8
|
import { getMcpOAuthClient } from './constants.js';
|
|
7
9
|
import { AuthError, classifyError } from './errors.js';
|
|
8
10
|
// 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
|
|
9
|
-
import { scopesForDomains
|
|
11
|
+
import { scopesForDomains } from './scopes.js';
|
|
10
12
|
import { writeCredentialJson } from '../lib/atomic-write.js';
|
|
11
13
|
// Primary config dir. Legacy `~/.preseed` is read as a fallback during the
|
|
12
14
|
// rebrand so existing auth sessions don't force a re-login; new writes go to
|
|
@@ -16,7 +18,36 @@ const LEGACY_TOKEN_DIR = path.join(os.homedir(), '.preseed');
|
|
|
16
18
|
const TOKEN_PATH = path.join(TOKEN_DIR, 'tokens.json');
|
|
17
19
|
const LEGACY_TOKEN_PATH = path.join(LEGACY_TOKEN_DIR, 'tokens.json');
|
|
18
20
|
const CREDENTIALS_PATH = path.join(TOKEN_DIR, 'credentials.json');
|
|
19
|
-
export
|
|
21
|
+
export const GOOGLE_PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
22
|
+
export function googleProfilePath(profile) {
|
|
23
|
+
if (!GOOGLE_PROFILE_ID.test(profile))
|
|
24
|
+
throw new Error('Invalid Google profile ID: use lowercase letters, digits, _ or - (1–64 characters).');
|
|
25
|
+
return path.join(TOKEN_DIR, 'google-profiles', `${profile}.json`);
|
|
26
|
+
}
|
|
27
|
+
function readProfile(profile) {
|
|
28
|
+
const file = googleProfilePath(profile);
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Only safe metadata; never return tokens or OAuth client secrets to MCP callers. */
|
|
37
|
+
export function listGoogleProfiles() {
|
|
38
|
+
const dir = path.join(TOKEN_DIR, 'google-profiles');
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return [];
|
|
41
|
+
return fs.readdirSync(dir).filter((file) => file.endsWith('.json') && GOOGLE_PROFILE_ID.test(file.slice(0, -5)))
|
|
42
|
+
.sort().map((file) => {
|
|
43
|
+
const profile = file.slice(0, -5);
|
|
44
|
+
const tokens = readProfile(profile)?.tokens;
|
|
45
|
+
return { profile, connected: !!tokens?.refresh_token, youtubeChannel: tokens?.youtubeChannel ?? null };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function getStoredCredentials(profile) {
|
|
49
|
+
if (profile !== undefined)
|
|
50
|
+
return readProfile(profile)?.credentials ?? null;
|
|
20
51
|
if (!fs.existsSync(CREDENTIALS_PATH))
|
|
21
52
|
return null;
|
|
22
53
|
try {
|
|
@@ -30,7 +61,9 @@ export function getStoredCredentials() {
|
|
|
30
61
|
export function saveCredentials(clientId, clientSecret) {
|
|
31
62
|
writeCredentialJson(CREDENTIALS_PATH, { clientId, clientSecret });
|
|
32
63
|
}
|
|
33
|
-
export function getStoredTokens() {
|
|
64
|
+
export function getStoredTokens(profile) {
|
|
65
|
+
if (profile !== undefined)
|
|
66
|
+
return readProfile(profile)?.tokens ?? null;
|
|
34
67
|
// Prefer new dir; fall back to legacy ~/.preseed during the rebrand window.
|
|
35
68
|
const pathToRead = fs.existsSync(TOKEN_PATH)
|
|
36
69
|
? TOKEN_PATH
|
|
@@ -52,7 +85,16 @@ export function getStoredTokens() {
|
|
|
52
85
|
* Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
|
|
53
86
|
* auth_status 응답 enrichment 에 사용.
|
|
54
87
|
*/
|
|
55
|
-
export function getTokensLastRefreshMs() {
|
|
88
|
+
export function getTokensLastRefreshMs(profile) {
|
|
89
|
+
if (profile !== undefined) {
|
|
90
|
+
const file = googleProfilePath(profile);
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(file).mtimeMs;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
56
98
|
const pathToRead = fs.existsSync(TOKEN_PATH)
|
|
57
99
|
? TOKEN_PATH
|
|
58
100
|
: fs.existsSync(LEGACY_TOKEN_PATH)
|
|
@@ -69,8 +111,19 @@ export function getTokensLastRefreshMs() {
|
|
|
69
111
|
}
|
|
70
112
|
// 원자적 교체가 특히 중요한 지점 — 이 함수는 access_token 만료 5분 전마다 다시 불리고,
|
|
71
113
|
// MCP 서버 인스턴스 여러 개와 CLI 가 같은 tokens.json 을 동시에 노린다.
|
|
72
|
-
function saveTokens(tokens) {
|
|
73
|
-
|
|
114
|
+
function saveTokens(tokens, profile, credentials) {
|
|
115
|
+
if (profile !== undefined) {
|
|
116
|
+
const creds = credentials ?? getStoredCredentials(profile);
|
|
117
|
+
if (!creds)
|
|
118
|
+
throw new Error(`Missing OAuth credentials for profile ${profile}. Sign in again.`);
|
|
119
|
+
// Client credentials and tokens belong to one grant and commit together.
|
|
120
|
+
writeCredentialJson(googleProfilePath(profile), { credentials: creds, tokens });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (credentials)
|
|
124
|
+
saveCredentials(credentials.clientId, credentials.clientSecret);
|
|
125
|
+
writeCredentialJson(TOKEN_PATH, tokens);
|
|
126
|
+
}
|
|
74
127
|
}
|
|
75
128
|
export function createOAuth2Client(clientId, clientSecret) {
|
|
76
129
|
return new google.auth.OAuth2(clientId, clientSecret, 'http://localhost:9876/callback');
|
|
@@ -79,32 +132,34 @@ export function createOAuth2Client(clientId, clientSecret) {
|
|
|
79
132
|
* Get authenticated OAuth2 client.
|
|
80
133
|
* Returns null if not authenticated yet.
|
|
81
134
|
*/
|
|
82
|
-
export function getAuthenticatedClient() {
|
|
83
|
-
const creds = getStoredCredentials();
|
|
135
|
+
export function getAuthenticatedClient(profile) {
|
|
136
|
+
const creds = getStoredCredentials(profile);
|
|
84
137
|
if (!creds)
|
|
85
138
|
return null;
|
|
86
|
-
const tokens = getStoredTokens();
|
|
139
|
+
const tokens = getStoredTokens(profile);
|
|
87
140
|
if (!tokens)
|
|
88
141
|
return null;
|
|
89
142
|
const client = createOAuth2Client(creds.clientId, creds.clientSecret);
|
|
90
143
|
client.setCredentials(tokens);
|
|
91
144
|
// Auto-refresh
|
|
92
145
|
client.on('tokens', (newTokens) => {
|
|
93
|
-
const stored = getStoredTokens();
|
|
94
|
-
if (stored) {
|
|
146
|
+
const stored = getStoredTokens(profile);
|
|
147
|
+
if (stored && stored.refresh_token === tokens.refresh_token) {
|
|
95
148
|
saveTokens({
|
|
96
149
|
...stored,
|
|
97
150
|
...(newTokens.access_token && { access_token: newTokens.access_token }),
|
|
98
151
|
...(newTokens.refresh_token && { refresh_token: newTokens.refresh_token }),
|
|
99
152
|
...(newTokens.expiry_date && { expiry_date: newTokens.expiry_date }),
|
|
100
153
|
...(newTokens.scope && { scope: newTokens.scope }),
|
|
101
|
-
});
|
|
154
|
+
}, profile);
|
|
102
155
|
}
|
|
103
156
|
});
|
|
104
157
|
return client;
|
|
105
158
|
}
|
|
106
159
|
// 동시 실행 방지용 — 활성 콜백 서버 참조
|
|
107
|
-
let
|
|
160
|
+
let cancelActiveAuth = null;
|
|
161
|
+
const authAttempts = new Map();
|
|
162
|
+
export function getGoogleAuthAttempt(profile) { return authAttempts.get(profile ?? '') ?? null; }
|
|
108
163
|
/**
|
|
109
164
|
* OAuth 플로우 시작.
|
|
110
165
|
* URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
|
|
@@ -117,17 +172,21 @@ let activeAuthServer = null;
|
|
|
117
172
|
* 얹는다(incremental authorization) — 토큰 응답의 scope 필드에도 누적 전체가 온다.
|
|
118
173
|
*/
|
|
119
174
|
export function startAuth(clientId, clientSecret, options = {}) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
activeAuthServer = null;
|
|
175
|
+
const { profile, expectedChannelId } = options;
|
|
176
|
+
if (profile !== undefined)
|
|
177
|
+
googleProfilePath(profile);
|
|
178
|
+
if (expectedChannelId !== undefined && (!profile || !YOUTUBE_CHANNEL_ID.test(expectedChannelId))) {
|
|
179
|
+
throw new Error('expectedChannelId requires a named profile and a valid YouTube channel ID.');
|
|
126
180
|
}
|
|
127
|
-
|
|
181
|
+
if (cancelActiveAuth)
|
|
182
|
+
cancelActiveAuth();
|
|
183
|
+
const attempt = { status: 'pending', expectedChannelId };
|
|
184
|
+
authAttempts.set(profile ?? '', attempt);
|
|
128
185
|
const oauth2Client = createOAuth2Client(clientId, clientSecret);
|
|
129
|
-
const requestedScopes = scopesForDomains(options.domains);
|
|
186
|
+
const requestedScopes = scopesForDomains(expectedChannelId && options.domains ? [...new Set([...options.domains, 'youtube'])] : options.domains);
|
|
187
|
+
const state = randomUUID();
|
|
130
188
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
189
|
+
state,
|
|
131
190
|
access_type: 'offline',
|
|
132
191
|
scope: requestedScopes,
|
|
133
192
|
// Private windows can still share cookies with an already-running private session.
|
|
@@ -136,8 +195,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
136
195
|
prompt: 'consent select_account',
|
|
137
196
|
include_granted_scopes: true,
|
|
138
197
|
});
|
|
198
|
+
let exchanging = false;
|
|
139
199
|
const wait = new Promise((resolve, reject) => {
|
|
140
|
-
const rejectAuth = (e) =>
|
|
200
|
+
const rejectAuth = (e) => {
|
|
201
|
+
attempt.status = 'failed';
|
|
202
|
+
reject(new AuthError(classifyError(e, { phase: 'login' })));
|
|
203
|
+
};
|
|
141
204
|
// 핸들러 전체가 try/catch 로 감싸여 있고 모든 경로가 응답 후 rejectAuth 로 끝난다 —
|
|
142
205
|
// createServer 가 void 를 기대하지만 여기서 새어 나갈 rejection 은 없다.
|
|
143
206
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
@@ -149,16 +212,20 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
149
212
|
res.end();
|
|
150
213
|
return;
|
|
151
214
|
}
|
|
215
|
+
if (url.searchParams.get('state') !== state) {
|
|
216
|
+
res.writeHead(400);
|
|
217
|
+
res.end('Invalid OAuth state');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
152
220
|
// Google이 동의 거부 시 ?error=access_denied 로 콜백
|
|
153
221
|
const errParam = url.searchParams.get('error');
|
|
154
222
|
if (errParam) {
|
|
155
|
-
res.writeHead(400, { 'Content-Type': 'text/
|
|
156
|
-
res.end(
|
|
223
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
224
|
+
res.end('Google authentication was denied.');
|
|
157
225
|
try {
|
|
158
226
|
server.close();
|
|
159
227
|
}
|
|
160
228
|
catch { /* noop */ }
|
|
161
|
-
activeAuthServer = null;
|
|
162
229
|
rejectAuth(new Error(errParam));
|
|
163
230
|
return;
|
|
164
231
|
}
|
|
@@ -168,6 +235,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
168
235
|
res.end('No code');
|
|
169
236
|
return;
|
|
170
237
|
}
|
|
238
|
+
if (attempt.status !== 'pending' || exchanging) {
|
|
239
|
+
res.writeHead(409);
|
|
240
|
+
res.end('Login is no longer accepting callbacks.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
exchanging = true;
|
|
171
244
|
let tokenResponse;
|
|
172
245
|
try {
|
|
173
246
|
tokenResponse = await oauth2Client.getToken(code);
|
|
@@ -179,7 +252,6 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
179
252
|
server.close();
|
|
180
253
|
}
|
|
181
254
|
catch { /* noop */ }
|
|
182
|
-
activeAuthServer = null;
|
|
183
255
|
rejectAuth(e);
|
|
184
256
|
return;
|
|
185
257
|
}
|
|
@@ -191,7 +263,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
191
263
|
server.close();
|
|
192
264
|
}
|
|
193
265
|
catch { /* noop */ }
|
|
194
|
-
|
|
266
|
+
attempt.status = 'failed';
|
|
195
267
|
reject(new AuthError({
|
|
196
268
|
code: 'TOKEN_RESPONSE_INVALID',
|
|
197
269
|
message: 'Google 응답에 access_token 또는 refresh_token이 누락되었습니다.',
|
|
@@ -202,22 +274,22 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
202
274
|
}));
|
|
203
275
|
return;
|
|
204
276
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// 선택형 로그인이 이를 깨서, 좁은 로그인이 legacy 로 오인돼 pre-flight 를 우회하는
|
|
211
|
-
// 것을 막는다. (기존엔 응답 scope 로 통째 덮어써서 이 두 위험에 모두 노출됐다.)
|
|
212
|
-
const priorScope = getStoredTokens()?.scope;
|
|
277
|
+
oauth2Client.setCredentials(tokens);
|
|
278
|
+
const youtubeChannel = (expectedChannelId || (profile && requestedScopes.some((scope) => scope.includes('/auth/youtube'))))
|
|
279
|
+
? await verifyYouTubeChannel(oauth2Client, expectedChannelId) : undefined;
|
|
280
|
+
// A fresh login may be another Google account. Never union its scopes with
|
|
281
|
+
// the previous account's grant; Google's response is authoritative.
|
|
213
282
|
const stored = {
|
|
214
283
|
access_token: tokens.access_token,
|
|
215
284
|
refresh_token: tokens.refresh_token,
|
|
216
285
|
token_type: tokens.token_type ?? 'Bearer',
|
|
217
286
|
expiry_date: tokens.expiry_date ?? Date.now() + 3600_000,
|
|
218
|
-
scope:
|
|
287
|
+
scope: tokens.scope ?? requestedScopes.join(' '),
|
|
288
|
+
...(youtubeChannel && { youtubeChannel }),
|
|
219
289
|
};
|
|
220
|
-
|
|
290
|
+
if (attempt.status !== 'pending')
|
|
291
|
+
throw new Error('Login cancelled or timed out; credentials were not saved.');
|
|
292
|
+
saveTokens(stored, profile, { clientId, clientSecret });
|
|
221
293
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
222
294
|
res.end(`
|
|
223
295
|
<html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
@@ -228,28 +300,36 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
228
300
|
</body></html>
|
|
229
301
|
`);
|
|
230
302
|
server.close();
|
|
231
|
-
|
|
303
|
+
attempt.status = 'succeeded';
|
|
232
304
|
resolve(stored);
|
|
233
305
|
}
|
|
234
306
|
catch (err) {
|
|
235
307
|
try {
|
|
236
308
|
res.writeHead(500);
|
|
237
|
-
res.end('Auth error');
|
|
309
|
+
res.end(err instanceof Error && err.message.startsWith('YouTube') ? err.message : 'Auth error. Check the MCP/CLI login status.');
|
|
238
310
|
}
|
|
239
311
|
catch { /* noop — already responded */ }
|
|
240
312
|
try {
|
|
241
313
|
server.close();
|
|
242
314
|
}
|
|
243
315
|
catch { /* noop */ }
|
|
244
|
-
activeAuthServer = null;
|
|
245
316
|
rejectAuth(err);
|
|
246
317
|
}
|
|
247
318
|
});
|
|
319
|
+
cancelActiveAuth = () => {
|
|
320
|
+
if (attempt.status !== 'pending')
|
|
321
|
+
return;
|
|
322
|
+
try {
|
|
323
|
+
server.close();
|
|
324
|
+
}
|
|
325
|
+
catch { /* noop */ }
|
|
326
|
+
rejectAuth(new Error('Login replaced by a new authentication request.'));
|
|
327
|
+
};
|
|
248
328
|
server.on('error', (err) => {
|
|
249
329
|
rejectAuth(err);
|
|
250
330
|
});
|
|
251
331
|
server.listen(9876, () => {
|
|
252
|
-
|
|
332
|
+
// Callback server is ready.
|
|
253
333
|
});
|
|
254
334
|
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
|
255
335
|
setTimeout(() => {
|
|
@@ -258,10 +338,9 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
258
338
|
server.close();
|
|
259
339
|
}
|
|
260
340
|
catch { /* noop */ }
|
|
261
|
-
activeAuthServer = null;
|
|
262
341
|
rejectAuth(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
|
|
263
342
|
}
|
|
264
|
-
}, timeoutMs);
|
|
343
|
+
}, timeoutMs).unref();
|
|
265
344
|
});
|
|
266
345
|
return { url: authUrl, wait };
|
|
267
346
|
}
|
|
@@ -278,8 +357,8 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
278
357
|
* 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
|
|
279
358
|
* "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
|
|
280
359
|
*/
|
|
281
|
-
export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
282
|
-
const tokens = getStoredTokens();
|
|
360
|
+
export async function ensureFreshAccessToken(marginMs = 300_000, profile) {
|
|
361
|
+
const tokens = getStoredTokens(profile);
|
|
283
362
|
if (!tokens) {
|
|
284
363
|
return {
|
|
285
364
|
status: 'unauthenticated',
|
|
@@ -316,12 +395,14 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
316
395
|
// 원격 의존을 최초 1회로 끝낸다. 조회 실패는 raw throw 가 아니라 분류된 에러로 반환.
|
|
317
396
|
let clientId;
|
|
318
397
|
let clientSecret;
|
|
319
|
-
const stored = getStoredCredentials();
|
|
398
|
+
const stored = getStoredCredentials(profile);
|
|
320
399
|
if (stored?.clientId && stored?.clientSecret) {
|
|
321
400
|
({ clientId, clientSecret } = stored);
|
|
322
401
|
}
|
|
323
402
|
else {
|
|
324
403
|
try {
|
|
404
|
+
if (profile !== undefined)
|
|
405
|
+
throw new Error(`Missing credentials for profile ${profile}. Sign in again.`);
|
|
325
406
|
({ clientId, clientSecret } = await getMcpOAuthClient());
|
|
326
407
|
saveCredentials(clientId, clientSecret);
|
|
327
408
|
}
|
|
@@ -338,6 +419,7 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
338
419
|
try {
|
|
339
420
|
const { credentials } = await client.refreshAccessToken();
|
|
340
421
|
const refreshed = {
|
|
422
|
+
...tokens,
|
|
341
423
|
access_token: credentials.access_token ?? tokens.access_token,
|
|
342
424
|
refresh_token: credentials.refresh_token ?? tokens.refresh_token,
|
|
343
425
|
token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
|
|
@@ -345,7 +427,10 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
345
427
|
// refresh 응답은 scope 를 생략할 수 있으므로 기존 값을 보존(blank 방지).
|
|
346
428
|
scope: credentials.scope ?? tokens.scope,
|
|
347
429
|
};
|
|
348
|
-
|
|
430
|
+
if (getStoredTokens(profile)?.refresh_token !== tokens.refresh_token) {
|
|
431
|
+
throw new Error('Google login changed during refresh. Retry with the selected profile.');
|
|
432
|
+
}
|
|
433
|
+
saveTokens(refreshed, profile);
|
|
349
434
|
return {
|
|
350
435
|
status: 'refreshed',
|
|
351
436
|
tokens: refreshed,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export declare const YOUTUBE_CHANNEL_ID: RegExp;
|
|
3
|
+
/** A channel ID is a guard, not a videos.insert routing parameter. Google selects
|
|
4
|
+
* the personal/Brand channel during OAuth; ambiguous or mismatched grants fail closed. */
|
|
5
|
+
export declare function verifyYouTubeChannel(auth: OAuth2Client, expectedChannelId?: string): Promise<{
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { google } from '../lib/googleapis-lite.js';
|
|
2
|
+
import { friendlyGoogleError } from '../lib/google-errors.js';
|
|
3
|
+
export const YOUTUBE_CHANNEL_ID = /^UC[\w-]{22}$/;
|
|
4
|
+
/** A channel ID is a guard, not a videos.insert routing parameter. Google selects
|
|
5
|
+
* the personal/Brand channel during OAuth; ambiguous or mismatched grants fail closed. */
|
|
6
|
+
export async function verifyYouTubeChannel(auth, expectedChannelId) {
|
|
7
|
+
if (expectedChannelId !== undefined && !YOUTUBE_CHANNEL_ID.test(expectedChannelId)) {
|
|
8
|
+
throw new Error('Invalid YouTube channel ID (expected UC + 22 characters).');
|
|
9
|
+
}
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await google.youtube({ version: 'v3', auth }).channels.list({ part: ['snippet'], mine: true, maxResults: 50 });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw friendlyGoogleError(error);
|
|
16
|
+
}
|
|
17
|
+
const channels = response.data.items ?? [];
|
|
18
|
+
if (channels.length !== 1 || !channels[0].id || response.data.nextPageToken) {
|
|
19
|
+
throw new Error('YouTube login must resolve to exactly one channel. Sign in again and select the intended personal/Brand channel.');
|
|
20
|
+
}
|
|
21
|
+
const channel = { id: channels[0].id, title: channels[0].snippet?.title ?? '' };
|
|
22
|
+
if (expectedChannelId && channel.id !== expectedChannelId) {
|
|
23
|
+
throw new Error(`YouTube channel mismatch: expected ${expectedChannelId}, authenticated ${channel.id} (${channel.title}). Sign in again with the intended channel. Nothing was uploaded.`);
|
|
24
|
+
}
|
|
25
|
+
return channel;
|
|
26
|
+
}
|