@yoonion/mimi-seed-mcp 0.11.0 → 0.12.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.
@@ -1,6 +1,6 @@
1
1
  import { publisher, withEdit } from '../playstore/tools.js';
2
2
  import { apiGet } from '../appstore/tools.js';
3
- const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW';
3
+ const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,METADATA_REJECTED,REJECTED';
4
4
  export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
5
5
  const risks = [];
6
6
  await withEdit(auth, packageName, async (editId) => {
@@ -95,19 +95,23 @@ export async function checkAppStoreRisks(appId) {
95
95
  }
96
96
  else {
97
97
  for (const loc of locs.data) {
98
- const { locale, description, whatsNew, keywords } = loc.attributes ?? {};
98
+ const { locale: rawLocale, description, whatsNew, keywords } = loc.attributes ?? {};
99
+ const locale = rawLocale ?? loc.id;
99
100
  if (!description)
100
101
  risks.push({ level: 'blocker', code: `NO_DESC_${locale}`, title: `${locale} 설명 없음`, detail: '앱 설명은 필수 항목입니다.' });
101
102
  if (!whatsNew)
102
103
  risks.push({ level: 'warning', code: `NO_WHATS_NEW_${locale}`, title: `${locale} 새로운 기능 없음`, detail: '릴리즈 노트를 입력하면 다운로드 전환율이 높아집니다.' });
103
104
  if (!keywords)
104
105
  risks.push({ level: 'warning', code: `NO_KEYWORDS_${locale}`, title: `${locale} 키워드 없음`, detail: '키워드는 검색 노출에 직접 영향을 줍니다.' });
105
- }
106
- // screenshots depend on locs being present
107
- const locId = locs.data[0].id;
108
- const screenshots = await safeGet(() => apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`), risks, 'SCREENSHOTS', '스크린샷 셋');
109
- if (screenshots && !screenshots?.data?.length) {
110
- risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '스크린샷 없음', detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.' });
106
+ const screenshots = await safeGet(() => apiGet(`/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`), risks, `SCREENSHOTS_${locale}`, `${locale} 스크린샷 셋`);
107
+ if (screenshots && !screenshots.data?.length) {
108
+ risks.push({
109
+ level: 'blocker',
110
+ code: `NO_SCREENSHOTS_${locale}`,
111
+ title: `${locale} 스크린샷 없음`,
112
+ detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.',
113
+ });
114
+ }
111
115
  }
112
116
  }
113
117
  // 빌드 — 1차: 버전에 첨부된 빌드. 2차 폴백: 앱 전체 빌드 (예전 동작).
package/dist/helpers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getAuthenticatedClient, ensureFreshAccessToken, getStoredTokens } from './auth/google-auth.js';
2
+ import { domainsForScope, isPreTrackingScope } from './auth/scopes.js';
2
3
  import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore-auth.js';
3
4
  import { getAppStoreCredentials } from './appstore/auth.js';
4
5
  export { ensureFreshAccessToken };
@@ -37,17 +38,28 @@ export async function requireAuth(requiredScope) {
37
38
  needsReauth: true,
38
39
  }));
39
40
  }
40
- // 신규 스코프(GA4 analytics.edit 등)는 변경 전 발급된 토큰엔 없다. expiry 만 보는
41
- // ensureFreshAccessToken 이를 못 걸러내므로(런타임 ACCESS_TOKEN_SCOPE_INSUFFICIENT),
42
- // 저장된 scope pre-flight 검사해 결정적인 재로그인 안내를 던진다.
43
- // (scope undefined 토큰은 미보유로 간주 재로그인 유도.)
41
+ // 도구가 요구하는 스코프의 pre-flight 검사. expiry 만 보는 ensureFreshAccessToken 은
42
+ // 스코프 미보유를 못 걸러내므로(런타임 ACCESS_TOKEN_SCOPE_INSUFFICIENT), 저장된 scope 로
43
+ // 결정적인 안내를 던진다. 도메인 선택형 로그인 도입 후에는 "전체 재로그인"이 아니라
44
+ // "--domains <해당 도메인> 으로 추가 부여(기존 권한 유지)"가 올바른 해법이다.
45
+ //
46
+ // scope 가 undefined 인 구 토큰(스코프 추적 도입 전 full-scope 로그인)은 추적 도입
47
+ // 이전부터 있던 스코프는 보유한 게 확실하므로 통과시킨다 — 안 그러면 pre-flight 를 새로
48
+ // 다는 순간 멀쩡한 기존 사용자에게 재로그인을 강제한다. 추적 이후 추가된 스코프(GA4)만
49
+ // 미보유 확정으로 본다.
44
50
  if (requiredScope) {
45
- const granted = (getStoredTokens()?.scope ?? '').split(' ').filter(Boolean);
46
- if (!granted.includes(requiredScope)) {
51
+ const scopeStr = getStoredTokens()?.scope;
52
+ const missing = scopeStr === undefined
53
+ ? !isPreTrackingScope(requiredScope)
54
+ : !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
55
+ if (missing) {
56
+ const domainArg = domainsForScope(requiredScope).join(',');
47
57
  throw new Error(formatAuthError({
48
58
  code: 'INSUFFICIENT_SCOPE',
49
- message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 기존 로그인에 없어서 1회 재로그인이 필요해.`,
50
- hint: 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
59
+ message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 권한이 없어.`,
60
+ hint: domainArg
61
+ ? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
62
+ : 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
51
63
  retriable: false,
52
64
  needsReauth: true,
53
65
  }));
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
3
  import { buildServer } from './server.js';
5
- // dist/index.js 기준 ../package.json — npm 패키지 루트의 버전을 단일 출처로 사용.
4
+ import { readPackageRootText } from './lib/package-root.js';
5
+ // npm 패키지 루트의 버전을 단일 출처로 사용.
6
6
  // 하드코딩하면 publish 때마다 serverInfo.version 이 드리프트하므로 런타임에 읽는다.
7
- const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
7
+ const { version } = JSON.parse(readPackageRootText('package.json'));
8
8
  // `npx -y @yoonion/mimi-seed-mcp <subcommand>` 처리.
9
9
  // npx는 스코프 패키지의 basename(`mimi-seed-mcp`)을 매치해 이 bin을 실행하므로,
10
10
  // 추가 인자(`mimi-seed-auth` 등)는 여기 argv로 흘러들어온다. 이전엔 MCP 서버가
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 패키지 루트의 파일을 읽는다 (없거나 못 읽으면 throw).
3
+ * src/lib/ 와 dist/lib/ 모두 패키지 루트에서 두 단계 아래라 `../../` 가 같은 곳을 가리킨다 —
4
+ * dev(tsx)·vitest·배포본(npm) 어디서 실행해도 동일하게 동작한다.
5
+ * 개별 `new URL('../..', import.meta.url)` 복사본을 만들지 말고 이 헬퍼를 쓸 것
6
+ * (빌드 레이아웃이 바뀌면 여기 한 곳만 고치면 된다).
7
+ */
8
+ export declare function readPackageRootText(relativePath: string): string;
9
+ export type DomainEntry = {
10
+ /** 사람이 읽는 도메인 이름 (예: "Google Play") */
11
+ label: string;
12
+ /** 이 도메인을 쓰기 위해 필요한 자격증명 + 연결 명령 힌트 */
13
+ credential: string;
14
+ /** 도메인이 하는 일 한 줄 요약 */
15
+ summary: string;
16
+ tools: string[];
17
+ };
18
+ /** tool-manifest.json 의 형태 — 도구 인벤토리 + 도메인 메타데이터의 SSOT. */
19
+ export type ToolManifest = {
20
+ total: number;
21
+ domains: Record<string, DomainEntry>;
22
+ };
23
+ /** tool-manifest.json 을 읽고 최소 형태를 검증한다. 손상/형태이상이면 throw. */
24
+ export declare function readToolManifest(): ToolManifest;
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * 패키지 루트의 파일을 읽는다 (없거나 못 읽으면 throw).
4
+ * src/lib/ 와 dist/lib/ 모두 패키지 루트에서 두 단계 아래라 `../../` 가 같은 곳을 가리킨다 —
5
+ * dev(tsx)·vitest·배포본(npm) 어디서 실행해도 동일하게 동작한다.
6
+ * 개별 `new URL('../..', import.meta.url)` 복사본을 만들지 말고 이 헬퍼를 쓸 것
7
+ * (빌드 레이아웃이 바뀌면 여기 한 곳만 고치면 된다).
8
+ */
9
+ export function readPackageRootText(relativePath) {
10
+ return readFileSync(new URL(`../../${relativePath}`, import.meta.url), 'utf8');
11
+ }
12
+ /** tool-manifest.json 을 읽고 최소 형태를 검증한다. 손상/형태이상이면 throw. */
13
+ export function readToolManifest() {
14
+ const manifest = JSON.parse(readPackageRootText('tool-manifest.json'));
15
+ if (typeof manifest?.total !== 'number' ||
16
+ typeof manifest?.domains !== 'object' ||
17
+ manifest.domains === null) {
18
+ throw new Error('tool-manifest.json 의 형태가 예상과 다릅니다');
19
+ }
20
+ return manifest;
21
+ }
@@ -1,6 +1,7 @@
1
1
  import { google } from 'googleapis';
2
2
  import { JWT } from 'google-auth-library';
3
3
  import fs from 'node:fs';
4
+ import { extractHttpStatus } from '../lib/google-errors.js';
4
5
  function mimeTypeFor(filePath) {
5
6
  const ext = filePath.toLowerCase().split('.').pop() ?? '';
6
7
  if (ext === 'png')
@@ -149,14 +150,35 @@ export async function updateListing(auth, packageName, language, data) {
149
150
  // 그대로 실려 나가면 patch 라도 해당 필드를 비우려는 의도로 해석될 여지가 있다.
150
151
  const requestBody = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
151
152
  return withEdit(auth, packageName, async (editId) => {
152
- const updated = await publisher().edits.listings.patch({
153
- auth,
154
- packageName,
155
- editId,
156
- language,
157
- requestBody,
158
- });
159
- return updated.data;
153
+ try {
154
+ const updated = await publisher().edits.listings.patch({
155
+ auth,
156
+ packageName,
157
+ editId,
158
+ language,
159
+ requestBody,
160
+ });
161
+ return updated.data;
162
+ }
163
+ catch (error) {
164
+ // PATCH only works when the locale already exists. A missing translation returns
165
+ // 404 even though the package and edit are valid. In that case a complete PUT is
166
+ // the documented create-or-replace operation for the new locale.
167
+ if (extractHttpStatus(error) !== 404)
168
+ throw error;
169
+ const { title, shortDescription, fullDescription } = data;
170
+ if (!title || !shortDescription || !fullDescription) {
171
+ throw new Error(`Google Play ${language} 리스팅이 아직 없어요. 새 언어를 만들려면 title, shortDescription, fullDescription을 모두 보내야 해요.`, { cause: error });
172
+ }
173
+ const created = await publisher().edits.listings.update({
174
+ auth,
175
+ packageName,
176
+ editId,
177
+ language,
178
+ requestBody: { title, shortDescription, fullDescription },
179
+ });
180
+ return created.data;
181
+ }
160
182
  }, true);
161
183
  }
162
184
  // ─── 트랙 목록 (릴리스 현황) ───
@@ -1,8 +1,8 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { z } from 'zod';
3
2
  import { getMcpOAuthClient } from '../auth/constants.js';
4
3
  import { classifyError } from '../auth/errors.js';
5
- import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs } from '../auth/google-auth.js';
4
+ import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs, getStoredTokens, } from '../auth/google-auth.js';
5
+ import { AUTH_DOMAINS, DOMAIN_IDS, summarizeGrantedDomains } from '../auth/scopes.js';
6
6
  import { listRegisteredServiceAccounts } from '../auth/playstore-auth.js';
7
7
  import { getAppStoreCredentials } from '../appstore/auth.js';
8
8
  import { loadJenkinsConfig } from '../jenkins/config.js';
@@ -12,6 +12,7 @@ import { loadFacebookConfig } from '../facebook/config.js';
12
12
  import { loadInstagramConfig } from '../instagram/config.js';
13
13
  import { loadThreadsConfig } from '../threads/config.js';
14
14
  import { metaTokenFreshness } from '../lib/meta-auth.js';
15
+ import { readPackageRootText } from '../lib/package-root.js';
15
16
  import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
16
17
  import { syncRemoteCredentials } from '../remote-sync.js';
17
18
  import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
@@ -68,7 +69,7 @@ const MANIFEST_FIX = {
68
69
  };
69
70
  // 두 MCP 가 모두 'mimi-seed' 로 등록되는 환경에서 에이전트가 프로그램적으로
70
71
  // 어느 서버인지 판별할 수 있도록 status 첫 줄에 자기소개를 넣는다.
71
- const { version: PKG_VERSION } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
72
+ const { version: PKG_VERSION } = JSON.parse(readPackageRootText('package.json'));
72
73
  /** 서비스별 식별자를 한 줄 detail 로 (예: "ads-coffee / analytics_530080532"). */
73
74
  function manifestServiceDetail(id, svc) {
74
75
  const parts = [];
@@ -249,8 +250,15 @@ export function registerAuthTools(server) {
249
250
  'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
250
251
  '응답에 포함된 URL을 브라우저에서 열고 승인하면 localhost:9876으로 자동 콜백 → 토큰이 ~/.mimi-seed/tokens.json에 저장됨.',
251
252
  '이후 playstore_*, firebase_*, admob_* 등 다른 MCP 도구 바로 호출 가능.',
253
+ 'domains 로 필요한 권한 도메인만 골라 요청 가능 (미지정 시 전체). 재로그인 시 기존 부여 권한은 유지되고 새 권한만 추가됨(incremental).',
252
254
  '토큰 만료(invalid_rapt) / 재인증 필요 시 사용. 10분 내 완료해야 함.',
253
- ].join(' '), {}, async () => {
255
+ ].join(' '), {
256
+ domains: z
257
+ .array(z.enum(DOMAIN_IDS))
258
+ .optional()
259
+ .describe(`요청할 권한 도메인 서브셋 (미지정 시 전체). 가능한 값: ${DOMAIN_IDS.join(', ')}. ` +
260
+ '예: ["ga4","googleads"] — 기존에 부여된 다른 도메인 권한은 유지된다.'),
261
+ }, async ({ domains }) => {
254
262
  // 설정 조회 실패를 분류된 안내로 — raw throw 는 MCP 클라이언트에 마커 문자열만 노출된다.
255
263
  let clientId;
256
264
  let clientSecret;
@@ -266,9 +274,12 @@ export function registerAuthTools(server) {
266
274
  }],
267
275
  };
268
276
  }
269
- const { url, wait } = startAuth(clientId, clientSecret);
277
+ const { url, wait } = startAuth(clientId, clientSecret, { domains });
270
278
  // fire-and-forget — 토큰은 콜백 서버가 자동 저장
271
279
  wait.then(() => { }, (err) => { console.error('[mimi-seed auth]', err.message); });
280
+ const scopeLine = domains?.length
281
+ ? `요청 도메인: ${domains.map((d) => `${d} (${AUTH_DOMAINS[d].label})`).join(', ')} — 기존 부여 권한은 유지됨.`
282
+ : `요청 도메인: 전체 (${DOMAIN_IDS.join(', ')})`;
272
283
  return {
273
284
  content: [{
274
285
  type: 'text',
@@ -277,6 +288,8 @@ export function registerAuthTools(server) {
277
288
  '',
278
289
  url,
279
290
  '',
291
+ scopeLine,
292
+ '',
280
293
  '이 URL을 브라우저에서 열고 Google 계정으로 승인해줘.',
281
294
  '완료되면 localhost:9876으로 자동 리다이렉트되고 토큰이 저장돼.',
282
295
  '이후 바로 다른 MCP 도구(playstore_*, firebase_* 등) 호출 가능.',
@@ -289,6 +302,23 @@ export function registerAuthTools(server) {
289
302
  const refreshHint = formatLastRefreshHint(getTokensLastRefreshMs());
290
303
  const refreshLine = ` 마지막 갱신: ${refreshHint.label}`;
291
304
  const recommendation = refreshHint.recommendation ? `\n\n${refreshHint.recommendation}` : '';
305
+ // 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께
306
+ // 보여준다. 유효/갱신된 상태에서만 쓰므로 그 두 arm 에서만 계산한다(불필요한 토큰
307
+ // 재조회 + 도메인 스캔 방지).
308
+ const domainStatusBlock = () => {
309
+ const summary = summarizeGrantedDomains(getStoredTokens()?.scope);
310
+ const domainLines = [];
311
+ if (!summary.known) {
312
+ domainLines.push(' 권한 도메인: (구 토큰 — scope 기록 없음. 재로그인하면 기록됨)');
313
+ }
314
+ else {
315
+ domainLines.push(` 권한 도메인: ${summary.granted.join(', ') || '(없음)'}`);
316
+ if (summary.missing.length > 0) {
317
+ domainLines.push(` 미부여: ${summary.missing.join(', ')} → mimi_seed_auth_start(domains=[...]) 로 추가 (기존 권한 유지)`);
318
+ }
319
+ }
320
+ return `\n${domainLines.join('\n')}`;
321
+ };
292
322
  switch (r.status) {
293
323
  case 'unauthenticated':
294
324
  return {
@@ -304,7 +334,7 @@ export function registerAuthTools(server) {
304
334
  return {
305
335
  content: [{
306
336
  type: 'text',
307
- text: `✅ 인증 유효 (${min}분 남음).\n${refreshLine}${recommendation}`,
337
+ text: `✅ 인증 유효 (${min}분 남음).\n${refreshLine}${domainStatusBlock()}${recommendation}`,
308
338
  }],
309
339
  };
310
340
  }
@@ -313,7 +343,7 @@ export function registerAuthTools(server) {
313
343
  return {
314
344
  content: [{
315
345
  type: 'text',
316
- text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).\n${refreshLine}${recommendation}`,
346
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).\n${refreshLine}${domainStatusBlock()}${recommendation}`,
317
347
  }],
318
348
  };
319
349
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import * as firebaseRaw from '../firebase/tools.js';
3
3
  import { requireAuth } from '../helpers.js';
4
+ import { CLOUD_PLATFORM_SCOPE } from '../auth/scopes.js';
4
5
  import { friendlyGoogleError } from '../lib/google-errors.js';
5
6
  // 모든 firebase tools 호출을 친절 에러로 감싸는 프록시 — 17개 핸들러에 개별
6
7
  // try/catch 없이 raw GaxiosError(API 미활성화/프로젝트 없음/billing/권한)를
@@ -53,7 +54,8 @@ export function registerFirebaseTools(server) {
53
54
  .optional()
54
55
  .describe("조직/폴더 소속이 필요한 계정일 때만: 'organizations/<id>' 또는 'folders/<id>'. 생략 시 계정 기본 정책대로 생성"),
55
56
  }, async ({ projectId, displayName, parent }) => {
56
- const auth = await requireAuth();
57
+ // 프로젝트 생성은 Cloud Resource Manager 라 firebase 스코프만으로는 안 된다.
58
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
57
59
  const project = await firebase.createProject(auth, projectId, displayName, { parent });
58
60
  return {
59
61
  content: [
@@ -167,17 +169,18 @@ export function registerFirebaseTools(server) {
167
169
  projectId: z.string().describe('프로젝트 ID'),
168
170
  serviceId: z.string().describe('서비스 ID (예: firestore.googleapis.com)'),
169
171
  }, async ({ projectId, serviceId }) => {
170
- const auth = await requireAuth();
172
+ // Service Usage API — firebase 스코프가 아니라 cloud-platform 을 요구한다.
173
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
171
174
  const result = await firebase.enableService(auth, projectId, serviceId);
172
175
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
173
176
  });
174
177
  server.tool('firebase_enable_common_services', 'Firebase 기본 서비스 일괄 활성화 (Firestore, Auth, Storage, FCM 등)', { projectId: z.string().describe('프로젝트 ID') }, async ({ projectId }) => {
175
- const auth = await requireAuth();
178
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
176
179
  const results = await firebase.enableCommonServices(auth, projectId);
177
180
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
178
181
  });
179
182
  server.tool('firebase_list_enabled_services', '프로젝트에서 활성화된 GCP 서비스 목록', { projectId: z.string().describe('프로젝트 ID') }, async ({ projectId }) => {
180
- const auth = await requireAuth();
183
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
181
184
  const services = await firebase.listEnabledServices(auth, projectId);
182
185
  return { content: [{ type: 'text', text: JSON.stringify(services, null, 2) }] };
183
186
  });
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import * as iam from '../iam/tools.js';
3
3
  import { requireAuth } from '../helpers.js';
4
+ import { CLOUD_PLATFORM_SCOPE } from '../auth/scopes.js';
4
5
  export function registerIamTools(server) {
5
6
  server.tool('iam_list_service_accounts', '주어진 projectId의 서비스 계정 목록. 이메일 / displayName / disabled 상태 반환.', {
6
7
  projectId: z.string().describe('Google Cloud 프로젝트 ID'),
7
8
  }, async ({ projectId }) => {
8
- const auth = await requireAuth();
9
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
9
10
  const accounts = await iam.listServiceAccounts(auth, projectId);
10
11
  return { content: [{ type: 'text', text: JSON.stringify(accounts, null, 2) }] };
11
12
  });
@@ -17,7 +18,7 @@ export function registerIamTools(server) {
17
18
  .describe('서비스 계정 ID (이메일 로컬 파트)'),
18
19
  displayName: z.string().describe('사람이 읽을 표시 이름 (예: "onesub Play verifier")'),
19
20
  }, async ({ projectId, accountId, displayName }) => {
20
- const auth = await requireAuth();
21
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
21
22
  const account = await iam.createServiceAccount(auth, projectId, accountId, displayName);
22
23
  return {
23
24
  content: [
@@ -41,7 +42,7 @@ export function registerIamTools(server) {
41
42
  server.tool('iam_list_keys', '주어진 서비스 계정의 기존 키 목록. keyId / keyType / 만료시간 반환. 회수할 키 찾을 때 사용.', {
42
43
  serviceAccount: z.string().describe('서비스 계정 이메일'),
43
44
  }, async ({ serviceAccount }) => {
44
- const auth = await requireAuth();
45
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
45
46
  const keys = await iam.listServiceAccountKeys(auth, serviceAccount);
46
47
  return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
47
48
  });
@@ -51,7 +52,7 @@ export function registerIamTools(server) {
51
52
  ].join(' '), {
52
53
  serviceAccount: z.string().describe('서비스 계정 이메일'),
53
54
  }, async ({ serviceAccount }) => {
54
- const auth = await requireAuth();
55
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
55
56
  const key = await iam.createServiceAccountKey(auth, serviceAccount);
56
57
  return {
57
58
  content: [
@@ -89,7 +90,7 @@ export function registerIamTools(server) {
89
90
  .describe('권한 받을 주체 (예: serviceAccount:my-sa@my-project.iam.gserviceaccount.com)'),
90
91
  role: z.string().describe('IAM 역할 (예: roles/iam.serviceAccountTokenCreator)'),
91
92
  }, async ({ projectId, member, role }) => {
92
- const auth = await requireAuth();
93
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
93
94
  const result = await iam.addProjectIamPolicyBinding(auth, projectId, member, role);
94
95
  return {
95
96
  content: [
@@ -59,7 +59,7 @@ export function registerPlaystoreTools(server) {
59
59
  const listing = await playstore.getListing(auth, packageName, language);
60
60
  return { content: [{ type: 'text', text: JSON.stringify(listing, null, 2) }] };
61
61
  });
62
- server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정 (제목, 설명문 변경). 넘긴 필드만 부분 갱신(edits.listings.patch) 생략한 필드와 프로모 영상(video) 보존된다. ⚠️ Play API 편집과 Console 동시 편집은 충돌 — Console에서 같은 리스팅을 편집·미게시 중이면 그 변경이 덮어써질 수 있음.', {
62
+ server.tool('playstore_update_listing', 'Google Play 스토어 리스팅 수정 또는 언어 생성. 기존 언어는 넘긴 필드만 부분 갱신(edits.listings.patch) 생략한 필드와 프로모 영상(video) 보존한다. 새 언어는 title·shortDescription·fullDescription을 모두 전달해야 하며 전체 리스팅을 생성한다. ⚠️ Play API 편집과 Console 동시 편집은 충돌 — Console에서 같은 리스팅을 편집·미게시 중이면 그 변경이 덮어써질 수 있음.', {
63
63
  packageName: z.string().describe('패키지명'),
64
64
  language: z.string().describe('언어 코드 (예: ko-KR, en-US)'),
65
65
  title: z.string().optional().describe('앱 제목 (30자 이내)'),
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerVideoTools(server: McpServer): void;
@@ -0,0 +1,165 @@
1
+ import { z } from 'zod';
2
+ import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, } from '../video/project.js';
3
+ import { downloadStockAssets, generateImage, researchYouTube, searchStock, } from '../video/providers.js';
4
+ import { getRenderJob, startRender, validateVideo } from '../video/render.js';
5
+ import { synthesizeResearch } from '../video/research.js';
6
+ function text(value) {
7
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
8
+ }
9
+ const absolutePath = z.string().min(1).describe('절대경로. 상대경로는 허용하지 않습니다.');
10
+ const httpUrl = z.string().url().refine((value) => {
11
+ const protocol = new URL(value).protocol;
12
+ return protocol === 'https:' || protocol === 'http:';
13
+ }, 'HTTP(S) URL만 허용합니다.');
14
+ export function registerVideoTools(server) {
15
+ server.tool('video_plan_from_story', [
16
+ '정리된 story를 장면별 스토리보드로 변환하고 로컬 영상 프로젝트를 만듭니다.',
17
+ 'ANTHROPIC_API_KEY가 필요합니다. projectDir에는 project.json과 자산/리서치/렌더 폴더가 생성됩니다.',
18
+ '기존 project.json은 overwrite=true 없이는 덮어쓰지 않습니다.',
19
+ ].join(' '), {
20
+ projectDir: absolutePath,
21
+ title: z.string().min(1).max(500).describe('영상 제목'),
22
+ story: z.string().min(1).max(200_000).describe('영상으로 만들 story 원문'),
23
+ language: z.string().default('ko').describe('내레이션과 화면 문구 언어'),
24
+ aspectRatio: z.enum(['9:16', '16:9', '1:1']).default('9:16').describe('출력 화면비'),
25
+ targetDurationSec: z.number().min(5).max(300).default(30).describe('목표 영상 길이(초)'),
26
+ style: z.string().max(10_000).optional().describe('시각 스타일과 톤'),
27
+ overwrite: z.boolean().default(false).describe('기존 메타데이터를 .history에 보관하고 새 프로젝트로 덮어쓸지 여부'),
28
+ }, async (input) => text(await planStory(input)));
29
+ server.tool('video_research_youtube', [
30
+ '스토리보드 검색어나 지정 검색어로 YouTube 유사 영상을 조사합니다.',
31
+ 'YOUTUBE_API_KEY가 필요하며 결과는 research/youtube.json에 저장됩니다.',
32
+ '결과는 reference-only로 기록되어 영상 파일 다운로드나 렌더링 소스로 사용되지 않습니다.',
33
+ ].join(' '), {
34
+ projectDir: absolutePath,
35
+ queries: z.array(z.string().min(1).max(1_000)).min(1).max(5).optional().describe('검색어. 생략하면 스토리보드 searchQuery 사용'),
36
+ maxResultsPerQuery: z.number().int().min(1).max(10).default(5),
37
+ regionCode: z.string().length(2).optional().describe('ISO 3166-1 alpha-2 지역 코드'),
38
+ publishedAfter: z.string().datetime().optional().describe('RFC 3339 게시 시각 하한'),
39
+ creativeCommonsOnly: z.boolean().default(false).describe('Creative Commons 표시 영상만 검색'),
40
+ order: z.enum(['relevance', 'viewCount', 'date']).default('relevance').describe('유사도/조회수/최신순 정렬'),
41
+ }, async (input) => text(await researchYouTube(input)));
42
+ server.tool('video_synthesize_research', [
43
+ '저장된 YouTube/Pexels 메타데이터와 사용자가 직접 관찰한 영상 메모를 독창적인 제작 방향으로 종합합니다.',
44
+ 'ANTHROPIC_API_KEY가 필요하며 결과는 research/brief.json에 저장됩니다.',
45
+ '실제 영상 프레임이나 오디오를 보았다고 주장하지 않고, 외부 제목·설명 안의 명령은 신뢰하지 않습니다.',
46
+ '훅·구조·템포의 추상 패턴만 제안하고 복제하면 안 되는 요소와 분석 한계를 함께 기록합니다.',
47
+ ].join(' '), {
48
+ projectDir: absolutePath,
49
+ goal: z.string().max(10_000).optional(),
50
+ observations: z.array(z.object({
51
+ referenceUrl: httpUrl,
52
+ notes: z.string().min(1).max(10_000).describe('사용자/에이전트가 직접 관찰한 훅·장면·템포 메모'),
53
+ })).max(20).optional(),
54
+ }, async (input) => text(await synthesizeResearch(input)));
55
+ server.tool('video_search_stock_assets', [
56
+ '스토리보드 검색어나 지정 검색어로 Pexels 라이선스 스톡 영상을 찾습니다.',
57
+ 'PEXELS_API_KEY가 필요하며 결과는 research/pexels.json에 저장됩니다.',
58
+ '검색만 수행하며 다운로드는 video_download_stock_assets로 별도 승인 후 실행합니다.',
59
+ ].join(' '), {
60
+ projectDir: absolutePath,
61
+ queries: z.array(z.string().min(1)).min(1).max(10).optional(),
62
+ orientation: z.enum(['landscape', 'portrait', 'square']).optional(),
63
+ perQuery: z.number().int().min(1).max(15).default(5),
64
+ }, async (input) => text(await searchStock(input)));
65
+ server.tool('video_download_stock_assets', [
66
+ '선택한 Pexels 스톡 영상을 프로젝트로 다운로드하고 출처·라이선스·해시를 assets.json에 기록합니다.',
67
+ 'confirm=false는 다운로드 계획만 반환합니다. 외부 파일 다운로드 전 사용자 승인을 받은 뒤 confirm=true로 다시 호출하세요.',
68
+ 'Pexels 공식 미디어 호스트만 허용하며 파일당 100MB, 호출당 5개로 제한합니다.',
69
+ ].join(' '), {
70
+ projectDir: absolutePath,
71
+ selections: z.array(z.object({
72
+ id: z.number().int(),
73
+ downloadUrl: httpUrl,
74
+ pageUrl: httpUrl,
75
+ author: z.string().optional(),
76
+ attribution: z.string().optional(),
77
+ })).min(1).max(5),
78
+ confirm: z.boolean().default(false),
79
+ }, async ({ projectDir, selections, confirm }) => {
80
+ loadProject(projectDir);
81
+ if (!confirm)
82
+ return text({ confirmed: false, action: 'download', count: selections.length, selections });
83
+ return text(await downloadStockAssets(projectDir, selections));
84
+ });
85
+ server.tool('video_generate_image', [
86
+ 'OpenAI Image API로 장면 이미지를 생성하고 프로젝트 자산으로 등록합니다.',
87
+ 'OPENAI_API_KEY가 필요합니다. 기본 모델은 gpt-image-2이며 이미지 바이트는 도구 응답에 넣지 않고 로컬 절대경로만 반환합니다.',
88
+ 'confirm=false는 생성 계획만 반환합니다. 비용이 발생하므로 사용자 승인 후 confirm=true로 호출하세요.',
89
+ ].join(' '), {
90
+ projectDir: absolutePath,
91
+ prompt: z.string().min(1),
92
+ name: z.string().optional().describe('출력 파일명 힌트(확장자 제외)'),
93
+ model: z.enum(['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini']).default('gpt-image-2'),
94
+ quality: z.enum(['low', 'medium', 'high', 'auto']).default('medium'),
95
+ size: z.enum(['1024x1024', '1024x1536', '1536x1024']).optional(),
96
+ confirm: z.boolean().default(false),
97
+ }, async ({ confirm, ...input }) => {
98
+ loadProject(input.projectDir);
99
+ if (!confirm)
100
+ return text({ confirmed: false, action: 'generate_image', ...input });
101
+ return text(await generateImage(input));
102
+ });
103
+ server.tool('video_add_local_asset', [
104
+ '사용자 소유 또는 별도 라이선스를 확보한 로컬 이미지·영상·오디오를 프로젝트에 복사해 등록합니다.',
105
+ 'filePath는 절대경로여야 하며 license와 출처 정보가 assets.json에 기록됩니다.',
106
+ 'reference-only 자료는 이 도구로 등록하지 마세요.',
107
+ ].join(' '), {
108
+ projectDir: absolutePath,
109
+ filePath: absolutePath,
110
+ kind: z.enum(['image', 'video', 'audio']),
111
+ sourceType: z.enum(['user-owned', 'licensed']),
112
+ license: z.string().min(1).describe('소유권 또는 라이선스 근거'),
113
+ author: z.string().optional(),
114
+ attribution: z.string().optional(),
115
+ }, async (input) => text(addLocalAsset(input)));
116
+ server.tool('video_build_timeline', [
117
+ '등록된 이미지·영상 자산을 장면 순서와 길이에 맞춰 timeline.json으로 조합합니다.',
118
+ 'assets.json에서 allowedForRendering=true이고 reference-only가 아닌 자산만 허용합니다.',
119
+ '화면 문구는 최종 렌더에서 자막으로 번인됩니다.',
120
+ ].join(' '), {
121
+ projectDir: absolutePath,
122
+ scenes: z.array(z.object({
123
+ id: z.string().min(1),
124
+ assetId: z.string().min(1),
125
+ durationSec: z.number().positive().max(300),
126
+ onScreenText: z.string().optional(),
127
+ narration: z.string().optional(),
128
+ })).min(1).max(30),
129
+ audioAssetId: z.string().optional(),
130
+ }, async ({ projectDir, scenes, audioAssetId }) => text(buildTimeline(projectDir, scenes, audioAssetId)));
131
+ server.tool('video_render', [
132
+ 'timeline.json과 등록 자산을 FFmpeg로 합성해 MP4 렌더 작업을 시작합니다.',
133
+ '이미지/영상 크롭, 장면 연결, 화면 문구 번인, 선택적 배경음, H.264/yuv420p 출력을 지원합니다.',
134
+ 'confirm=false는 계획만 반환합니다. CPU를 사용하는 로컬 쓰기 작업이므로 승인 후 confirm=true로 호출하세요.',
135
+ '즉시 jobId를 반환하므로 video_job_status로 완료 여부를 확인하세요.',
136
+ ].join(' '), {
137
+ projectDir: absolutePath,
138
+ outputFileName: z.string().default('output.mp4'),
139
+ ffmpegPath: z.string().optional().describe('FFmpeg 실행 파일 경로. 생략하면 MIMI_SEED_FFMPEG_PATH 또는 PATH 사용'),
140
+ overwriteOutput: z.boolean().default(false).describe('기존 출력 MP4 덮어쓰기 여부'),
141
+ confirm: z.boolean().default(false),
142
+ }, async ({ confirm, ...input }) => {
143
+ const project = loadProject(input.projectDir);
144
+ const timeline = loadTimeline(input.projectDir);
145
+ if (!confirm) {
146
+ return text({
147
+ confirmed: false,
148
+ action: 'render',
149
+ title: project.title,
150
+ scenes: timeline.scenes.length,
151
+ durationSec: timeline.totalDurationSec,
152
+ outputFileName: input.outputFileName,
153
+ });
154
+ }
155
+ return text(await startRender(input));
156
+ });
157
+ server.tool('video_job_status', 'video_render가 시작한 비동기 FFmpeg 작업의 상태와 결과 절대경로를 조회합니다. 실패한 경우 로그 마지막 부분을 함께 반환합니다.', {
158
+ projectDir: absolutePath,
159
+ jobId: z.string().uuid(),
160
+ }, async ({ projectDir, jobId }) => text(getRenderJob(projectDir, jobId)));
161
+ server.tool('video_validate', 'ffprobe로 완성된 영상의 길이·해상도·코덱·픽셀 포맷을 검사합니다. H.264와 yuv420p가 아니면 이슈로 표시합니다.', {
162
+ filePath: absolutePath,
163
+ ffmpegPath: z.string().optional().describe('FFmpeg 절대경로를 주면 같은 폴더의 ffprobe를 사용'),
164
+ }, async ({ filePath, ffmpegPath }) => text(await validateVideo(filePath, ffmpegPath)));
165
+ }
@@ -1,7 +1,2 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- /** tool-manifest.json 의 형태. 테스트들도 이 타입을 import 해 캐스트 드리프트를 막는다. */
3
- export type ToolManifest = {
4
- total: number;
5
- domains: Record<string, string[]>;
6
- };
7
2
  export declare function registerResources(server: McpServer): void;