@yoonion/mimi-seed-mcp 0.14.1 → 0.15.0
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/google-auth.d.ts +0 -7
- package/dist/auth/google-auth.js +0 -11
- package/dist/lib/http.d.ts +27 -5
- package/dist/lib/http.js +125 -19
- package/package.json +4 -2
|
@@ -46,13 +46,6 @@ export declare function startAuth(clientId: string, clientSecret: string, option
|
|
|
46
46
|
url: string;
|
|
47
47
|
wait: Promise<StoredTokens>;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* Interactive login — opens a private browser window, waits for callback.
|
|
51
|
-
* startAuth() 래퍼 — CLI에서 사용.
|
|
52
|
-
*/
|
|
53
|
-
export declare function login(clientId: string, clientSecret: string, options?: {
|
|
54
|
-
domains?: readonly AuthDomainId[];
|
|
55
|
-
}): Promise<StoredTokens>;
|
|
56
49
|
export type RefreshStatus = {
|
|
57
50
|
status: 'fresh';
|
|
58
51
|
tokens: StoredTokens;
|
package/dist/auth/google-auth.js
CHANGED
|
@@ -5,7 +5,6 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { getMcpOAuthClient } from './constants.js';
|
|
7
7
|
import { AuthError, classifyError } from './errors.js';
|
|
8
|
-
import { openPrivateBrowser } from './browser.js';
|
|
9
8
|
// 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
|
|
10
9
|
import { scopesForDomains, mergeScopeStrings } from './scopes.js';
|
|
11
10
|
import { writeCredentialJson } from '../lib/atomic-write.js';
|
|
@@ -266,16 +265,6 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
266
265
|
});
|
|
267
266
|
return { url: authUrl, wait };
|
|
268
267
|
}
|
|
269
|
-
/**
|
|
270
|
-
* Interactive login — opens a private browser window, waits for callback.
|
|
271
|
-
* startAuth() 래퍼 — CLI에서 사용.
|
|
272
|
-
*/
|
|
273
|
-
export async function login(clientId, clientSecret, options = {}) {
|
|
274
|
-
const { url, wait } = startAuth(clientId, clientSecret, options);
|
|
275
|
-
console.log('🔐 시크릿 브라우저에서 Google 계정 선택 중...');
|
|
276
|
-
await openPrivateBrowser(url);
|
|
277
|
-
return wait;
|
|
278
|
-
}
|
|
279
268
|
/**
|
|
280
269
|
* 저장된 access_token이 만료/곧만료면 refresh_token으로 silent 갱신 시도.
|
|
281
270
|
* - 갱신 성공 시 tokens.json 업데이트
|
package/dist/lib/http.d.ts
CHANGED
|
@@ -8,11 +8,33 @@ export declare const HTTP_TIMEOUT_MS = 60000;
|
|
|
8
8
|
* 아니라 "무한 대기 금지"이므로 넉넉하게 두되 상한은 반드시 존재하게 한다.
|
|
9
9
|
*/
|
|
10
10
|
export declare const HTTP_TRANSFER_TIMEOUT_MS = 600000;
|
|
11
|
+
/** 총 시도 횟수 (최초 1회 + 재시도 2회). */
|
|
12
|
+
export declare const HTTP_MAX_ATTEMPTS = 3;
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* `Retry-After` 해석. 초 단위 숫자와 HTTP-date 두 형식을 모두 받는다.
|
|
15
|
+
* 해석 실패나 음수면 null (호출부가 지수 백오프로 폴백).
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseRetryAfter(value: string | null, nowMs: number): number | null;
|
|
18
|
+
export interface FetchOptions {
|
|
19
|
+
/** 한 **시도당** 상한 (총 상한이 아니다). */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** 총 시도 횟수. 1 이면 재시도하지 않는다. */
|
|
22
|
+
maxAttempts?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 타임아웃 + 재시도가 붙은 `fetch`.
|
|
26
|
+
*
|
|
27
|
+
* 재시도 정책:
|
|
28
|
+
* - **429** 는 메서드와 무관하게 재시도한다. 레이트 리미터는 요청을 처리하기 전에
|
|
29
|
+
* 거절하므로 POST 라도 중복 생성이 일어나지 않는다.
|
|
30
|
+
* - **5xx / 네트워크 오류 / 타임아웃** 은 idempotent 메서드에서만 재시도한다.
|
|
31
|
+
* POST 는 서버가 이미 처리했는지 알 수 없어 재요청이 중복 생성을 만든다.
|
|
32
|
+
* - `Retry-After` 가 있으면 그 값을 쓰고, 없으면 지수 백오프 + 지터.
|
|
33
|
+
* - 재전송 불가능한 본문(스트림)이면 재시도하지 않는다.
|
|
34
|
+
*
|
|
35
|
+
* 호출부가 `signal` 을 넘기면 그쪽 취소를 존중하고 **재시도하지 않는다** — 취소 주체가
|
|
36
|
+
* 둘이 되면 안 되고, Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다.
|
|
14
37
|
*
|
|
15
|
-
*
|
|
16
|
-
* `expect(fetchMock).toHaveBeenCalledWith(url, expect.any(Object))` 로 계약을 잡고 있다.
|
|
38
|
+
* 세 번째 인자는 숫자(=timeoutMs)도 받는다 — 기존 호출부 호환.
|
|
17
39
|
*/
|
|
18
|
-
export declare function fetchWithTimeout(input: string | URL, init?: RequestInit,
|
|
40
|
+
export declare function fetchWithTimeout(input: string | URL, init?: RequestInit, options?: number | FetchOptions): Promise<Response>;
|
package/dist/lib/http.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
// 외부 HTTP 호출의 공통
|
|
1
|
+
// 외부 HTTP 호출의 공통 진입점 — 타임아웃 + 일시적 실패 재시도.
|
|
2
2
|
//
|
|
3
|
-
// 왜 필요한가: 이 서버는 stdio MCP 로 돈다. 소켓이 응답 없이 매달리면
|
|
4
|
-
// **영원히** 반환하지 않고, 클라이언트는 그 호출을 끊을 방법이 없다 —
|
|
5
|
-
// 전체가 멈춘다. Node 의 fetch 는 기본 타임아웃이
|
|
6
|
-
// 있고 응답 대기는 무한)
|
|
3
|
+
// 왜 타임아웃이 필요한가: 이 서버는 stdio MCP 로 돈다. 소켓이 응답 없이 매달리면
|
|
4
|
+
// 도구 호출이 **영원히** 반환하지 않고, 클라이언트는 그 호출을 끊을 방법이 없다 —
|
|
5
|
+
// 에이전트 세션 전체가 멈춘다. Node 의 fetch 는 기본 타임아웃이 없다(undici 는 연결
|
|
6
|
+
// 타임아웃만 있고 응답 대기는 무한).
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// 왜 재시도가 필요한가: Play / App Store Connect / Meta 는 정상 운영 중에도 429 와
|
|
9
|
+
// 일시적 5xx 를 돌려준다. 재시도가 없으면 그 한 번에 도구가 실패하고, 특히 스크린샷·
|
|
10
|
+
// 미리보기 **다중 청크 업로드 중간**에 터지면 서버 쪽에 부분 상태가 남는다.
|
|
11
|
+
//
|
|
12
|
+
// 규칙: 새 provider 클라이언트를 만들 때 raw `fetch` 를 쓰지 말고 이 래퍼를 쓸 것
|
|
13
|
+
// (`__tests__/http-timeout.test.ts` 가 강제한다).
|
|
9
14
|
/** JSON/메타데이터 API 의 기본 상한. 대부분의 Google·Apple·Meta·Jenkins 호출. */
|
|
10
15
|
export const HTTP_TIMEOUT_MS = 60_000;
|
|
11
16
|
/**
|
|
@@ -16,6 +21,22 @@ export const HTTP_TIMEOUT_MS = 60_000;
|
|
|
16
21
|
* 아니라 "무한 대기 금지"이므로 넉넉하게 두되 상한은 반드시 존재하게 한다.
|
|
17
22
|
*/
|
|
18
23
|
export const HTTP_TRANSFER_TIMEOUT_MS = 600_000;
|
|
24
|
+
/** 총 시도 횟수 (최초 1회 + 재시도 2회). */
|
|
25
|
+
export const HTTP_MAX_ATTEMPTS = 3;
|
|
26
|
+
/** 첫 재시도까지의 대기. 이후 2배씩 늘어난다. */
|
|
27
|
+
const BASE_BACKOFF_MS = 500;
|
|
28
|
+
/** Retry-After 가 아무리 길어도 이 이상은 기다리지 않는다 — 도구 호출이 매달린 것과 같아진다. */
|
|
29
|
+
const MAX_BACKOFF_MS = 20_000;
|
|
30
|
+
/**
|
|
31
|
+
* 메서드가 재요청해도 안전한가(RFC 9110 idempotent).
|
|
32
|
+
*
|
|
33
|
+
* POST 는 여기 없다. 5xx 나 네트워크 오류는 **서버가 이미 처리했는지 알 수 없는**
|
|
34
|
+
* 상태이고, POST 를 다시 보내면 리소스가 두 개 생긴다(버전·제품·심사 제출이 중복
|
|
35
|
+
* 생성되는 쪽이 한 번 실패하는 것보다 훨씬 나쁘다).
|
|
36
|
+
*/
|
|
37
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']);
|
|
38
|
+
/** 일시적이라고 보는 상태 코드. 408·425 도 재요청이 정답인 표준 케이스다. */
|
|
39
|
+
const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
19
40
|
/**
|
|
20
41
|
* 에러 메시지에 쓸 엔드포인트 라벨.
|
|
21
42
|
*
|
|
@@ -37,22 +58,107 @@ function isTimeoutAbort(error) {
|
|
|
37
58
|
return named(error) || named(error?.cause);
|
|
38
59
|
}
|
|
39
60
|
/**
|
|
40
|
-
*
|
|
41
|
-
* (취소 주체가 둘이 되지 않게 — Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다).
|
|
61
|
+
* 본문을 다시 보낼 수 있는가.
|
|
42
62
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
63
|
+
* 스트림 본문은 한 번 읽히면 소진돼 재전송이 조용히 빈 요청이 된다. 문자열·버퍼·
|
|
64
|
+
* FormData 는 안전하다.
|
|
45
65
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
function isReplayableBody(body) {
|
|
67
|
+
if (body == null)
|
|
68
|
+
return true;
|
|
69
|
+
if (typeof body === 'string')
|
|
70
|
+
return true;
|
|
71
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body))
|
|
72
|
+
return true;
|
|
73
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData)
|
|
74
|
+
return true;
|
|
75
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams)
|
|
76
|
+
return true;
|
|
77
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob)
|
|
78
|
+
return true;
|
|
79
|
+
return false; // ReadableStream 등 — 재전송 불가
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `Retry-After` 해석. 초 단위 숫자와 HTTP-date 두 형식을 모두 받는다.
|
|
83
|
+
* 해석 실패나 음수면 null (호출부가 지수 백오프로 폴백).
|
|
84
|
+
*/
|
|
85
|
+
export function parseRetryAfter(value, nowMs) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return null;
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
if (/^\d+$/.test(trimmed)) {
|
|
90
|
+
return Math.min(Number(trimmed) * 1000, MAX_BACKOFF_MS);
|
|
50
91
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
const at = Date.parse(trimmed);
|
|
93
|
+
if (Number.isNaN(at))
|
|
94
|
+
return null;
|
|
95
|
+
const delta = at - nowMs;
|
|
96
|
+
if (delta <= 0)
|
|
97
|
+
return 0;
|
|
98
|
+
return Math.min(delta, MAX_BACKOFF_MS);
|
|
99
|
+
}
|
|
100
|
+
/** 시도 번호(0부터)에 대한 지수 백오프 + 지터. 지터는 동시 재시도가 몰리는 것을 막는다. */
|
|
101
|
+
function backoffFor(attempt) {
|
|
102
|
+
const base = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
103
|
+
return base + Math.floor(Math.random() * (base / 2));
|
|
104
|
+
}
|
|
105
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
|
+
/**
|
|
107
|
+
* 타임아웃 + 재시도가 붙은 `fetch`.
|
|
108
|
+
*
|
|
109
|
+
* 재시도 정책:
|
|
110
|
+
* - **429** 는 메서드와 무관하게 재시도한다. 레이트 리미터는 요청을 처리하기 전에
|
|
111
|
+
* 거절하므로 POST 라도 중복 생성이 일어나지 않는다.
|
|
112
|
+
* - **5xx / 네트워크 오류 / 타임아웃** 은 idempotent 메서드에서만 재시도한다.
|
|
113
|
+
* POST 는 서버가 이미 처리했는지 알 수 없어 재요청이 중복 생성을 만든다.
|
|
114
|
+
* - `Retry-After` 가 있으면 그 값을 쓰고, 없으면 지수 백오프 + 지터.
|
|
115
|
+
* - 재전송 불가능한 본문(스트림)이면 재시도하지 않는다.
|
|
116
|
+
*
|
|
117
|
+
* 호출부가 `signal` 을 넘기면 그쪽 취소를 존중하고 **재시도하지 않는다** — 취소 주체가
|
|
118
|
+
* 둘이 되면 안 되고, Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다.
|
|
119
|
+
*
|
|
120
|
+
* 세 번째 인자는 숫자(=timeoutMs)도 받는다 — 기존 호출부 호환.
|
|
121
|
+
*/
|
|
122
|
+
export async function fetchWithTimeout(input, init = {}, options = {}) {
|
|
123
|
+
const opts = typeof options === 'number' ? { timeoutMs: options } : options;
|
|
124
|
+
const timeoutMs = opts.timeoutMs ?? HTTP_TIMEOUT_MS;
|
|
125
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
126
|
+
const callerSignal = init.signal != null;
|
|
127
|
+
const retryable = !callerSignal && isReplayableBody(init.body);
|
|
128
|
+
const maxAttempts = retryable ? (opts.maxAttempts ?? HTTP_MAX_ATTEMPTS) : 1;
|
|
129
|
+
const idempotent = IDEMPOTENT_METHODS.has(method);
|
|
130
|
+
let lastError;
|
|
131
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
132
|
+
const isLast = attempt === maxAttempts - 1;
|
|
133
|
+
const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await fetch(input, { ...init, signal });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
lastError = error;
|
|
140
|
+
// 응답 전 실패는 서버가 요청을 받았는지 알 수 없다 → idempotent 에서만 재시도.
|
|
141
|
+
if (isLast || !idempotent) {
|
|
142
|
+
if (isTimeoutAbort(error)) {
|
|
143
|
+
throw new Error(`${endpointLabel(input)} 요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않아 중단했습니다. ` +
|
|
144
|
+
'네트워크 상태를 확인하고 다시 시도하세요.', { cause: error });
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
await sleep(backoffFor(attempt));
|
|
149
|
+
continue;
|
|
55
150
|
}
|
|
56
|
-
|
|
151
|
+
if (response.ok || isLast || !TRANSIENT_STATUS.has(response.status))
|
|
152
|
+
return response;
|
|
153
|
+
// 429 는 처리 전 거절이므로 POST 도 안전하다. 그 외 일시적 오류는 idempotent 만.
|
|
154
|
+
if (response.status !== 429 && !idempotent)
|
|
155
|
+
return response;
|
|
156
|
+
const wait = parseRetryAfter(response.headers.get('retry-after'), Date.now()) ?? backoffFor(attempt);
|
|
157
|
+
// 재시도할 응답의 본문은 읽지 않고 버린다 — 소켓을 붙잡고 있지 않도록.
|
|
158
|
+
await response.body?.cancel().catch(() => undefined);
|
|
159
|
+
await sleep(wait);
|
|
57
160
|
}
|
|
161
|
+
// 도달 불가 — 마지막 시도(isLast)는 반드시 반환하거나 throw 한다. 그래도 unknown 을
|
|
162
|
+
// 그대로 던지지 않고 Error 로 감싼다: 호출부의 `instanceof Error` 분기가 전부 무너진다.
|
|
163
|
+
throw new Error(`${endpointLabel(input)} 요청 실패`, { cause: lastError });
|
|
58
164
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoonion/mimi-seed-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"test": "npm run lint && vitest run",
|
|
30
30
|
"test:watch": "vitest",
|
|
31
31
|
"prepublishOnly": "node ../../scripts/sync-agent-guide.mjs --check && tsc",
|
|
32
|
-
"lint": "eslint ."
|
|
32
|
+
"lint": "eslint .",
|
|
33
|
+
"coverage": "vitest run --coverage"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"mcp",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@eslint/js": "^10.0.1",
|
|
73
74
|
"@types/node": "^22.0.0",
|
|
75
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
74
76
|
"eslint": "^10.8.0",
|
|
75
77
|
"tsx": "^4.19.0",
|
|
76
78
|
"typescript": "^5.7.0",
|