@yoonion/mimi-seed-mcp 0.19.17 → 0.19.18

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.
@@ -0,0 +1 @@
1
+ export declare function friendlyAdmobError(e: unknown, accountEmail?: string | null): Error;
@@ -0,0 +1,44 @@
1
+ // AdMob API 에러 친절화. playstore/errors.ts 와 같은 역할.
2
+ //
3
+ // 핵심 케이스: 토큰도 admob 스코프도 정상인데 AdMob 이 요청을 거부하는 경우.
4
+ // AdMob 은 로그인한 Google 계정이 어떤 AdMob 계정의 사용자도 아니면 401 UNAUTHENTICATED
5
+ // ("Request is missing required authentication credential") 를 돌려준다 — 메시지만 보면
6
+ // 토큰이 안 붙은 것처럼 읽혀 SDK 버그나 만료로 오진하게 된다. 실제 해법은 "AdMob 계정에
7
+ // 등록된 Google 계정으로 다시 로그인" 이므로, 지금 로그인한 계정을 이름으로 보여 준다.
8
+ // 인식 못 한 에러는 원본을 그대로 보존한다.
9
+ import { extractHttpStatus, authReauthMessage, withCause, googleErrorDetail, rawMessage } from '../lib/google-errors.js';
10
+ const RELOGIN = [
11
+ 'AdMob 계정에 사용자로 등록된 Google 계정으로 다시 로그인하세요 (계정 선택 화면에서 그 계정을 고르면 됩니다):',
12
+ ' mimi_seed_auth_start(domains=["admob"])',
13
+ ' 또는 터미널: npx -y @yoonion/mimi-seed-mcp mimi-seed-auth --domains admob',
14
+ 'ℹ️ 기본 로그인은 한 계정만 담습니다. 다른 계정으로 바꾸면 그 계정 기준으로 권한이 다시 잡히니,',
15
+ ' 다른 도구에 필요한 권한 도메인이 있으면 domains 에 함께 넣으세요. 결과는 mimi_seed_auth_status 로 확인합니다.',
16
+ ].join('\n');
17
+ function accountLabel(accountEmail) {
18
+ return accountEmail ? `현재 로그인 계정(${accountEmail})` : '현재 로그인한 Google 계정';
19
+ }
20
+ export function friendlyAdmobError(e, accountEmail) {
21
+ const text = rawMessage(e);
22
+ const status = extractHttpStatus(e);
23
+ // 스코프 부족·refresh 만료는 공통 재로그인 안내가 정확하다.
24
+ const reauth = authReauthMessage(text);
25
+ if (reauth)
26
+ return withCause(new Error(reauth), e);
27
+ if (status === 401 || /UNAUTHENTICATED|missing required authentication credential/i.test(text)) {
28
+ return withCause(new Error([
29
+ `❌ ${accountLabel(accountEmail)}으로는 AdMob 에 접근할 수 없어요 (401).`,
30
+ '토큰과 admob 권한은 유효합니다. AdMob 은 이 계정이 어떤 AdMob 계정의 사용자도 아닐 때 이렇게 거부합니다.',
31
+ '',
32
+ RELOGIN,
33
+ ].join('\n')), e);
34
+ }
35
+ if (status === 403 || /PERMISSION_DENIED/i.test(text)) {
36
+ const detail = googleErrorDetail(e);
37
+ const lines = [`❌ AdMob 403 — ${accountLabel(accountEmail)}에 이 작업 권한이 없어요.`];
38
+ if (detail)
39
+ lines.push(`Google 사유: ${detail}`);
40
+ lines.push('→ AdMob 콘솔 → 설정 → 사용자 에서 이 계정의 역할을 확인하거나, 권한 있는 계정으로 다시 로그인하세요.', '', RELOGIN);
41
+ return withCause(new Error(lines.join('\n')), e);
42
+ }
43
+ return e instanceof Error ? e : new Error(text);
44
+ }
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, googleProfilePath, listGoogleProfiles, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
3
+ import { startAuth, googleProfilePath, listGoogleProfiles, getStoredTokens, ensureFreshAccessToken, resolveAccountEmail, } 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';
@@ -40,6 +40,8 @@ const ko = {
40
40
  statusRefreshed: (left) => ` ✅ 연결됨 — refresh_token으로 갱신 (${left})`,
41
41
  statusExpired: ' ⚠️ 토큰 만료 + 자동 갱신 실패',
42
42
  statusNone: ' ❌ 연결된 계정 없음.',
43
+ account: (email) => ` 계정: ${email}`,
44
+ accountUnknown: ' 계정: (확인 불가 — 재로그인하면 기록됨)',
43
45
  grantedDomains: (list) => ` 권한 도메인: ${list}`,
44
46
  missingDomains: (list) => ` 미부여: ${list} — mimi-seed-auth --domains <id> 로 추가 (기존 권한 유지)`,
45
47
  domainsUnknown: ' 권한 도메인: (구 토큰 — scope 기록 없음. 재로그인하면 기록됨)',
@@ -106,6 +108,8 @@ const en = {
106
108
  statusRefreshed: (left) => ` ✅ Connected — refreshed with refresh_token (${left})`,
107
109
  statusExpired: ' ⚠️ Token expired + automatic refresh failed',
108
110
  statusNone: ' ❌ No connected account.',
111
+ account: (email) => ` Account: ${email}`,
112
+ accountUnknown: ' Account: (unknown — re-login records it)',
109
113
  grantedDomains: (list) => ` Granted domains: ${list}`,
110
114
  missingDomains: (list) => ` Not granted: ${list} — add with mimi-seed-auth --domains <id> (prior grants are kept)`,
111
115
  domainsUnknown: ' Granted domains: (legacy token — no scope record; re-login records it)',
@@ -179,7 +183,10 @@ function printAuthError(p) {
179
183
  err(` (cause: ${p.cause})`);
180
184
  }
181
185
  /** 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께 출력. */
182
- function printGrantedDomains() {
186
+ async function printGrantedDomains() {
187
+ // 어느 Google 계정인지 먼저 — 다른 계정 로그인은 권한 목록만 봐서는 드러나지 않는다.
188
+ const email = await resolveAccountEmail(profile);
189
+ err(email ? M.account(email) : M.accountUnknown);
183
190
  const channel = getStoredTokens(profile)?.youtubeChannel;
184
191
  if (channel)
185
192
  err(`YouTube: ${channel.title} (${channel.id})`);
@@ -201,12 +208,12 @@ async function cmdStatus() {
201
208
  switch (r.status) {
202
209
  case 'fresh':
203
210
  err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
204
- printGrantedDomains();
211
+ await printGrantedDomains();
205
212
  err('');
206
213
  return 0;
207
214
  case 'refreshed':
208
215
  err(M.statusRefreshed(fmtRemaining(r.msUntilExpiry)));
209
- printGrantedDomains();
216
+ await printGrantedDomains();
210
217
  err('');
211
218
  return 0;
212
219
  case 'expired_refresh_failed':
@@ -382,7 +389,7 @@ async function cmdLogin() {
382
389
  err('');
383
390
  err('');
384
391
  err(M.done);
385
- printGrantedDomains();
392
+ await printGrantedDomains();
386
393
  err('');
387
394
  err(M.nextTitle);
388
395
  err(M.nextExample1);
@@ -7,6 +7,7 @@ export declare function googleProfilePath(profile: string): string;
7
7
  export declare function listGoogleProfiles(): {
8
8
  profile: string;
9
9
  connected: boolean;
10
+ accountEmail: string | null;
10
11
  youtubeChannel: {
11
12
  id: string;
12
13
  title: string;
@@ -23,7 +24,17 @@ export interface StoredTokens {
23
24
  expiry_date: number;
24
25
  /** 공백 구분 부여 스코프. 신규 도구(GA4 등) pre-flight 스코프 검사에 사용. 구 토큰은 undefined. */
25
26
  scope?: string;
27
+ /**
28
+ * 이 토큰이 속한 Google 계정 이메일. 로그인 시 id_token 에서 기록하고, 기록 전 토큰은
29
+ * resolveAccountEmail() 이 tokeninfo 로 한 번 채운다. 표시·오류 안내용이며 인가 판단에 쓰지 않는다.
30
+ */
31
+ accountEmail?: string;
26
32
  }
33
+ /**
34
+ * id_token(JWT) payload 의 email. 토큰 엔드포인트가 TLS 로 직접 준 값이라 서명 검증 없이
35
+ * 표시용으로만 읽는다. 형식이 다르거나 email 이 없으면 undefined.
36
+ */
37
+ export declare function emailFromIdToken(idToken: string | null | undefined): string | undefined;
27
38
  export declare function getStoredCredentials(profile?: string): {
28
39
  clientId: string;
29
40
  clientSecret: string;
@@ -97,3 +108,11 @@ export type RefreshStatus = {
97
108
  * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
98
109
  */
99
110
  export declare function ensureFreshAccessToken(marginMs?: number, profile?: string): Promise<RefreshStatus>;
111
+ /**
112
+ * 저장된 로그인이 어느 Google 계정인지. 로그인 때 기록한 값을 우선 쓰고, 기록이 없는 구 토큰은
113
+ * tokeninfo 로 한 번 조회해 저장한다(다음부터 네트워크 없음). 조회 실패·email 스코프 미부여면 null —
114
+ * 상태 표시가 이것 때문에 실패하면 안 되므로 절대 throw 하지 않는다.
115
+ *
116
+ * 호출 전에 ensureFreshAccessToken() 으로 access_token 을 갱신해 두는 것이 호출자 책임이다.
117
+ */
118
+ export declare function resolveAccountEmail(profile?: string): Promise<string | null>;
@@ -8,8 +8,9 @@ import os from 'node:os';
8
8
  import { getMcpOAuthClient } from './constants.js';
9
9
  import { AuthError, classifyError } from './errors.js';
10
10
  // 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
11
- import { scopesForDomains } from './scopes.js';
11
+ import { scopesForDomains, IDENTITY_SCOPES } from './scopes.js';
12
12
  import { writeCredentialJson } from '../lib/atomic-write.js';
13
+ import { fetchWithTimeout } from '../lib/http.js';
13
14
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
14
15
  // rebrand so existing auth sessions don't force a re-login; new writes go to
15
16
  // the new dir.
@@ -42,9 +43,30 @@ export function listGoogleProfiles() {
42
43
  .sort().map((file) => {
43
44
  const profile = file.slice(0, -5);
44
45
  const tokens = readProfile(profile)?.tokens;
45
- return { profile, connected: !!tokens?.refresh_token, youtubeChannel: tokens?.youtubeChannel ?? null };
46
+ return {
47
+ profile,
48
+ connected: !!tokens?.refresh_token,
49
+ accountEmail: tokens?.accountEmail ?? null,
50
+ youtubeChannel: tokens?.youtubeChannel ?? null,
51
+ };
46
52
  });
47
53
  }
54
+ /**
55
+ * id_token(JWT) payload 의 email. 토큰 엔드포인트가 TLS 로 직접 준 값이라 서명 검증 없이
56
+ * 표시용으로만 읽는다. 형식이 다르거나 email 이 없으면 undefined.
57
+ */
58
+ export function emailFromIdToken(idToken) {
59
+ const payload = idToken?.split('.')[1];
60
+ if (!payload)
61
+ return undefined;
62
+ try {
63
+ const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
64
+ return typeof json.email === 'string' && json.email ? json.email : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
48
70
  export function getStoredCredentials(profile) {
49
71
  if (profile !== undefined)
50
72
  return readProfile(profile)?.credentials ?? null;
@@ -184,7 +206,10 @@ export function startAuth(clientId, clientSecret, options = {}) {
184
206
  authAttempts.set(profile ?? '', attempt);
185
207
  const oauth2Client = createOAuth2Client(clientId, clientSecret);
186
208
  const needsYouTubeDomain = expectedChannelId && options.domains && !options.domains.some((id) => id === 'youtube' || id === 'youtube_analytics');
187
- const requestedScopes = scopesForDomains(needsYouTubeDomain ? [...new Set([...options.domains, 'youtube'])] : options.domains);
209
+ const requestedScopes = [...new Set([
210
+ ...scopesForDomains(needsYouTubeDomain ? [...new Set([...options.domains, 'youtube'])] : options.domains),
211
+ ...IDENTITY_SCOPES,
212
+ ])];
188
213
  const state = randomUUID();
189
214
  const authUrl = oauth2Client.generateAuthUrl({
190
215
  state,
@@ -288,6 +313,9 @@ export function startAuth(clientId, clientSecret, options = {}) {
288
313
  scope: tokens.scope ?? requestedScopes.join(' '),
289
314
  ...(youtubeChannel && { youtubeChannel }),
290
315
  };
316
+ const accountEmail = emailFromIdToken(tokens.id_token);
317
+ if (accountEmail)
318
+ stored.accountEmail = accountEmail;
291
319
  if (attempt.status !== 'pending')
292
320
  throw new Error('Login cancelled or timed out; credentials were not saved.');
293
321
  saveTokens(stored, profile, { clientId, clientSecret });
@@ -446,3 +474,38 @@ export async function ensureFreshAccessToken(marginMs = 300_000, profile) {
446
474
  };
447
475
  }
448
476
  }
477
+ /**
478
+ * 저장된 로그인이 어느 Google 계정인지. 로그인 때 기록한 값을 우선 쓰고, 기록이 없는 구 토큰은
479
+ * tokeninfo 로 한 번 조회해 저장한다(다음부터 네트워크 없음). 조회 실패·email 스코프 미부여면 null —
480
+ * 상태 표시가 이것 때문에 실패하면 안 되므로 절대 throw 하지 않는다.
481
+ *
482
+ * 호출 전에 ensureFreshAccessToken() 으로 access_token 을 갱신해 두는 것이 호출자 책임이다.
483
+ */
484
+ export async function resolveAccountEmail(profile) {
485
+ const tokens = getStoredTokens(profile);
486
+ if (!tokens)
487
+ return null;
488
+ if (tokens.accountEmail)
489
+ return tokens.accountEmail;
490
+ if (!tokens.access_token)
491
+ return null;
492
+ try {
493
+ // 상태 표시용 — 느린 네트워크에서 status 가 매달리지 않게 5초 1회만 시도한다.
494
+ const res = await fetchWithTimeout(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(tokens.access_token)}`, {}, { timeoutMs: 5_000, maxAttempts: 1 });
495
+ if (!res.ok)
496
+ return null;
497
+ const info = (await res.json());
498
+ const email = typeof info.email === 'string' && info.email ? info.email : null;
499
+ if (!email)
500
+ return null;
501
+ // 조회 사이에 다른 계정으로 재로그인됐으면 옛 계정 이메일을 새 토큰에 붙이지 않는다.
502
+ const current = getStoredTokens(profile);
503
+ if (current && current.refresh_token === tokens.refresh_token && !current.accountEmail) {
504
+ saveTokens({ ...current, accountEmail: email }, profile);
505
+ }
506
+ return email;
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ }
@@ -95,6 +95,16 @@ export declare function mergeScopeStrings(...parts: Array<string | undefined>):
95
95
  * (auth-scopes.test.ts 가 고정한다 — 스코프가 소리 없이 빠지면 기존 사용자 도구가 죽는다).
96
96
  */
97
97
  export declare const ALL_SCOPES: readonly string[];
98
+ /**
99
+ * 모든 로그인에 함께 싣는 계정 식별 스코프 (비민감 — 동의 화면에 "이메일 주소 보기"만 추가).
100
+ *
101
+ * 권한 도메인이 아니다 — 어떤 도구도 이걸로 API 를 부르지 않는다. 목적은 "지금 어느 Google
102
+ * 계정으로 로그인돼 있는가" 를 기록·표시하는 것 하나다. 이게 없으면 다른 계정으로 로그인된
103
+ * 토큰도 상태 화면에 그냥 "✅ 연결됨" 으로 나오고, 그 계정에 권한이 없는 서비스(AdMob 등)는
104
+ * 원인 불명의 401 로만 죽는다 (실사고). 도메인 목록(ALL_SCOPES)에 넣지 않는 이유: 도메인
105
+ * 부여 현황·pre-flight 는 도구 권한만 다뤄야 하고, auth-scopes.test.ts 가 ALL_SCOPES 를 고정한다.
106
+ */
107
+ export declare const IDENTITY_SCOPES: readonly string[];
98
108
  /** 도메인 서브셋 → 요청할 스코프 목록. 미지정/빈 배열이면 전체(기존 동작). */
99
109
  export declare function scopesForDomains(domains?: readonly AuthDomainId[]): string[];
100
110
  /** 스코프 하나를 요구하는 도메인들 (INSUFFICIENT_SCOPE 안내에서 "--domains X" 를 채울 때 사용). */
@@ -118,6 +118,16 @@ export function mergeScopeStrings(...parts) {
118
118
  * (auth-scopes.test.ts 가 고정한다 — 스코프가 소리 없이 빠지면 기존 사용자 도구가 죽는다).
119
119
  */
120
120
  export const ALL_SCOPES = dedupe(DOMAIN_IDS.flatMap((id) => AUTH_DOMAINS[id].scopes));
121
+ /**
122
+ * 모든 로그인에 함께 싣는 계정 식별 스코프 (비민감 — 동의 화면에 "이메일 주소 보기"만 추가).
123
+ *
124
+ * 권한 도메인이 아니다 — 어떤 도구도 이걸로 API 를 부르지 않는다. 목적은 "지금 어느 Google
125
+ * 계정으로 로그인돼 있는가" 를 기록·표시하는 것 하나다. 이게 없으면 다른 계정으로 로그인된
126
+ * 토큰도 상태 화면에 그냥 "✅ 연결됨" 으로 나오고, 그 계정에 권한이 없는 서비스(AdMob 등)는
127
+ * 원인 불명의 401 로만 죽는다 (실사고). 도메인 목록(ALL_SCOPES)에 넣지 않는 이유: 도메인
128
+ * 부여 현황·pre-flight 는 도구 권한만 다뤄야 하고, auth-scopes.test.ts 가 ALL_SCOPES 를 고정한다.
129
+ */
130
+ export const IDENTITY_SCOPES = ['openid', 'https://www.googleapis.com/auth/userinfo.email'];
121
131
  /** 도메인 서브셋 → 요청할 스코프 목록. 미지정/빈 배열이면 전체(기존 동작). */
122
132
  export function scopesForDomains(domains) {
123
133
  if (!domains || domains.length === 0)
@@ -1,26 +1,40 @@
1
1
  import { z } from 'zod';
2
2
  import * as admob from '../admob/tools.js';
3
3
  import { requireAuth } from '../helpers.js';
4
+ import { resolveAccountEmail } from '../auth/google-auth.js';
5
+ import { friendlyAdmobError } from '../admob/errors.js';
4
6
  import { jsonResult } from '../lib/mcp-response.js';
7
+ /**
8
+ * AdMob 호출의 계정 거부(401/403)를 "지금 어느 계정으로 로그인돼 있는지" 와 함께 안내한다.
9
+ * raw 401 은 토큰이 안 붙은 것처럼 읽혀 SDK 버그로 오진된다 — admob/errors.ts 참고.
10
+ */
11
+ async function withAdmobErrors(call) {
12
+ try {
13
+ return await call;
14
+ }
15
+ catch (e) {
16
+ throw friendlyAdmobError(e, await resolveAccountEmail());
17
+ }
18
+ }
5
19
  export function registerAdmobTools(server) {
6
20
  server.tool('admob_list_accounts', 'AdMob 계정 목록 조회', {}, async () => {
7
21
  const auth = await requireAuth();
8
- const accounts = await admob.listAccounts(auth);
22
+ const accounts = await withAdmobErrors(admob.listAccounts(auth));
9
23
  return jsonResult(accounts);
10
24
  });
11
25
  server.tool('admob_list_apps', 'AdMob에 등록된 앱 목록', { accountId: z.string().describe('AdMob 계정 ID (예: accounts/pub-XXXX)') }, async ({ accountId }) => {
12
26
  const auth = await requireAuth();
13
- const apps = await admob.listApps(auth, accountId);
27
+ const apps = await withAdmobErrors(admob.listApps(auth, accountId));
14
28
  return jsonResult(apps);
15
29
  });
16
30
  server.tool('admob_list_ad_units', 'AdMob 광고 단위 목록', { accountId: z.string().describe('AdMob 계정 ID') }, async ({ accountId }) => {
17
31
  const auth = await requireAuth();
18
- const units = await admob.listAdUnits(auth, accountId);
32
+ const units = await withAdmobErrors(admob.listAdUnits(auth, accountId));
19
33
  return jsonResult(units);
20
34
  });
21
35
  server.tool('admob_get_today_earnings', '오늘 AdMob 수익 요약', { accountId: z.string().describe('AdMob 계정 ID') }, async ({ accountId }) => {
22
36
  const auth = await requireAuth();
23
- const report = await admob.getTodayEarnings(auth, accountId);
37
+ const report = await withAdmobErrors(admob.getTodayEarnings(auth, accountId));
24
38
  return jsonResult(report);
25
39
  });
26
40
  server.tool('admob_get_report', 'AdMob 수익 리포트 (기간 지정)', {
@@ -33,7 +47,7 @@ export function registerAdmobTools(server) {
33
47
  endDay: z.number().describe('종료 일'),
34
48
  }, async ({ accountId, startYear, startMonth, startDay, endYear, endMonth, endDay }) => {
35
49
  const auth = await requireAuth();
36
- const report = await admob.getNetworkReport(auth, accountId, { year: startYear, month: startMonth, day: startDay }, { year: endYear, month: endMonth, day: endDay });
50
+ const report = await withAdmobErrors(admob.getNetworkReport(auth, accountId, { year: startYear, month: startMonth, day: startDay }, { year: endYear, month: endMonth, day: endDay }));
37
51
  return jsonResult(report);
38
52
  });
39
53
  server.tool('admob_create_app', 'AdMob에 새 앱 등록 (v1beta — Limited Access). appStoreId 를 주면 스토어 게시 앱과 링크(Android=패키지명, iOS=App Store 숫자 ID), 없으면 manual(unlinked) 앱.', {
@@ -64,7 +78,7 @@ export function registerAdmobTools(server) {
64
78
  }],
65
79
  };
66
80
  }
67
- throw err;
81
+ throw friendlyAdmobError(err, await resolveAccountEmail());
68
82
  }
69
83
  });
70
84
  server.tool('admob_create_ad_unit', 'AdMob 광고 단위 생성 (v1beta — Limited Access)', {
@@ -92,7 +106,7 @@ export function registerAdmobTools(server) {
92
106
  }],
93
107
  };
94
108
  }
95
- throw err;
109
+ throw friendlyAdmobError(err, await resolveAccountEmail());
96
110
  }
97
111
  });
98
112
  }
@@ -3,7 +3,7 @@ import { verifyYouTubeChannel, YOUTUBE_CHANNEL_ID } from '../auth/youtube-channe
3
3
  import { z } from 'zod';
4
4
  import { getMcpOAuthClient } from '../auth/constants.js';
5
5
  import { classifyError } from '../auth/errors.js';
6
- import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs, getStoredTokens, } from '../auth/google-auth.js';
6
+ import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs, getStoredTokens, resolveAccountEmail, } from '../auth/google-auth.js';
7
7
  import { AUTH_DOMAINS, DOMAIN_IDS, summarizeGrantedDomains } from '../auth/scopes.js';
8
8
  import { listRegisteredServiceAccounts } from '../auth/playstore-auth.js';
9
9
  import { getAppStoreCredentials } from '../appstore/auth.js';
@@ -131,7 +131,9 @@ export function registerAuthTools(server) {
131
131
  if (oauthResult.status === 'fresh' || oauthResult.status === 'refreshed') {
132
132
  const min = Math.round(oauthResult.msUntilExpiry / 60_000);
133
133
  const hint = formatLastRefreshHint(getTokensLastRefreshMs());
134
- lines.push(`✅ Google OAuth — 연결됨 (${min}분 남음, ${hint.label})`);
134
+ // 계정을 함께 보여야 "다른 계정으로 로그인돼 있음" 을 여기서 알아챈다.
135
+ const account = (await resolveAccountEmail()) ?? '계정 확인 불가 — 재로그인하면 기록됨';
136
+ lines.push(`✅ Google OAuth — 연결됨 (${account}, ${min}분 남음, ${hint.label})`);
135
137
  if (hint.recommendation)
136
138
  lines.push(` ${hint.recommendation}`);
137
139
  }
@@ -342,11 +344,15 @@ export function registerAuthTools(server) {
342
344
  const client = getAuthenticatedClient(profile);
343
345
  const channel = result.tokens.youtubeChannel && client
344
346
  ? await verifyYouTubeChannel(client, result.tokens.youtubeChannel.id) : null;
345
- return textResult(JSON.stringify({ profile, status: result.status, youtubeChannel: channel, loginAttempt, profiles }));
347
+ const accountEmail = await resolveAccountEmail(profile);
348
+ return textResult(JSON.stringify({ profile, status: result.status, accountEmail, youtubeChannel: channel, loginAttempt, profiles }));
346
349
  }
347
350
  const r = await ensureFreshAccessToken();
348
351
  const refreshHint = formatLastRefreshHint(getTokensLastRefreshMs());
349
- const refreshLine = ` 마지막 갱신: ${refreshHint.label}\nGoogle profiles: ${JSON.stringify(profiles)}`;
352
+ const accountLine = r.status === 'fresh' || r.status === 'refreshed'
353
+ ? ` 계정: ${(await resolveAccountEmail()) ?? '(확인 불가 — 재로그인하면 기록됨)'}\n`
354
+ : '';
355
+ const refreshLine = `${accountLine} 마지막 갱신: ${refreshHint.label}\nGoogle profiles: ${JSON.stringify(profiles)}`;
350
356
  const recommendation = refreshHint.recommendation ? `\n\n${refreshHint.recommendation}` : '';
351
357
  // 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께
352
358
  // 보여준다. 유효/갱신된 상태에서만 쓰므로 그 두 arm 에서만 계산한다(불필요한 토큰
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.19.17",
3
+ "version": "0.19.18",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {