@yoonion/mimi-seed-mcp 0.3.9 → 0.3.11

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/dist/auth/cli.js CHANGED
@@ -1,40 +1,246 @@
1
1
  #!/usr/bin/env node
2
- import { login, getStoredTokens } from './google-auth.js';
3
- import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
4
2
  import readline from 'node:readline';
5
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6
- const ask = (q) => new Promise((r) => rl.question(q, r));
7
- async function main() {
8
- console.log('');
9
- console.log(' ☕ Mimi Seed — Google 계정 연결');
10
- console.log('');
11
- const existing = getStoredTokens();
12
- if (existing) {
13
- console.log(' ✅ 이미 연결되어 있어.');
14
- console.log('');
15
- const answer = await ask(' 다시 연결할래? (y/N): ');
16
- if (answer.toLowerCase() !== 'y') {
17
- rl.close();
18
- return;
3
+ import open from 'open';
4
+ import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
5
+ import { AuthError, classifyError } from './errors.js';
6
+ import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
7
+ const args = process.argv.slice(2);
8
+ const hasFlag = (name) => args.includes(`--${name}`);
9
+ const flagValue = (name) => {
10
+ const i = args.indexOf(`--${name}`);
11
+ if (i < 0)
12
+ return undefined;
13
+ const v = args[i + 1];
14
+ return v && !v.startsWith('--') ? v : undefined;
15
+ };
16
+ function err(msg) {
17
+ process.stderr.write(msg + '\n');
18
+ }
19
+ function printHelp() {
20
+ err(`
21
+ ☕ mimi-seed-auth — Google OAuth 인증 CLI
22
+
23
+ 사용법:
24
+ mimi-seed-auth # 로그인 (이미 있으면 자동 refresh 시도)
25
+ mimi-seed-auth --refresh # refresh_token으로 갱신만 시도 (브라우저 X)
26
+ mimi-seed-auth --status # 현재 토큰 상태 출력
27
+ mimi-seed-auth --logout # 토큰 삭제
28
+
29
+ 옵션:
30
+ --no-browser URL 자동 오픈 안 함 (직접 복붙)
31
+ --timeout <초> 콜백 대기 시간 (기본 600)
32
+ --force 기존 토큰 무시하고 강제 재로그인
33
+ --help 이 도움말
34
+ `);
35
+ }
36
+ function fmtRemaining(ms) {
37
+ if (ms <= 0)
38
+ return '만료됨';
39
+ const min = Math.round(ms / 60000);
40
+ if (min < 60)
41
+ return `${min}분 남음`;
42
+ const hr = Math.round(min / 60);
43
+ if (hr < 48)
44
+ return `${hr}시간 남음`;
45
+ return `${Math.round(hr / 24)}일 남음`;
46
+ }
47
+ function printAuthError(p) {
48
+ err(` 코드: ${p.code}`);
49
+ err(` ${p.message}`);
50
+ if (p.hint)
51
+ err(` → ${p.hint}`);
52
+ if (p.cause && process.env.DEBUG)
53
+ err(` (cause: ${p.cause})`);
54
+ }
55
+ async function cmdStatus() {
56
+ err('');
57
+ err(' ☕ Mimi Seed — 인증 상태');
58
+ err('');
59
+ const r = await ensureFreshAccessToken();
60
+ switch (r.status) {
61
+ case 'fresh':
62
+ err(` ✅ 연결됨 — 토큰 유효 (${fmtRemaining(r.msUntilExpiry)})`);
63
+ err('');
64
+ return 0;
65
+ case 'refreshed':
66
+ err(` ✅ 연결됨 — refresh_token으로 갱신 (${fmtRemaining(r.msUntilExpiry)})`);
67
+ err('');
68
+ return 0;
69
+ case 'expired_refresh_failed':
70
+ err(' ⚠️ 토큰 만료 + 자동 갱신 실패');
71
+ printAuthError(r.error);
72
+ err('');
73
+ return 2;
74
+ case 'unauthenticated':
75
+ err(' ❌ 연결된 계정 없음.');
76
+ printAuthError(r.error);
77
+ err('');
78
+ return 1;
79
+ }
80
+ }
81
+ async function cmdRefresh() {
82
+ err('');
83
+ err(' 🔄 refresh_token으로 갱신 시도 중...');
84
+ err('');
85
+ const r = await ensureFreshAccessToken(0); // 무조건 갱신 시도
86
+ switch (r.status) {
87
+ case 'fresh':
88
+ err(` ✅ 토큰 유효 — 갱신 불필요 (${fmtRemaining(r.msUntilExpiry)})`);
89
+ err('');
90
+ return 0;
91
+ case 'refreshed':
92
+ err(` ✅ 갱신 완료 (${fmtRemaining(r.msUntilExpiry)})`);
93
+ err('');
94
+ return 0;
95
+ case 'expired_refresh_failed':
96
+ err(' ❌ refresh 실패');
97
+ printAuthError(r.error);
98
+ err('');
99
+ return 2;
100
+ case 'unauthenticated':
101
+ err(' ❌ 저장된 토큰 없음.');
102
+ printAuthError(r.error);
103
+ err('');
104
+ return 1;
105
+ }
106
+ }
107
+ async function cmdLogout() {
108
+ const fs = await import('node:fs');
109
+ const path = await import('node:path');
110
+ const os = await import('node:os');
111
+ const tokenPath = path.join(os.homedir(), '.mimi-seed', 'tokens.json');
112
+ err('');
113
+ if (fs.existsSync(tokenPath)) {
114
+ fs.rmSync(tokenPath, { force: true });
115
+ err(' ✅ 토큰 삭제 완료.');
116
+ }
117
+ else {
118
+ err(' (이미 삭제된 상태)');
119
+ }
120
+ err('');
121
+ return 0;
122
+ }
123
+ async function cmdLogin() {
124
+ const noBrowser = hasFlag('no-browser');
125
+ const force = hasFlag('force');
126
+ const timeoutSec = parseInt(flagValue('timeout') ?? '600', 10);
127
+ err('');
128
+ err(' ☕ Mimi Seed — Google 계정 연결');
129
+ err('');
130
+ // 1) 기존 토큰이 있으면 silent refresh 먼저 시도
131
+ if (!force) {
132
+ const existing = getStoredTokens();
133
+ if (existing) {
134
+ err(' 🔍 기존 토큰 검사 중...');
135
+ const r = await ensureFreshAccessToken();
136
+ if (r.status === 'fresh' || r.status === 'refreshed') {
137
+ const label = r.status === 'fresh' ? '유효함' : 'refresh_token으로 갱신 완료';
138
+ err(` ✅ 이미 연결됨 (${label}, ${fmtRemaining(r.msUntilExpiry)}).`);
139
+ err('');
140
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
141
+ const answer = await new Promise((res) => rl.question(' 다시 로그인할래? (y/N): ', res));
142
+ rl.close();
143
+ if (answer.toLowerCase() !== 'y') {
144
+ err('');
145
+ return 0;
146
+ }
147
+ }
148
+ else if (r.status === 'expired_refresh_failed') {
149
+ err(` ⚠️ 토큰 만료 + 자동 갱신 실패 [${r.error.code}] — 재로그인 진행.`);
150
+ if (r.error.cause && process.env.DEBUG)
151
+ err(` cause: ${r.error.cause}`);
152
+ }
19
153
  }
20
154
  }
21
- console.log('');
22
- console.log(' 🌐 브라우저 열게. Google 계정으로 로그인해줘.');
23
- console.log('');
155
+ // 2) OAuth 콜백 서버 + URL 발급
156
+ err('');
157
+ err(' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback');
158
+ let url;
159
+ let wait;
24
160
  try {
25
- await login(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET);
26
- console.log('');
27
- console.log(' ✅ 연결 완료!');
28
- console.log('');
29
- console.log(' 이제 Claude Code에서 이렇게 쓸 수 있어:');
30
- console.log(' "내 Firebase 프로젝트 보여줘"');
31
- console.log(' "새 Android 앱 등록해줘"');
32
- console.log(' "google-services.json 다운로드해줘"');
33
- console.log('');
34
- }
35
- catch (err) {
36
- console.error(' ❌ 연결 실패:', err);
37
- }
38
- rl.close();
39
- }
40
- main();
161
+ const r = startAuth(MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET, {
162
+ timeoutMs: timeoutSec * 1000,
163
+ });
164
+ url = r.url;
165
+ wait = r.wait;
166
+ }
167
+ catch (e) {
168
+ err(' 콜백 서버 시작 실패');
169
+ printAuthError(classifyError(e, { phase: 'login' }));
170
+ err('');
171
+ return 1;
172
+ }
173
+ // 3) 브라우저 열기 (or URL 출력)
174
+ if (noBrowser) {
175
+ err('');
176
+ err(' 📋 아래 URL을 브라우저에 직접 붙여넣으세요:');
177
+ err('');
178
+ err(' ' + url);
179
+ err('');
180
+ }
181
+ else {
182
+ err(' 🌐 기본 브라우저 자동 열기...');
183
+ try {
184
+ await open(url);
185
+ err(' (실패 시 --no-browser 로 URL 직접 받기)');
186
+ }
187
+ catch (e) {
188
+ err(' ⚠️ 브라우저 자동 열기 실패: ' + (e instanceof Error ? e.message : String(e)));
189
+ err(' 📋 직접 열어주세요:');
190
+ err(' ' + url);
191
+ }
192
+ }
193
+ // 4) 콜백 대기
194
+ err(` ⏳ Google 승인 대기 중... (timeout ${timeoutSec}s)`);
195
+ // 진행 표시기 — 사용자에게 살아있다는 신호 전달
196
+ const ticker = setInterval(() => process.stderr.write('.'), 5000);
197
+ try {
198
+ await wait;
199
+ }
200
+ catch (e) {
201
+ clearInterval(ticker);
202
+ err('');
203
+ err(' ❌ 인증 실패');
204
+ if (e instanceof AuthError) {
205
+ printAuthError(e.payload);
206
+ }
207
+ else {
208
+ printAuthError(classifyError(e, { phase: 'login' }));
209
+ }
210
+ err('');
211
+ return 1;
212
+ }
213
+ clearInterval(ticker);
214
+ err('');
215
+ err('');
216
+ err(' ✅ 연결 완료!');
217
+ err('');
218
+ err(' 이제 Claude Code에서 이렇게 쓸 수 있어:');
219
+ err(' "내 Firebase 프로젝트 보여줘"');
220
+ err(' "새 Android 앱 등록해줘"');
221
+ err(' "google-services.json 다운로드해줘"');
222
+ err('');
223
+ return 0;
224
+ }
225
+ async function main() {
226
+ if (hasFlag('help') || hasFlag('h')) {
227
+ printHelp();
228
+ process.exit(0);
229
+ }
230
+ let code;
231
+ if (hasFlag('status'))
232
+ code = await cmdStatus();
233
+ else if (hasFlag('refresh'))
234
+ code = await cmdRefresh();
235
+ else if (hasFlag('logout'))
236
+ code = await cmdLogout();
237
+ else
238
+ code = await cmdLogin();
239
+ process.exit(code);
240
+ }
241
+ main().catch((e) => {
242
+ err('');
243
+ err(' ❌ 예외: ' + (e instanceof Error ? e.message : String(e)));
244
+ err('');
245
+ process.exit(1);
246
+ });
@@ -0,0 +1,24 @@
1
+ export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
2
+ export interface AuthErrorPayload {
3
+ code: AuthErrorCode;
4
+ message: string;
5
+ hint?: string;
6
+ retriable: boolean;
7
+ needsReauth: boolean;
8
+ cause?: string;
9
+ }
10
+ export declare class AuthError extends Error {
11
+ payload: AuthErrorPayload;
12
+ constructor(payload: AuthErrorPayload);
13
+ }
14
+ interface ClassifyContext {
15
+ phase: 'refresh' | 'login';
16
+ }
17
+ /**
18
+ * 임의의 에러 객체를 AuthErrorPayload로 분류.
19
+ * Google OAuth 응답 형태:
20
+ * { error: 'invalid_grant', error_description: '...' }
21
+ * googleapis 라이브러리는 e.response?.data?.error 또는 e.message에 코드 포함.
22
+ */
23
+ export declare function classifyError(e: unknown, ctx: ClassifyContext): AuthErrorPayload;
24
+ export {};
@@ -0,0 +1,152 @@
1
+ // Auth 에러 코드 — CLI/MCP/스크립트 모두 같은 코드를 보고 분기할 수 있도록
2
+ // 단일 source of truth.
3
+ export class AuthError extends Error {
4
+ payload;
5
+ constructor(payload) {
6
+ super(`[${payload.code}] ${payload.message}`);
7
+ this.payload = payload;
8
+ this.name = 'AuthError';
9
+ }
10
+ }
11
+ /**
12
+ * 임의의 에러 객체를 AuthErrorPayload로 분류.
13
+ * Google OAuth 응답 형태:
14
+ * { error: 'invalid_grant', error_description: '...' }
15
+ * googleapis 라이브러리는 e.response?.data?.error 또는 e.message에 코드 포함.
16
+ */
17
+ export function classifyError(e, ctx) {
18
+ const raw = describeError(e);
19
+ const oauthCode = extractOAuthErrorCode(e, raw);
20
+ // Google OAuth 표준 에러 코드 매핑
21
+ if (oauthCode === 'invalid_grant') {
22
+ return {
23
+ code: 'INVALID_GRANT',
24
+ message: 'refresh_token이 더 이상 유효하지 않습니다 (revoke 또는 만료).',
25
+ hint: 'mimi-seed-auth 로 재로그인하세요.',
26
+ retriable: false,
27
+ needsReauth: true,
28
+ cause: raw,
29
+ };
30
+ }
31
+ if (oauthCode === 'invalid_client') {
32
+ return {
33
+ code: 'INVALID_CLIENT',
34
+ message: 'OAuth client_id/secret이 토큰 발급 당시와 일치하지 않습니다.',
35
+ hint: '일반적으로 발생하지 않음. 환경변수 MIMI_SEED_GOOGLE_CLIENT_ID/SECRET 오버라이드 사용 시 ' +
36
+ '동일 값으로 다시 발급받으세요.',
37
+ retriable: false,
38
+ needsReauth: true,
39
+ cause: raw,
40
+ };
41
+ }
42
+ if (oauthCode === 'unauthorized_client') {
43
+ return {
44
+ code: 'UNAUTHORIZED_CLIENT',
45
+ message: 'OAuth client에 부여되지 않은 grant_type 또는 scope입니다.',
46
+ hint: 'mimi-seed-auth 로 새로운 동의를 받으세요.',
47
+ retriable: false,
48
+ needsReauth: true,
49
+ cause: raw,
50
+ };
51
+ }
52
+ if (oauthCode === 'access_denied') {
53
+ return {
54
+ code: 'USER_DENIED',
55
+ message: '브라우저에서 Google 동의를 거부했습니다.',
56
+ hint: '다시 시도하고 모든 권한에 동의해주세요.',
57
+ retriable: true,
58
+ needsReauth: true,
59
+ cause: raw,
60
+ };
61
+ }
62
+ // 네트워크 / 시스템 레벨
63
+ if (isNetworkError(e, raw)) {
64
+ return {
65
+ code: 'REFRESH_NETWORK_ERROR',
66
+ message: '구글 토큰 엔드포인트(oauth2.googleapis.com)에 연결할 수 없습니다.',
67
+ hint: '인터넷 연결, 프록시, 방화벽을 확인하세요.',
68
+ retriable: true,
69
+ needsReauth: false,
70
+ cause: raw,
71
+ };
72
+ }
73
+ if (isPortInUseError(e, raw)) {
74
+ return {
75
+ code: 'CALLBACK_PORT_IN_USE',
76
+ message: 'OAuth 콜백 포트 9876이 이미 사용 중입니다.',
77
+ hint: '해당 포트를 점유 중인 프로세스를 종료하거나 잠시 후 다시 시도하세요.',
78
+ retriable: true,
79
+ needsReauth: false,
80
+ cause: raw,
81
+ };
82
+ }
83
+ if (/timeout/i.test(raw) && ctx.phase === 'login') {
84
+ return {
85
+ code: 'CALLBACK_TIMEOUT',
86
+ message: '시간 내 Google 콜백을 받지 못했습니다.',
87
+ hint: '브라우저에서 동의 안 하셨거나 자동 열기 실패. ' +
88
+ '`mimi-seed-auth --no-browser`로 URL을 직접 받아 다른 환경에서 여세요.',
89
+ retriable: true,
90
+ needsReauth: false,
91
+ cause: raw,
92
+ };
93
+ }
94
+ // 분류 안 됨
95
+ return {
96
+ code: ctx.phase === 'refresh' ? 'REFRESH_UNKNOWN' : 'CODE_EXCHANGE_FAILED',
97
+ message: '인증 처리 중 알 수 없는 오류가 발생했습니다.',
98
+ hint: '재시도 후 동일하면 mimi-seed-auth 로 재로그인하세요.',
99
+ retriable: true,
100
+ needsReauth: ctx.phase === 'refresh',
101
+ cause: raw,
102
+ };
103
+ }
104
+ function describeError(e) {
105
+ if (e instanceof Error)
106
+ return e.message;
107
+ if (typeof e === 'string')
108
+ return e;
109
+ try {
110
+ return JSON.stringify(e);
111
+ }
112
+ catch {
113
+ return String(e);
114
+ }
115
+ }
116
+ /**
117
+ * googleapis OAuth 에러는 다양한 위치에 코드가 박혀 있다:
118
+ * - e.response?.data?.error
119
+ * - e.response?.data?.error_description
120
+ * - e.message (e.g. "invalid_grant")
121
+ * - e.code (네트워크: 'ENOTFOUND', 'ECONNREFUSED')
122
+ */
123
+ function extractOAuthErrorCode(e, raw) {
124
+ if (e && typeof e === 'object') {
125
+ // @ts-expect-error — 런타임 shape 검사
126
+ const dataErr = e.response?.data?.error;
127
+ if (typeof dataErr === 'string')
128
+ return dataErr;
129
+ }
130
+ // message에 'invalid_grant' 같은 표준 코드가 박혀 있는 경우
131
+ const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type)\b/);
132
+ return m?.[1];
133
+ }
134
+ function isNetworkError(e, raw) {
135
+ if (e && typeof e === 'object') {
136
+ // @ts-expect-error — runtime shape 검사
137
+ const code = e.code;
138
+ if (typeof code === 'string' && /^(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ECONNRESET)$/.test(code)) {
139
+ return true;
140
+ }
141
+ }
142
+ return /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|getaddrinfo|network/i.test(raw);
143
+ }
144
+ function isPortInUseError(e, raw) {
145
+ if (e && typeof e === 'object') {
146
+ // @ts-expect-error — runtime shape 검사
147
+ const code = e.code;
148
+ if (code === 'EADDRINUSE')
149
+ return true;
150
+ }
151
+ return /EADDRINUSE|address already in use|포트 9876/i.test(raw);
152
+ }
@@ -1,3 +1,4 @@
1
+ import { type AuthErrorPayload } from './errors.js';
1
2
  export interface StoredTokens {
2
3
  access_token: string;
3
4
  refresh_token: string;
@@ -34,3 +35,28 @@ export declare function startAuth(clientId: string, clientSecret: string, option
34
35
  * startAuth() 래퍼 — CLI에서 사용.
35
36
  */
36
37
  export declare function login(clientId: string, clientSecret: string): Promise<StoredTokens>;
38
+ export type RefreshStatus = {
39
+ status: 'fresh';
40
+ tokens: StoredTokens;
41
+ msUntilExpiry: number;
42
+ } | {
43
+ status: 'refreshed';
44
+ tokens: StoredTokens;
45
+ msUntilExpiry: number;
46
+ } | {
47
+ status: 'expired_refresh_failed';
48
+ tokens: StoredTokens;
49
+ error: AuthErrorPayload;
50
+ } | {
51
+ status: 'unauthenticated';
52
+ error: AuthErrorPayload;
53
+ };
54
+ /**
55
+ * 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
56
+ * - 갱신 성공 시 tokens.json 업데이트
57
+ * - refresh_token 자체가 invalid한 경우 'expired_refresh_failed' 반환
58
+ * - 토큰 자체가 없으면 'unauthenticated'
59
+ *
60
+ * MCP 도구와 CLI 양쪽에서 공유.
61
+ */
62
+ export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;
@@ -4,6 +4,8 @@ import open from 'open';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
+ import { MIMI_SEED_CLIENT_ID, MIMI_SEED_CLIENT_SECRET } from './constants.js';
8
+ import { AuthError, classifyError } from './errors.js';
7
9
  const SCOPES = [
8
10
  'https://www.googleapis.com/auth/firebase',
9
11
  'https://www.googleapis.com/auth/cloud-platform',
@@ -117,6 +119,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
117
119
  prompt: 'consent',
118
120
  });
119
121
  const wait = new Promise((resolve, reject) => {
122
+ const rejectAuth = (e) => reject(new AuthError(classifyError(e, { phase: 'login' })));
120
123
  const server = http.createServer(async (req, res) => {
121
124
  try {
122
125
  const url = new URL(req.url, `http://localhost:9876`);
@@ -125,15 +128,58 @@ export function startAuth(clientId, clientSecret, options = {}) {
125
128
  res.end();
126
129
  return;
127
130
  }
131
+ // Google이 동의 거부 시 ?error=access_denied 로 콜백
132
+ const errParam = url.searchParams.get('error');
133
+ if (errParam) {
134
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
135
+ res.end(`<html><body><h2>❌ 인증 거부됨 (${errParam})</h2></body></html>`);
136
+ try {
137
+ server.close();
138
+ }
139
+ catch { /* noop */ }
140
+ activeAuthServer = null;
141
+ rejectAuth(new Error(errParam));
142
+ return;
143
+ }
128
144
  const code = url.searchParams.get('code');
129
145
  if (!code) {
130
146
  res.writeHead(400);
131
147
  res.end('No code');
132
148
  return;
133
149
  }
134
- const { tokens } = await oauth2Client.getToken(code);
150
+ let tokenResponse;
151
+ try {
152
+ tokenResponse = await oauth2Client.getToken(code);
153
+ }
154
+ catch (e) {
155
+ res.writeHead(500);
156
+ res.end('Code exchange failed');
157
+ try {
158
+ server.close();
159
+ }
160
+ catch { /* noop */ }
161
+ activeAuthServer = null;
162
+ rejectAuth(e);
163
+ return;
164
+ }
165
+ const tokens = tokenResponse.tokens;
135
166
  if (!tokens.access_token || !tokens.refresh_token) {
136
- throw new Error('Token response missing required fields');
167
+ res.writeHead(500);
168
+ res.end('Token response invalid');
169
+ try {
170
+ server.close();
171
+ }
172
+ catch { /* noop */ }
173
+ activeAuthServer = null;
174
+ reject(new AuthError({
175
+ code: 'TOKEN_RESPONSE_INVALID',
176
+ message: 'Google 응답에 access_token 또는 refresh_token이 누락되었습니다.',
177
+ hint: 'OAuth 동의 화면에서 모든 권한에 동의했는지 확인하세요.',
178
+ retriable: true,
179
+ needsReauth: true,
180
+ cause: JSON.stringify({ has_access: !!tokens.access_token, has_refresh: !!tokens.refresh_token }),
181
+ }));
182
+ return;
137
183
  }
138
184
  const stored = {
139
185
  access_token: tokens.access_token,
@@ -156,18 +202,21 @@ export function startAuth(clientId, clientSecret, options = {}) {
156
202
  resolve(stored);
157
203
  }
158
204
  catch (err) {
159
- res.writeHead(500);
160
- res.end('Auth error');
205
+ try {
206
+ res.writeHead(500);
207
+ res.end('Auth error');
208
+ }
209
+ catch { /* noop — already responded */ }
161
210
  try {
162
211
  server.close();
163
212
  }
164
213
  catch { /* noop */ }
165
214
  activeAuthServer = null;
166
- reject(err);
215
+ rejectAuth(err);
167
216
  }
168
217
  });
169
218
  server.on('error', (err) => {
170
- reject(new Error(`포트 9876이 이미 사용 중이거나 에러: ${err.message}`));
219
+ rejectAuth(err);
171
220
  });
172
221
  server.listen(9876, () => {
173
222
  activeAuthServer = server;
@@ -180,7 +229,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
180
229
  }
181
230
  catch { /* noop */ }
182
231
  activeAuthServer = null;
183
- reject(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
232
+ rejectAuth(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
184
233
  }
185
234
  }, timeoutMs);
186
235
  });
@@ -196,3 +245,72 @@ export async function login(clientId, clientSecret) {
196
245
  open(url);
197
246
  return wait;
198
247
  }
248
+ /**
249
+ * 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
250
+ * - 갱신 성공 시 tokens.json 업데이트
251
+ * - refresh_token 자체가 invalid한 경우 'expired_refresh_failed' 반환
252
+ * - 토큰 자체가 없으면 'unauthenticated'
253
+ *
254
+ * MCP 도구와 CLI 양쪽에서 공유.
255
+ */
256
+ export async function ensureFreshAccessToken(marginMs = 60_000) {
257
+ const tokens = getStoredTokens();
258
+ if (!tokens) {
259
+ return {
260
+ status: 'unauthenticated',
261
+ error: {
262
+ code: 'UNAUTHENTICATED',
263
+ message: '저장된 인증 토큰이 없습니다.',
264
+ hint: 'mimi-seed-auth 로 로그인하세요.',
265
+ retriable: false,
266
+ needsReauth: true,
267
+ },
268
+ };
269
+ }
270
+ const now = Date.now();
271
+ const msUntilExpiry = (tokens.expiry_date ?? 0) - now;
272
+ if (tokens.expiry_date && msUntilExpiry > marginMs) {
273
+ return { status: 'fresh', tokens, msUntilExpiry };
274
+ }
275
+ if (!tokens.refresh_token) {
276
+ return {
277
+ status: 'expired_refresh_failed',
278
+ tokens,
279
+ error: {
280
+ code: 'NO_REFRESH_TOKEN',
281
+ message: '저장된 토큰에 refresh_token이 없습니다 (offline_access 미발급).',
282
+ hint: 'mimi-seed-auth 로 재로그인하면 prompt=consent로 새 refresh_token이 발급됩니다.',
283
+ retriable: false,
284
+ needsReauth: true,
285
+ },
286
+ };
287
+ }
288
+ // refresh 시도 — credentials.json 우선, 없으면 내장 클라이언트 사용
289
+ const stored = getStoredCredentials();
290
+ const clientId = stored?.clientId ?? MIMI_SEED_CLIENT_ID;
291
+ const clientSecret = stored?.clientSecret ?? MIMI_SEED_CLIENT_SECRET;
292
+ const client = createOAuth2Client(clientId, clientSecret);
293
+ client.setCredentials({ refresh_token: tokens.refresh_token });
294
+ try {
295
+ const { credentials } = await client.refreshAccessToken();
296
+ const refreshed = {
297
+ access_token: credentials.access_token ?? tokens.access_token,
298
+ refresh_token: credentials.refresh_token ?? tokens.refresh_token,
299
+ token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
300
+ expiry_date: credentials.expiry_date ?? Date.now() + 3600_000,
301
+ };
302
+ saveTokens(refreshed);
303
+ return {
304
+ status: 'refreshed',
305
+ tokens: refreshed,
306
+ msUntilExpiry: refreshed.expiry_date - Date.now(),
307
+ };
308
+ }
309
+ catch (e) {
310
+ return {
311
+ status: 'expired_refresh_failed',
312
+ tokens,
313
+ error: classifyError(e, { phase: 'refresh' }),
314
+ };
315
+ }
316
+ }
@@ -0,0 +1,20 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ export type StepState = 'done' | 'pending' | 'blocked';
3
+ export interface PlanStep {
4
+ state: StepState;
5
+ title: string;
6
+ detail?: string;
7
+ tool?: string;
8
+ irreversible?: boolean;
9
+ }
10
+ export declare function buildPlayStoreReleasePlan(opts: {
11
+ auth: OAuth2Client;
12
+ packageName: string;
13
+ versionCode?: string;
14
+ track: 'production' | 'beta' | 'alpha' | 'internal';
15
+ language: string;
16
+ }): Promise<string>;
17
+ export declare function buildAppStoreReleasePlan(opts: {
18
+ appId: string;
19
+ versionString?: string;
20
+ }): Promise<string>;
@@ -0,0 +1,258 @@
1
+ // Release Plan — Play Store / App Store 배포 사전 점검 + AI에게 TodoWrite 지시
2
+ //
3
+ // 이 모듈은 read-only.
4
+ // 실행 도구(playstore_submit_release, appstore_submit_for_review 등)는 호출하지 않고,
5
+ // 현재 상태를 조사해서 markdown 체크리스트로 변환한다.
6
+ // AI 에이전트가 응답을 받아 TodoWrite로 사용자에게 제시한 뒤,
7
+ // 단계별로 기존 *_update_*, *_submit_*, *_check_* 도구를 호출하도록 유도한다.
8
+ import { publisher, withEdit } from '../playstore/tools.js';
9
+ import { apiGet } from '../appstore/tools.js';
10
+ import { checkPlayStoreRisks, checkAppStoreRisks } from './risks.js';
11
+ const TODO_DIRECTIVE = [
12
+ '📋 [AI 지시 — 호출자 에이전트 전용]',
13
+ '',
14
+ '이 응답은 배포 플랜입니다. 다음 절차를 반드시 따르세요:',
15
+ '',
16
+ '1. 아래 체크리스트의 미완료(◻︎) 항목을 TodoWrite 도구로 사용자에게 먼저 보여주세요.',
17
+ '2. 사용자가 진행에 명시적으로 동의하기 전엔 ⚠️ 비가역 도구를 호출하지 마세요',
18
+ ' (playstore_submit_release status=completed, playstore_promote_release status=completed,',
19
+ ' appstore_submit_for_review).',
20
+ '3. 한 단계 끝날 때마다 TodoWrite로 해당 항목을 completed로 표시하세요.',
21
+ '4. 항목 옆에 "도구:" 가 표기된 경우, 그 도구를 직접 호출해서 단계를 실행하세요.',
22
+ '',
23
+ ].join('\n');
24
+ function fmtState(s) {
25
+ return s === 'done' ? '☑︎' : s === 'blocked' ? '⛔' : '◻︎';
26
+ }
27
+ function risksToSteps(risks, toolHint) {
28
+ return risks.map((r) => ({
29
+ state: 'blocked',
30
+ title: `[${r.code}] ${r.title}`,
31
+ detail: r.detail + (r.fixUrl ? ` → ${r.fixUrl}` : ''),
32
+ tool: r.level === 'blocker' ? toolHint : undefined,
33
+ }));
34
+ }
35
+ function renderPlan(ctx, steps) {
36
+ const blocked = steps.filter((s) => s.state === 'blocked').length;
37
+ const pending = steps.filter((s) => s.state === 'pending').length;
38
+ const done = steps.filter((s) => s.state === 'done').length;
39
+ const lines = [];
40
+ lines.push(TODO_DIRECTIVE);
41
+ lines.push(`🎯 ${ctx.platform} 배포 플랜`);
42
+ lines.push('');
43
+ lines.push(` 대상: ${ctx.identity}`);
44
+ lines.push(` 버전: ${ctx.version}`);
45
+ if (ctx.track)
46
+ lines.push(` 트랙: ${ctx.track}`);
47
+ lines.push('');
48
+ lines.push(` 진행률: ☑︎ ${done} ◻︎ ${pending} ⛔ ${blocked}`);
49
+ if (blocked > 0) {
50
+ lines.push(` ⚠️ 블로커 ${blocked}건 — 먼저 해결해야 제출 가능`);
51
+ }
52
+ lines.push('');
53
+ lines.push('체크리스트 (TodoWrite로 사용자에게 제시):');
54
+ lines.push('');
55
+ for (const s of steps) {
56
+ const irr = s.irreversible ? ' ⚠️ 비가역' : '';
57
+ lines.push(`${fmtState(s.state)} ${s.title}${irr}`);
58
+ if (s.detail)
59
+ lines.push(` ${s.detail}`);
60
+ if (s.tool)
61
+ lines.push(` 도구: ${s.tool}`);
62
+ }
63
+ lines.push('');
64
+ lines.push('—');
65
+ lines.push('TodoWrite로 위 미완료 항목을 등록한 후, 사용자 확인을 받고 도구를 단계별로 호출하세요.');
66
+ return lines.join('\n');
67
+ }
68
+ // ─── Play Store ──────────────────────────────────────────────────────
69
+ export async function buildPlayStoreReleasePlan(opts) {
70
+ const { auth, packageName, versionCode, track, language } = opts;
71
+ const steps = [];
72
+ // 1) 인증 + 트랙 / 빌드 상태 조회 (단일 edit session)
73
+ let trackInfo = {
74
+ hasTargetVersion: false,
75
+ };
76
+ let listingPresent = false;
77
+ try {
78
+ await withEdit(auth, packageName, async (editId) => {
79
+ const [tracks, listing] = await Promise.all([
80
+ publisher().edits.tracks.list({ auth, packageName, editId }).catch(() => null),
81
+ publisher().edits.listings.get({ auth, packageName, editId, language }).catch(() => null),
82
+ ]);
83
+ const targetTrack = tracks?.data?.tracks?.find((t) => t.track === track);
84
+ const release = targetTrack?.releases?.[0];
85
+ const codes = release?.versionCodes ?? [];
86
+ trackInfo = {
87
+ hasTargetVersion: versionCode ? codes.includes(versionCode) : codes.length > 0,
88
+ latestVersionCode: codes[codes.length - 1],
89
+ releaseStatus: release?.status ?? undefined,
90
+ };
91
+ listingPresent = !!listing?.data?.title;
92
+ return null;
93
+ });
94
+ steps.push({ state: 'done', title: 'Google Play 인증 + 트랙 조회 성공' });
95
+ }
96
+ catch (e) {
97
+ steps.push({
98
+ state: 'blocked',
99
+ title: 'Google Play 인증 또는 트랙 조회 실패',
100
+ detail: e instanceof Error ? e.message : String(e),
101
+ tool: 'mimi_seed_auth_status',
102
+ });
103
+ return renderPlan({ platform: 'Play Store', identity: packageName, version: versionCode ?? 'latest', track }, steps);
104
+ }
105
+ // 2) 빌드 도착 확인
106
+ if (trackInfo.hasTargetVersion) {
107
+ steps.push({
108
+ state: 'done',
109
+ title: `${track} 트랙에 versionCode ${versionCode ?? trackInfo.latestVersionCode} 도착 확인`,
110
+ detail: `현재 release status: ${trackInfo.releaseStatus ?? 'unknown'}`,
111
+ });
112
+ }
113
+ else {
114
+ steps.push({
115
+ state: 'blocked',
116
+ title: versionCode
117
+ ? `${track} 트랙에 versionCode ${versionCode} 없음`
118
+ : `${track} 트랙에 release 없음`,
119
+ detail: 'Gradle / Fastlane / Play Console 업로드를 먼저 완료하세요.',
120
+ tool: 'playstore_list_tracks',
121
+ });
122
+ }
123
+ // 3) 메타데이터 / 스크린샷 / 정책 위험 점검
124
+ const risks = await checkPlayStoreRisks(auth, packageName, language);
125
+ if (risks.length === 0) {
126
+ steps.push({
127
+ state: 'done',
128
+ title: '제출 위험 점검 통과 (블로커 0)',
129
+ });
130
+ }
131
+ else {
132
+ const blockers = risks.filter((r) => r.level === 'blocker');
133
+ const warnings = risks.filter((r) => r.level === 'warning');
134
+ steps.push({
135
+ state: blockers.length > 0 ? 'blocked' : 'pending',
136
+ title: `제출 위험 ${risks.length}건 (블로커 ${blockers.length} / 경고 ${warnings.length})`,
137
+ detail: '아래 항목을 개별 수정 후 다시 plan 호출 권장',
138
+ tool: 'playstore_check_submission_risks',
139
+ });
140
+ steps.push(...risksToSteps(blockers, 'playstore_update_listing'));
141
+ steps.push(...risksToSteps(warnings, 'playstore_update_listing'));
142
+ }
143
+ // 4) 릴리즈 노트
144
+ steps.push({
145
+ state: 'pending',
146
+ title: `릴리즈 노트 등록/갱신 (${language})`,
147
+ detail: 'AI 노트 초안 + 사용자 확인 후 적용',
148
+ tool: 'playstore_update_release_notes (또는 playstore_update_latest_release_notes)',
149
+ });
150
+ // 5) 제출
151
+ const canSubmit = !risks.some((r) => r.level === 'blocker') && trackInfo.hasTargetVersion;
152
+ steps.push({
153
+ state: canSubmit ? 'pending' : 'blocked',
154
+ title: `${track} 트랙 release status 변경 (draft → completed)`,
155
+ detail: canSubmit
156
+ ? '⚠️ 비가역에 가까움. 사용자 명시 동의 필수.'
157
+ : '블로커 해결 후 가능',
158
+ tool: 'playstore_submit_release',
159
+ irreversible: true,
160
+ });
161
+ return renderPlan({ platform: 'Play Store', identity: packageName, version: versionCode ?? 'latest', track }, steps);
162
+ }
163
+ // ─── App Store ────────────────────────────────────────────────────────
164
+ export async function buildAppStoreReleasePlan(opts) {
165
+ const { appId, versionString } = opts;
166
+ const steps = [];
167
+ // 1) 편집 가능한 버전 조회
168
+ let versionId;
169
+ let versionState;
170
+ let buildAttached = false;
171
+ try {
172
+ const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
173
+ 'filter[appStoreState]': 'PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,DEVELOPER_REJECTED',
174
+ ...(versionString ? { 'filter[versionString]': versionString } : {}),
175
+ limit: '5',
176
+ }).catch(() => null);
177
+ if (versions?.data?.length) {
178
+ versionId = versions.data[0].id;
179
+ versionState = versions.data[0].attributes?.appStoreState;
180
+ steps.push({
181
+ state: 'done',
182
+ title: `편집 가능한 버전 발견 (${versions.data[0].attributes?.versionString}, state=${versionState})`,
183
+ });
184
+ // build attached?
185
+ const build = await apiGet(`/appStoreVersions/${versionId}/build`).catch(() => null);
186
+ buildAttached = !!build?.data?.id;
187
+ if (buildAttached) {
188
+ steps.push({ state: 'done', title: '버전에 빌드 연결됨' });
189
+ }
190
+ else {
191
+ steps.push({
192
+ state: 'blocked',
193
+ title: '버전에 빌드 미연결',
194
+ detail: 'TestFlight 빌드 처리 완료 후 App Store Connect에서 연결하거나 API로 attach.',
195
+ tool: 'appstore_list_builds',
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ steps.push({
201
+ state: 'blocked',
202
+ title: versionString
203
+ ? `편집 가능한 ${versionString} 버전 없음`
204
+ : '편집 가능한 버전 없음',
205
+ detail: 'App Store Connect에서 새 버전을 생성하세요.',
206
+ });
207
+ }
208
+ }
209
+ catch (e) {
210
+ steps.push({
211
+ state: 'blocked',
212
+ title: 'App Store 인증 또는 버전 조회 실패',
213
+ detail: e instanceof Error ? e.message : String(e),
214
+ tool: 'mimi_seed_auth_status',
215
+ });
216
+ return renderPlan({ platform: 'App Store', identity: appId, version: versionString ?? 'latest' }, steps);
217
+ }
218
+ // 2) 메타데이터 / 스크린샷 / 정책 위험 점검
219
+ const risks = await checkAppStoreRisks(appId);
220
+ if (risks.length === 0) {
221
+ steps.push({ state: 'done', title: '제출 위험 점검 통과 (블로커 0)' });
222
+ }
223
+ else {
224
+ const blockers = risks.filter((r) => r.level === 'blocker');
225
+ const warnings = risks.filter((r) => r.level === 'warning');
226
+ steps.push({
227
+ state: blockers.length > 0 ? 'blocked' : 'pending',
228
+ title: `제출 위험 ${risks.length}건 (블로커 ${blockers.length} / 경고 ${warnings.length})`,
229
+ tool: 'appstore_check_submission_risks',
230
+ });
231
+ steps.push(...risksToSteps(blockers, 'appstore_update_localization'));
232
+ steps.push(...risksToSteps(warnings, 'appstore_update_localization'));
233
+ }
234
+ // 3) What's New / promotional text / reviewer notes
235
+ steps.push({
236
+ state: 'pending',
237
+ title: "What's New (릴리즈 노트) 등록/갱신",
238
+ detail: '버전별 로컬라이제이션마다 입력',
239
+ tool: 'appstore_update_whats_new',
240
+ });
241
+ steps.push({
242
+ state: 'pending',
243
+ title: 'Promotional Text / Description / Keywords 검토',
244
+ tool: 'appstore_update_localization',
245
+ });
246
+ // 4) 제출
247
+ const canSubmit = versionId && buildAttached && !risks.some((r) => r.level === 'blocker');
248
+ steps.push({
249
+ state: canSubmit ? 'pending' : 'blocked',
250
+ title: '심사 제출 (reviewSubmissions v2)',
251
+ detail: canSubmit
252
+ ? '⚠️ 비가역. 제출 후 메타/빌드 변경 불가 (REJECTED 시 재편집).'
253
+ : '블로커 해결 후 가능',
254
+ tool: versionId ? `appstore_submit_for_review (versionId=${versionId})` : 'appstore_submit_for_review',
255
+ irreversible: true,
256
+ });
257
+ return renderPlan({ platform: 'App Store', identity: appId, version: versionString ?? 'latest' }, steps);
258
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { getAuthenticatedClient, getStoredTokens, startAuth } from './auth/google-auth.js';
5
+ import { getAuthenticatedClient, startAuth, ensureFreshAccessToken } from './auth/google-auth.js';
6
6
  import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
7
7
  import { getAppStoreCredentials } from './appstore/auth.js';
8
8
  import { createGoogleOneTimePurchase, createGoogleSubscription, updateGoogleProduct, deleteGoogleProduct, listGoogleProducts, createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
@@ -14,6 +14,7 @@ import * as appstore from './appstore/tools.js';
14
14
  import * as appstoreScreenshots from './appstore/screenshots.js';
15
15
  import * as iam from './iam/tools.js';
16
16
  import { checkPlayStoreRisks, checkAppStoreRisks, formatRisks } from './checks/risks.js';
17
+ import { buildPlayStoreReleasePlan, buildAppStoreReleasePlan } from './checks/plan.js';
17
18
  import { validateAppStoreScreenshots, validatePlayStoreScreenshots, formatValidationResults } from './checks/screenshots.js';
18
19
  import { generateReleaseNotesFromCommits, formatGeneratedNotes } from './ai/notes.js';
19
20
  import { generateReviewReply, formatReviewReply } from './ai/review.js';
@@ -1042,6 +1043,42 @@ server.tool('appstore_delete_product', '⚠️ 비가역. App Store IAP 상품
1042
1043
  }
1043
1044
  return { content: [{ type: 'text', text: `✓ ${productId} 삭제 완료` }] };
1044
1045
  });
1046
+ // --- 배포 플랜 (read-only, AI에게 TodoWrite 지시) ---
1047
+ server.tool('playstore_plan_release', [
1048
+ 'Google Play 배포 플랜 — 인증/빌드/메타데이터/스크린샷/정책 위험을 한 번에 점검하고',
1049
+ '체크리스트로 반환합니다 (read-only, 아무것도 변경하지 않음).',
1050
+ '⚠️ AI 호출자 지시: 응답을 받으면 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
1051
+ '사용자 동의 후 단계별로 기존 도구(playstore_update_listing, playstore_update_release_notes,',
1052
+ 'playstore_submit_release 등)를 호출하세요. submit_release(status=completed)는 비가역이므로 반드시 명시 동의 필요.',
1053
+ ].join(' '), {
1054
+ packageName: z.string().describe('Android 패키지명 (예: gg.pryzm.coffee)'),
1055
+ versionCode: z.string().optional().describe('확인할 versionCode. 미지정 시 트랙 최신 release 검사'),
1056
+ track: z.enum(['production', 'beta', 'alpha', 'internal']).optional().describe('대상 트랙 (기본: production)'),
1057
+ language: z.string().optional().describe('점검할 리스팅 언어 (기본: ko-KR)'),
1058
+ }, async ({ packageName, versionCode, track, language }) => {
1059
+ const auth = requirePlayStoreAuth();
1060
+ const text = await buildPlayStoreReleasePlan({
1061
+ auth,
1062
+ packageName,
1063
+ versionCode,
1064
+ track: track ?? 'production',
1065
+ language: language ?? 'ko-KR',
1066
+ });
1067
+ return { content: [{ type: 'text', text }] };
1068
+ });
1069
+ server.tool('appstore_plan_release', [
1070
+ 'App Store 배포 플랜 — 편집 가능한 버전/빌드 attach/메타/스크린샷/정책 위험을 한 번에 점검하고',
1071
+ '체크리스트로 반환합니다 (read-only).',
1072
+ '⚠️ AI 호출자 지시: 응답의 미완료 항목을 반드시 TodoWrite로 사용자에게 먼저 보여주고,',
1073
+ '사용자 동의 후 단계별로 기존 도구(appstore_update_localization, appstore_update_whats_new,',
1074
+ 'appstore_submit_for_review 등)를 호출하세요. submit_for_review는 비가역이므로 반드시 명시 동의 필요.',
1075
+ ].join(' '), {
1076
+ appId: z.string().describe('App Store 앱 ID (appstore_list_apps 결과의 id)'),
1077
+ versionString: z.string().optional().describe('대상 버전 (예: 1.3.0). 미지정 시 가장 최근 편집 가능 버전'),
1078
+ }, async ({ appId, versionString }) => {
1079
+ const text = await buildAppStoreReleasePlan({ appId, versionString });
1080
+ return { content: [{ type: 'text', text }] };
1081
+ });
1045
1082
  // --- App Store 심사 제출 (Submit for Review) ---
1046
1083
  server.tool('appstore_submit_for_review', [
1047
1084
  'App Store 버전을 심사에 제출 — 새 reviewSubmissions API 사용 (옛 /appStoreVersionSubmissions는 2024-01 deprecated).',
@@ -1130,34 +1167,43 @@ server.tool('mimi_seed_auth_start', [
1130
1167
  }],
1131
1168
  };
1132
1169
  });
1133
- server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인', {}, async () => {
1134
- const client = getAuthenticatedClient();
1135
- if (!client) {
1136
- return {
1137
- content: [{
1138
- type: 'text',
1139
- text: '❌ 인증되지 않음.\n\n터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1140
- }],
1141
- };
1142
- }
1143
- const tokens = getStoredTokens();
1144
- if (tokens?.expiry_date) {
1145
- const now = Date.now();
1146
- const msUntilExpiry = tokens.expiry_date - now;
1147
- if (msUntilExpiry <= 0) {
1170
+ server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
1171
+ const r = await ensureFreshAccessToken();
1172
+ switch (r.status) {
1173
+ case 'unauthenticated':
1148
1174
  return {
1149
- content: [{ type: 'text', text: '⚠️ 토큰 만료. 다시 인증 필요.\n\n터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth' }],
1175
+ content: [{
1176
+ type: 'text',
1177
+ text: `❌ [${r.error.code}] ${r.error.message}\n` +
1178
+ (r.error.hint ? `→ ${r.error.hint}\n\n` : '\n') +
1179
+ '터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1180
+ }],
1150
1181
  };
1182
+ case 'fresh': {
1183
+ const min = Math.round(r.msUntilExpiry / 60000);
1184
+ return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
1151
1185
  }
1152
- if (msUntilExpiry <= 30 * 60 * 1000) {
1186
+ case 'refreshed': {
1187
+ const min = Math.round(r.msUntilExpiry / 60000);
1153
1188
  return {
1154
- content: [{ type: 'text', text: '⚠️ 토큰 곧 만료. 자동 갱신 예정.' }],
1189
+ content: [{
1190
+ type: 'text',
1191
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
1192
+ }],
1155
1193
  };
1156
1194
  }
1195
+ case 'expired_refresh_failed':
1196
+ return {
1197
+ content: [{
1198
+ type: 'text',
1199
+ text: `⚠️ 토큰 만료 + 자동 갱신 실패\n` +
1200
+ ` 코드: ${r.error.code}\n` +
1201
+ ` ${r.error.message}\n` +
1202
+ (r.error.hint ? ` → ${r.error.hint}\n` : '') +
1203
+ '\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1204
+ }],
1205
+ };
1157
1206
  }
1158
- return {
1159
- content: [{ type: 'text', text: '✅ 인증 완료.' }],
1160
- };
1161
1207
  });
1162
1208
  // ══════════════════════════════════════════════════
1163
1209
  // 제출 위험 점검 (Submission Risk Check)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {