@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 +241 -35
- package/dist/auth/errors.d.ts +24 -0
- package/dist/auth/errors.js +152 -0
- package/dist/auth/google-auth.d.ts +26 -0
- package/dist/auth/google-auth.js +125 -7
- package/dist/index.js +37 -22
- package/package.json +1 -1
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
155
|
+
// 2) OAuth 콜백 서버 + URL 발급
|
|
156
|
+
err('');
|
|
157
|
+
err(' 🌐 OAuth 콜백 서버 시작: http://localhost:9876/callback');
|
|
158
|
+
let url;
|
|
159
|
+
let wait;
|
|
24
160
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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>;
|
package/dist/auth/google-auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
215
|
+
rejectAuth(err);
|
|
167
216
|
}
|
|
168
217
|
});
|
|
169
218
|
server.on('error', (err) => {
|
|
170
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1129
|
-
|
|
1130
|
-
|
|
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: [{
|
|
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
|
-
|
|
1149
|
+
case 'refreshed': {
|
|
1150
|
+
const min = Math.round(r.msUntilExpiry / 60000);
|
|
1147
1151
|
return {
|
|
1148
|
-
content: [{
|
|
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