@yoonion/mimi-seed-mcp 0.14.1 → 0.15.1

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.
@@ -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;
@@ -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 업데이트
@@ -8,11 +8,34 @@ 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
- * 타임아웃이 걸린 `fetch`. 호출부가 이미 signal 넘겼으면 그쪽을 존중한다
13
- * (취소 주체가 둘이 되지 않게 Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다).
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
+ /** 한 **시도당** 상한. 총 소요 시간의 상한은 아래 RETRY_WINDOW_MS 를 더한 값이다. */
20
+ timeoutMs?: number;
21
+ /** 총 시도 횟수. 1 이면 재시도하지 않는다. */
22
+ maxAttempts?: number;
23
+ }
24
+ /**
25
+ * 타임아웃 + 재시도가 붙은 `fetch`.
26
+ *
27
+ * 재시도 정책:
28
+ * - **429** 는 메서드와 무관하게 재시도한다. 레이트 리미터는 요청을 처리하기 전에
29
+ * 거절하므로 POST 라도 중복 생성이 일어나지 않는다.
30
+ * - **5xx / 빠른 네트워크 오류**(ECONNRESET·DNS 등)는 idempotent 메서드에서만.
31
+ * POST 는 서버가 이미 처리했는지 알 수 없어 재요청이 중복 생성을 만든다.
32
+ * - **타임아웃은 재시도하지 않는다.** 아래 시간 예산 참고.
33
+ * - `Retry-After` 가 있으면 그 값을 쓰고, 없으면 지수 백오프 + 지터.
34
+ * - 재전송 불가능한 본문(스트림)이면 재시도하지 않는다.
35
+ *
36
+ * 호출부가 `signal` 을 넘기면 그쪽 취소를 존중하고 **재시도하지 않는다** — 취소 주체가
37
+ * 둘이 되면 안 되고, Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다.
14
38
  *
15
- * 인자를 정확히 2개로 넘기는 것은 의도적이다: 기존 테스트들이
16
- * `expect(fetchMock).toHaveBeenCalledWith(url, expect.any(Object))` 로 계약을 잡고 있다.
39
+ * 번째 인자는 숫자(=timeoutMs)도 받는다 기존 호출부 호환.
17
40
  */
18
- export declare function fetchWithTimeout(input: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response>;
41
+ 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 는 기본 타임아웃이 없으므로(undici 는 연결 타임아웃만
6
- // 있고 응답 대기는 무한) 호출부마다 명시적으로 걸어야 한다.
3
+ // 왜 타임아웃이 필요한가: 이 서버는 stdio MCP 로 돈다. 소켓이 응답 없이 매달리면
4
+ // 도구 호출이 **영원히** 반환하지 않고, 클라이언트는 그 호출을 끊을 방법이 없다 —
5
+ // 에이전트 세션 전체가 멈춘다. Node 의 fetch 는 기본 타임아웃이 없다(undici 는 연결
6
+ // 타임아웃만 있고 응답 대기는 무한).
7
7
  //
8
- // 규칙: provider 클라이언트를 만들 raw `fetch` 쓰지 말고 래퍼를 것.
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,32 @@ 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
+ * 재시도가 **추가로** 쓸 수 있는 총 시간.
32
+ *
33
+ * 재시도 도입 전의 상한은 timeoutMs 하나였다(전송은 600초). 시도마다 그 상한을 새로
34
+ * 주면 총 30분까지 늘어나서, 이 모듈이 존재하는 이유("도구 호출이 매달리면 안 된다")를
35
+ * 재시도가 되살린다. 그래서 총 예산 = timeoutMs + 이 값으로 못박는다.
36
+ */
37
+ const RETRY_WINDOW_MS = 30_000;
38
+ /** 예산이 거의 소진돼도 최소한 이만큼은 준다 — 0초 타임아웃으로 즉시 죽는 것을 막는다. */
39
+ const MIN_ATTEMPT_MS = 1_000;
40
+ /**
41
+ * 메서드가 재요청해도 안전한가(RFC 9110 idempotent).
42
+ *
43
+ * POST 는 여기 없다. 5xx 나 네트워크 오류는 **서버가 이미 처리했는지 알 수 없는**
44
+ * 상태이고, POST 를 다시 보내면 리소스가 두 개 생긴다(버전·제품·심사 제출이 중복
45
+ * 생성되는 쪽이 한 번 실패하는 것보다 훨씬 나쁘다).
46
+ */
47
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']);
48
+ /** 일시적이라고 보는 상태 코드. 408·425 도 재요청이 정답인 표준 케이스다. */
49
+ const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
19
50
  /**
20
51
  * 에러 메시지에 쓸 엔드포인트 라벨.
21
52
  *
@@ -37,22 +68,115 @@ function isTimeoutAbort(error) {
37
68
  return named(error) || named(error?.cause);
38
69
  }
39
70
  /**
40
- * 타임아웃이 걸린 `fetch`. 호출부가 이미 signal 을 넘겼으면 그쪽을 존중한다
41
- * (취소 주체가 둘이 되지 않게 — Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다).
71
+ * 본문을 다시 보낼 있는가.
42
72
  *
43
- * 인자를 정확히 2개로 넘기는 것은 의도적이다: 기존 테스트들이
44
- * `expect(fetchMock).toHaveBeenCalledWith(url, expect.any(Object))` 로 계약을 잡고 있다.
73
+ * 스트림 본문은 읽히면 소진돼 재전송이 조용히 빈 요청이 된다. 문자열·버퍼·
74
+ * FormData 안전하다.
45
75
  */
46
- export async function fetchWithTimeout(input, init = {}, timeoutMs = HTTP_TIMEOUT_MS) {
47
- const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
48
- try {
49
- return await fetch(input, { ...init, signal });
76
+ function isReplayableBody(body) {
77
+ if (body == null)
78
+ return true;
79
+ if (typeof body === 'string')
80
+ return true;
81
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body))
82
+ return true;
83
+ if (typeof FormData !== 'undefined' && body instanceof FormData)
84
+ return true;
85
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams)
86
+ return true;
87
+ if (typeof Blob !== 'undefined' && body instanceof Blob)
88
+ return true;
89
+ return false; // ReadableStream 등 — 재전송 불가
90
+ }
91
+ /**
92
+ * `Retry-After` 해석. 초 단위 숫자와 HTTP-date 두 형식을 모두 받는다.
93
+ * 해석 실패나 음수면 null (호출부가 지수 백오프로 폴백).
94
+ */
95
+ export function parseRetryAfter(value, nowMs) {
96
+ if (!value)
97
+ return null;
98
+ const trimmed = value.trim();
99
+ if (/^\d+$/.test(trimmed)) {
100
+ return Math.min(Number(trimmed) * 1000, MAX_BACKOFF_MS);
50
101
  }
51
- catch (error) {
52
- if (isTimeoutAbort(error)) {
53
- throw new Error(`${endpointLabel(input)} 요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않아 중단했습니다. ` +
54
- '네트워크 상태를 확인하고 다시 시도하세요.', { cause: error });
102
+ const at = Date.parse(trimmed);
103
+ if (Number.isNaN(at))
104
+ return null;
105
+ const delta = at - nowMs;
106
+ if (delta <= 0)
107
+ return 0;
108
+ return Math.min(delta, MAX_BACKOFF_MS);
109
+ }
110
+ /** 시도 번호(0부터)에 대한 지수 백오프 + 지터. 지터는 동시 재시도가 몰리는 것을 막는다. */
111
+ function backoffFor(attempt) {
112
+ const base = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
113
+ return base + Math.floor(Math.random() * (base / 2));
114
+ }
115
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
116
+ /** 백오프가 총 예산을 넘기지 않게 자른다. */
117
+ const cappedWait = (wait, deadline) => Math.max(0, Math.min(wait, deadline - Date.now()));
118
+ /**
119
+ * 타임아웃 + 재시도가 붙은 `fetch`.
120
+ *
121
+ * 재시도 정책:
122
+ * - **429** 는 메서드와 무관하게 재시도한다. 레이트 리미터는 요청을 처리하기 전에
123
+ * 거절하므로 POST 라도 중복 생성이 일어나지 않는다.
124
+ * - **5xx / 빠른 네트워크 오류**(ECONNRESET·DNS 등)는 idempotent 메서드에서만.
125
+ * POST 는 서버가 이미 처리했는지 알 수 없어 재요청이 중복 생성을 만든다.
126
+ * - **타임아웃은 재시도하지 않는다.** 아래 시간 예산 참고.
127
+ * - `Retry-After` 가 있으면 그 값을 쓰고, 없으면 지수 백오프 + 지터.
128
+ * - 재전송 불가능한 본문(스트림)이면 재시도하지 않는다.
129
+ *
130
+ * 호출부가 `signal` 을 넘기면 그쪽 취소를 존중하고 **재시도하지 않는다** — 취소 주체가
131
+ * 둘이 되면 안 되고, Node 20.0 에는 AbortSignal.any 가 없어 합성도 못 한다.
132
+ *
133
+ * 세 번째 인자는 숫자(=timeoutMs)도 받는다 — 기존 호출부 호환.
134
+ */
135
+ export async function fetchWithTimeout(input, init = {}, options = {}) {
136
+ const opts = typeof options === 'number' ? { timeoutMs: options } : options;
137
+ const timeoutMs = opts.timeoutMs ?? HTTP_TIMEOUT_MS;
138
+ const method = (init.method ?? 'GET').toUpperCase();
139
+ const callerSignal = init.signal != null;
140
+ const replayable = !callerSignal && isReplayableBody(init.body);
141
+ const maxAttempts = replayable ? (opts.maxAttempts ?? HTTP_MAX_ATTEMPTS) : 1;
142
+ const idempotent = IDEMPOTENT_METHODS.has(method);
143
+ // 총 시간 예산. 재시도가 없던 시절의 상한(timeoutMs)에 재시도 창만 더한 값으로
144
+ // 고정한다 — 이게 없으면 전송용 600초 × 3회 = 30분이 되어, 이 모듈이 애초에
145
+ // 막으려던 "도구 호출이 매달림"을 재시도가 되살린다.
146
+ const deadline = Date.now() + timeoutMs + (maxAttempts > 1 ? RETRY_WINDOW_MS : 0);
147
+ /** 남은 예산 안에서 다음 시도를 시작해도 되는가. */
148
+ const hasBudget = (next) => next < maxAttempts && Date.now() < deadline;
149
+ for (let attempt = 0;; attempt += 1) {
150
+ // 마지막 시도가 예산을 넘겨 달리지 않도록, 남은 시간으로 잘라준다.
151
+ const attemptTimeout = Math.min(timeoutMs, Math.max(deadline - Date.now(), MIN_ATTEMPT_MS));
152
+ const signal = init.signal ?? AbortSignal.timeout(attemptTimeout);
153
+ let response;
154
+ try {
155
+ response = await fetch(input, { ...init, signal });
156
+ }
157
+ catch (error) {
158
+ // 타임아웃은 재시도하지 않는다. 이미 예산을 통째로 쓴 실패이고, 같은 상한으로
159
+ // 두 번 더 기다려도 얻는 게 없다 — 총 소요 시간만 배로 늘린다.
160
+ if (isTimeoutAbort(error)) {
161
+ throw new Error(`${endpointLabel(input)} 요청이 ${Math.round(attemptTimeout / 1000)}초 안에 끝나지 않아 중단했습니다. ` +
162
+ '네트워크 상태를 확인하고 다시 시도하세요.', { cause: error });
163
+ }
164
+ // 응답 전 실패는 서버가 요청을 받았는지 알 수 없다 → idempotent 에서만 재시도.
165
+ if (!idempotent || !hasBudget(attempt + 1))
166
+ throw error;
167
+ await sleep(cappedWait(backoffFor(attempt), deadline));
168
+ continue;
55
169
  }
56
- throw error;
170
+ if (response.ok || !TRANSIENT_STATUS.has(response.status))
171
+ return response;
172
+ // 429 는 처리 전 거절이므로 POST 도 안전하다. 그 외 일시적 오류는 idempotent 만.
173
+ if (response.status !== 429 && !idempotent)
174
+ return response;
175
+ if (!hasBudget(attempt + 1))
176
+ return response;
177
+ const wait = parseRetryAfter(response.headers.get('retry-after'), Date.now()) ?? backoffFor(attempt);
178
+ // 재시도할 응답의 본문은 읽지 않고 버린다 — 소켓을 붙잡고 있지 않도록.
179
+ await response.body?.cancel().catch(() => undefined);
180
+ await sleep(cappedWait(wait, deadline));
57
181
  }
58
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
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",