@yoonion/mimi-seed-mcp 0.3.8 → 0.3.10

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
+ }
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';
@@ -1007,6 +1007,9 @@ server.tool('appstore_update_product', 'App Store IAP 상품의 reference name
1007
1007
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
1008
1008
  name: z.string().describe('새 reference name'),
1009
1009
  }, async ({ appId, bundleId, productId, productType, name }) => {
1010
+ if (!appId && !bundleId) {
1011
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
1012
+ }
1010
1013
  const creds = requireAppStoreCreds();
1011
1014
  const result = await updateAppleProduct({
1012
1015
  appId, bundleId, productId, productType, name,
@@ -1023,6 +1026,9 @@ server.tool('appstore_delete_product', '⚠️ 비가역. App Store IAP 상품
1023
1026
  productId: z.string().describe('상품 ID'),
1024
1027
  productType: z.enum(['subscription', 'consumable', 'non_consumable']).describe('상품 유형'),
1025
1028
  }, async ({ appId, bundleId, productId, productType }) => {
1029
+ if (!appId && !bundleId) {
1030
+ throw new Error('appId 또는 bundleId 중 하나는 반드시 제공해야 합니다.');
1031
+ }
1026
1032
  const creds = requireAppStoreCreds();
1027
1033
  const result = await deleteAppleProduct({
1028
1034
  appId, bundleId, productId, productType,
@@ -1124,34 +1130,43 @@ server.tool('mimi_seed_auth_start', [
1124
1130
  }],
1125
1131
  };
1126
1132
  });
1127
- server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인', {}, async () => {
1128
- const client = getAuthenticatedClient();
1129
- if (!client) {
1130
- return {
1131
- content: [{
1132
- type: 'text',
1133
- text: '❌ 인증되지 않음.\n\n터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1134
- }],
1135
- };
1136
- }
1137
- const tokens = getStoredTokens();
1138
- if (tokens?.expiry_date) {
1139
- const now = Date.now();
1140
- const msUntilExpiry = tokens.expiry_date - now;
1141
- if (msUntilExpiry <= 0) {
1133
+ server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
1134
+ const r = await ensureFreshAccessToken();
1135
+ switch (r.status) {
1136
+ case 'unauthenticated':
1142
1137
  return {
1143
- content: [{ type: 'text', text: '⚠️ 토큰 만료. 다시 인증 필요.\n\n터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth' }],
1138
+ content: [{
1139
+ type: 'text',
1140
+ text: `❌ [${r.error.code}] ${r.error.message}\n` +
1141
+ (r.error.hint ? `→ ${r.error.hint}\n\n` : '\n') +
1142
+ '터미널에서 실행:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1143
+ }],
1144
1144
  };
1145
+ case 'fresh': {
1146
+ const min = Math.round(r.msUntilExpiry / 60000);
1147
+ return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
1145
1148
  }
1146
- if (msUntilExpiry <= 30 * 60 * 1000) {
1149
+ case 'refreshed': {
1150
+ const min = Math.round(r.msUntilExpiry / 60000);
1147
1151
  return {
1148
- content: [{ type: 'text', text: '⚠️ 토큰 곧 만료. 자동 갱신 예정.' }],
1152
+ content: [{
1153
+ type: 'text',
1154
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
1155
+ }],
1149
1156
  };
1150
1157
  }
1158
+ case 'expired_refresh_failed':
1159
+ return {
1160
+ content: [{
1161
+ type: 'text',
1162
+ text: `⚠️ 토큰 만료 + 자동 갱신 실패\n` +
1163
+ ` 코드: ${r.error.code}\n` +
1164
+ ` ${r.error.message}\n` +
1165
+ (r.error.hint ? ` → ${r.error.hint}\n` : '') +
1166
+ '\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
1167
+ }],
1168
+ };
1151
1169
  }
1152
- return {
1153
- content: [{ type: 'text', text: '✅ 인증 완료.' }],
1154
- };
1155
1170
  });
1156
1171
  // ══════════════════════════════════════════════════
1157
1172
  // 제출 위험 점검 (Submission Risk Check)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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": {