@yoonion/mimi-seed-mcp 0.3.26 → 0.3.28

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.
@@ -0,0 +1,221 @@
1
+ const API_VERSION = 'v17';
2
+ const BASE = `https://googleads.googleapis.com/${API_VERSION}`;
3
+ // ─── 내부 헬퍼 ───────────────────────────────────────────────
4
+ async function getAccessToken(auth) {
5
+ const token = await auth.getAccessToken();
6
+ if (!token.token)
7
+ throw new Error('OAuth 토큰을 가져올 수 없음. 재인증 필요.');
8
+ return token.token;
9
+ }
10
+ function buildHeaders(accessToken, cfg) {
11
+ const headers = {
12
+ Authorization: `Bearer ${accessToken}`,
13
+ 'developer-token': cfg.developerToken,
14
+ 'Content-Type': 'application/json',
15
+ };
16
+ if (cfg.loginCustomerId) {
17
+ headers['login-customer-id'] = cfg.loginCustomerId;
18
+ }
19
+ return headers;
20
+ }
21
+ /** search 엔드포인트 (paged JSON, searchStream보다 안정적) */
22
+ async function search(auth, cfg, query) {
23
+ const accessToken = await getAccessToken(auth);
24
+ const url = `${BASE}/customers/${cfg.customerId}/googleAds:search`;
25
+ const all = [];
26
+ let pageToken;
27
+ do {
28
+ const body = { query, pageSize: 1000 };
29
+ if (pageToken)
30
+ body.pageToken = pageToken;
31
+ const res = await fetch(url, {
32
+ method: 'POST',
33
+ headers: buildHeaders(accessToken, cfg),
34
+ body: JSON.stringify(body),
35
+ });
36
+ const text = await res.text();
37
+ if (!res.ok) {
38
+ let msg = `Google Ads API ${res.status}`;
39
+ try {
40
+ const err = JSON.parse(text);
41
+ const detail = err?.error?.message ?? err?.[0]?.error?.message ?? text;
42
+ msg += `: ${detail}`;
43
+ }
44
+ catch {
45
+ msg += `: ${text.slice(0, 300)}`;
46
+ }
47
+ throw new Error(msg);
48
+ }
49
+ const json = JSON.parse(text);
50
+ all.push(...(json.results ?? []));
51
+ pageToken = json.nextPageToken ?? undefined;
52
+ } while (pageToken);
53
+ return all;
54
+ }
55
+ function microsToCurrency(micros) {
56
+ if (micros == null)
57
+ return 0;
58
+ return Number(micros) / 1_000_000;
59
+ }
60
+ // ─── 접근 가능한 고객 목록 (API 연결 확인용) ─────────────────────
61
+ export async function listAccessibleCustomers(auth, cfg) {
62
+ const accessToken = await getAccessToken(auth);
63
+ const url = `${BASE}/customers:listAccessibleCustomers`;
64
+ const res = await fetch(url, {
65
+ method: 'GET',
66
+ headers: buildHeaders(accessToken, cfg),
67
+ });
68
+ const text = await res.text();
69
+ if (!res.ok) {
70
+ let msg = `Google Ads API ${res.status}`;
71
+ try {
72
+ const err = JSON.parse(text);
73
+ msg += `: ${err?.error?.message ?? text.slice(0, 300)}`;
74
+ }
75
+ catch {
76
+ msg += `: ${text.slice(0, 300)}`;
77
+ }
78
+ throw new Error(msg);
79
+ }
80
+ return JSON.parse(text);
81
+ }
82
+ // ─── 캠페인 목록 ─────────────────────────────────────────────
83
+ export async function listCampaigns(auth, cfg) {
84
+ const query = `
85
+ SELECT
86
+ campaign.id,
87
+ campaign.name,
88
+ campaign.status,
89
+ campaign.advertising_channel_type,
90
+ campaign.advertising_channel_sub_type,
91
+ campaign.start_date,
92
+ campaign.end_date,
93
+ campaign_budget.amount_micros
94
+ FROM campaign
95
+ WHERE campaign.status != 'REMOVED'
96
+ ORDER BY campaign.name ASC
97
+ LIMIT 200
98
+ `;
99
+ const rows = await search(auth, cfg, query);
100
+ return rows.map((r) => ({
101
+ id: r.campaign?.id,
102
+ name: r.campaign?.name,
103
+ status: r.campaign?.status,
104
+ channelType: r.campaign?.advertisingChannelType,
105
+ channelSubType: r.campaign?.advertisingChannelSubType,
106
+ startDate: r.campaign?.startDate,
107
+ endDate: r.campaign?.endDate,
108
+ dailyBudget: microsToCurrency(r.campaignBudget?.amountMicros),
109
+ }));
110
+ }
111
+ // ─── 캠페인 성과 리포트 ────────────────────────────────────────
112
+ export async function getCampaignReport(auth, cfg, range) {
113
+ const query = `
114
+ SELECT
115
+ campaign.id,
116
+ campaign.name,
117
+ campaign.status,
118
+ campaign.advertising_channel_type,
119
+ campaign.advertising_channel_sub_type,
120
+ metrics.clicks,
121
+ metrics.impressions,
122
+ metrics.cost_micros,
123
+ metrics.conversions,
124
+ metrics.conversions_value,
125
+ metrics.cost_per_conversion,
126
+ metrics.ctr,
127
+ metrics.average_cpc
128
+ FROM campaign
129
+ WHERE campaign.status != 'REMOVED'
130
+ AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
131
+ ORDER BY metrics.cost_micros DESC
132
+ LIMIT 500
133
+ `;
134
+ const rows = await search(auth, cfg, query);
135
+ return rows.map((r) => ({
136
+ id: r.campaign?.id,
137
+ name: r.campaign?.name,
138
+ status: r.campaign?.status,
139
+ channelType: r.campaign?.advertisingChannelType,
140
+ channelSubType: r.campaign?.advertisingChannelSubType,
141
+ clicks: Number(r.metrics?.clicks ?? 0),
142
+ impressions: Number(r.metrics?.impressions ?? 0),
143
+ cost: microsToCurrency(r.metrics?.costMicros),
144
+ conversions: Number(r.metrics?.conversions ?? 0),
145
+ conversionsValue: Number(r.metrics?.conversionsValue ?? 0),
146
+ cpi: microsToCurrency(r.metrics?.costPerConversion),
147
+ ctr: Number(r.metrics?.ctr ?? 0),
148
+ avgCpc: microsToCurrency(r.metrics?.averageCpc),
149
+ }));
150
+ }
151
+ // ─── UAC(앱 캠페인) 리포트 ─────────────────────────────────────
152
+ export async function getUacReport(auth, cfg, range) {
153
+ const query = `
154
+ SELECT
155
+ campaign.id,
156
+ campaign.name,
157
+ campaign.status,
158
+ campaign.advertising_channel_sub_type,
159
+ metrics.clicks,
160
+ metrics.impressions,
161
+ metrics.cost_micros,
162
+ metrics.conversions,
163
+ metrics.conversions_value,
164
+ metrics.cost_per_conversion,
165
+ metrics.ctr,
166
+ segments.date
167
+ FROM campaign
168
+ WHERE campaign.advertising_channel_type = 'MULTI_CHANNEL'
169
+ AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
170
+ AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
171
+ ORDER BY segments.date DESC, metrics.cost_micros DESC
172
+ LIMIT 500
173
+ `;
174
+ const rows = await search(auth, cfg, query);
175
+ const byId = new Map();
176
+ for (const r of rows) {
177
+ const id = String(r.campaign?.id ?? '');
178
+ const existing = byId.get(id);
179
+ const cost = microsToCurrency(r.metrics?.costMicros);
180
+ const installs = Number(r.metrics?.conversions ?? 0);
181
+ if (existing) {
182
+ existing.clicks += Number(r.metrics?.clicks ?? 0);
183
+ existing.impressions += Number(r.metrics?.impressions ?? 0);
184
+ existing.cost += cost;
185
+ existing.installs += installs;
186
+ existing.installsValue += Number(r.metrics?.conversionsValue ?? 0);
187
+ if (r.segments?.date)
188
+ existing.dates.push(r.segments.date);
189
+ }
190
+ else {
191
+ byId.set(id, {
192
+ id,
193
+ name: r.campaign?.name ?? '',
194
+ status: r.campaign?.status ?? '',
195
+ subType: r.campaign?.advertisingChannelSubType ?? '',
196
+ clicks: Number(r.metrics?.clicks ?? 0),
197
+ impressions: Number(r.metrics?.impressions ?? 0),
198
+ cost,
199
+ installs,
200
+ installsValue: Number(r.metrics?.conversionsValue ?? 0),
201
+ dates: r.segments?.date ? [r.segments.date] : [],
202
+ });
203
+ }
204
+ }
205
+ return Array.from(byId.values()).map((c) => ({
206
+ id: c.id,
207
+ name: c.name,
208
+ status: c.status,
209
+ subType: c.subType,
210
+ clicks: c.clicks,
211
+ impressions: c.impressions,
212
+ cost: c.cost,
213
+ installs: c.installs,
214
+ installsValue: c.installsValue,
215
+ cpi: c.installs > 0 ? c.cost / c.installs : 0,
216
+ dateRange: {
217
+ from: c.dates.length ? [...c.dates].sort()[0] : range.startDate,
218
+ to: c.dates.length ? [...c.dates].sort().reverse()[0] : range.endDate,
219
+ },
220
+ }));
221
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerAuthTools } from './registers/auth.js';
13
13
  import { registerCiTools } from './registers/ci.js';
14
14
  import { registerInstagramTools } from './registers/instagram.js';
15
15
  import { registerFacebookTools } from './registers/facebook.js';
16
+ import { registerGoogleAdsTools } from './registers/googleads.js';
16
17
  import { registerPrompts } from './prompts.js';
17
18
  import { registerResources } from './resources.js';
18
19
  const server = new McpServer({
@@ -31,6 +32,7 @@ registerAuthTools(server);
31
32
  registerCiTools(server);
32
33
  registerInstagramTools(server);
33
34
  registerFacebookTools(server);
35
+ registerGoogleAdsTools(server);
34
36
  registerPrompts(server);
35
37
  registerResources(server);
36
38
  // `npx -y @yoonion/mimi-seed-mcp <subcommand>` 처리.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 릴리스 노트 / What's New 텍스트 사전 검증.
3
+ *
4
+ * 스토어 측 거부 패턴(HTML 마크업, 길이 초과, 백슬래시 가격 표기 등)을
5
+ * API 호출 전에 잡아 round-trip 낭비와 무지한 재시도를 차단.
6
+ *
7
+ * 모든 detection 은 정규식 기반 단순 휴리스틱 — false positive 최소화를
8
+ * 위해 실측으로 거부된 패턴만 포함한다.
9
+ */
10
+ export type ValidationCode = "HTML_TAG" | "LENGTH_EXCEEDED" | "BACKSLASH_PRICE";
11
+ export interface ValidationIssue {
12
+ code: ValidationCode;
13
+ message: string;
14
+ /** 0-based 문자 인덱스. LENGTH_EXCEEDED 에는 없음. */
15
+ position?: number;
16
+ /** 문제 구간 ±10자 잘라낸 문자열 (HTML/BACKSLASH 위치 표시용). */
17
+ excerpt?: string;
18
+ }
19
+ export interface ValidationResult {
20
+ ok: boolean;
21
+ issues: ValidationIssue[];
22
+ }
23
+ /** App Store What's New 검증 — 길이(≤4000) + HTML 태그 거부. */
24
+ export declare function validateAppStoreWhatsNew(text: string): ValidationResult;
25
+ /** Play release notes 검증 — 길이(≤500) + HTML + 백슬래시 가격. */
26
+ export declare function validatePlayReleaseNotes(text: string): ValidationResult;
27
+ /** 사용자 친화 메시지로 포맷. MCP 도구가 거부 응답에 그대로 노출. */
28
+ export declare function formatIssuesForUser(issues: ValidationIssue[]): string;
29
+ export declare const __testing: {
30
+ MAX_APPSTORE_WHATSNEW: number;
31
+ MAX_PLAY_RELEASE_NOTES: number;
32
+ HTML_TAG_PATTERN: RegExp;
33
+ BACKSLASH_PRICE_PATTERN: RegExp;
34
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 릴리스 노트 / What's New 텍스트 사전 검증.
3
+ *
4
+ * 스토어 측 거부 패턴(HTML 마크업, 길이 초과, 백슬래시 가격 표기 등)을
5
+ * API 호출 전에 잡아 round-trip 낭비와 무지한 재시도를 차단.
6
+ *
7
+ * 모든 detection 은 정규식 기반 단순 휴리스틱 — false positive 최소화를
8
+ * 위해 실측으로 거부된 패턴만 포함한다.
9
+ */
10
+ // ── 한계값 (스토어 정책 SSOT) ─────────────────────────────────────
11
+ const MAX_APPSTORE_WHATSNEW = 4000;
12
+ const MAX_PLAY_RELEASE_NOTES = 500;
13
+ // ── 패턴 ──────────────────────────────────────────────────────────
14
+ // HTML 태그: <br>, </tag>, <a href="…"> 모두 매칭. Apple/Play 둘 다 마크업 거부.
15
+ const HTML_TAG_PATTERN = /<\/?[a-zA-Z][^>]*>/g;
16
+ // Play Store 가 홍보 정책으로 거부한 실측 사례: 본문에 '\5000원' 같이
17
+ // 역슬래시 + 숫자 + (선택적 통화 단위) 패턴 (memory: reference_appstore_whatsnew_blacklist).
18
+ const BACKSLASH_PRICE_PATTERN = /\\\d[\d,]*(?:원|won|krw)?/gi;
19
+ // ── 헬퍼 ──────────────────────────────────────────────────────────
20
+ function makeExcerpt(text, pos, radius = 10) {
21
+ const start = Math.max(0, pos - radius);
22
+ const end = Math.min(text.length, pos + radius);
23
+ const left = start > 0 ? "…" : "";
24
+ const right = end < text.length ? "…" : "";
25
+ return `${left}${text.slice(start, end)}${right}`;
26
+ }
27
+ function lintHtmlTags(text) {
28
+ const issues = [];
29
+ HTML_TAG_PATTERN.lastIndex = 0;
30
+ let m;
31
+ while ((m = HTML_TAG_PATTERN.exec(text)) !== null) {
32
+ issues.push({
33
+ code: "HTML_TAG",
34
+ message: `HTML 태그 '${m[0]}' 가 포함됐어요 — App Store / Play 모두 마크업 거부`,
35
+ position: m.index,
36
+ excerpt: makeExcerpt(text, m.index),
37
+ });
38
+ // 무한루프 방어 (제로폭 매치는 사실상 발생 안 하지만 안전망)
39
+ if (m.index === HTML_TAG_PATTERN.lastIndex)
40
+ HTML_TAG_PATTERN.lastIndex++;
41
+ }
42
+ return issues;
43
+ }
44
+ function lintLength(text, limit, label) {
45
+ if (text.length <= limit)
46
+ return [];
47
+ return [
48
+ {
49
+ code: "LENGTH_EXCEEDED",
50
+ message: `${label} 길이 ${text.length}자 — ${limit}자 이하로 줄여주세요`,
51
+ },
52
+ ];
53
+ }
54
+ function lintBackslashPrice(text) {
55
+ const issues = [];
56
+ BACKSLASH_PRICE_PATTERN.lastIndex = 0;
57
+ let m;
58
+ while ((m = BACKSLASH_PRICE_PATTERN.exec(text)) !== null) {
59
+ issues.push({
60
+ code: "BACKSLASH_PRICE",
61
+ message: `'${m[0]}' 처럼 역슬래시 + 숫자(가격) 표기는 Play Store 가 홍보 정책으로 거부할 수 있어요`,
62
+ position: m.index,
63
+ excerpt: makeExcerpt(text, m.index),
64
+ });
65
+ if (m.index === BACKSLASH_PRICE_PATTERN.lastIndex)
66
+ BACKSLASH_PRICE_PATTERN.lastIndex++;
67
+ }
68
+ return issues;
69
+ }
70
+ // ── public API ────────────────────────────────────────────────────
71
+ /** App Store What's New 검증 — 길이(≤4000) + HTML 태그 거부. */
72
+ export function validateAppStoreWhatsNew(text) {
73
+ const issues = [
74
+ ...lintLength(text, MAX_APPSTORE_WHATSNEW, "App Store What's New"),
75
+ ...lintHtmlTags(text),
76
+ ];
77
+ return { ok: issues.length === 0, issues };
78
+ }
79
+ /** Play release notes 검증 — 길이(≤500) + HTML + 백슬래시 가격. */
80
+ export function validatePlayReleaseNotes(text) {
81
+ const issues = [
82
+ ...lintLength(text, MAX_PLAY_RELEASE_NOTES, "Play release notes"),
83
+ ...lintHtmlTags(text),
84
+ ...lintBackslashPrice(text),
85
+ ];
86
+ return { ok: issues.length === 0, issues };
87
+ }
88
+ /** 사용자 친화 메시지로 포맷. MCP 도구가 거부 응답에 그대로 노출. */
89
+ export function formatIssuesForUser(issues) {
90
+ if (issues.length === 0)
91
+ return "검증 통과";
92
+ return issues
93
+ .map((i) => {
94
+ const loc = i.position !== undefined ? ` (pos ${i.position})` : "";
95
+ const ex = i.excerpt ? ` — 근처: ${JSON.stringify(i.excerpt)}` : "";
96
+ return `[${i.code}] ${i.message}${loc}${ex}`;
97
+ })
98
+ .join("\n");
99
+ }
100
+ // 테스트용 export (한계값 단언)
101
+ export const __testing = {
102
+ MAX_APPSTORE_WHATSNEW,
103
+ MAX_PLAY_RELEASE_NOTES,
104
+ HTML_TAG_PATTERN,
105
+ BACKSLASH_PRICE_PATTERN,
106
+ };
@@ -8,7 +8,22 @@ export type PlayImageType = 'featureGraphic' | 'icon' | 'phoneScreenshots' | 'pr
8
8
  * 여기서는 기존 앱의 메타데이터, 빌드, 출시를 관리.
9
9
  */
10
10
  export declare const publisher: () => import("googleapis").androidpublisher_v3.Androidpublisher;
11
+ export type PlayVitalsMetricSet = 'anrRate' | 'crashRate' | 'errorCount';
12
+ export interface PlayStatisticsQuery {
13
+ metricSet?: PlayVitalsMetricSet;
14
+ startDate: string;
15
+ endDate: string;
16
+ aggregationPeriod?: 'DAILY' | 'HOURLY';
17
+ dimensions?: string[];
18
+ metrics?: string[];
19
+ filter?: string;
20
+ pageSize?: number;
21
+ pageToken?: string;
22
+ userCohort?: 'OS_PUBLIC' | 'APP_TESTERS' | 'OS_BETA';
23
+ timeZone?: string;
24
+ }
11
25
  export declare function withEdit<T>(auth: OAuth2Client | JWT, packageName: string, fn: (editId: string) => Promise<T>, commit?: boolean): Promise<T>;
26
+ export declare function getStatistics(auth: OAuth2Client | JWT, packageName: string, query: PlayStatisticsQuery): Promise<unknown>;
12
27
  export declare function getAppDetails(auth: OAuth2Client | JWT, packageName: string): Promise<import("googleapis").androidpublisher_v3.Schema$AppDetails>;
13
28
  export declare function getListing(auth: OAuth2Client | JWT, packageName: string, language?: string): Promise<import("googleapis").androidpublisher_v3.Schema$Listing>;
14
29
  export declare function updateListing(auth: OAuth2Client | JWT, packageName: string, language: string, data: {
@@ -18,6 +18,28 @@ function mimeTypeFor(filePath) {
18
18
  * 여기서는 기존 앱의 메타데이터, 빌드, 출시를 관리.
19
19
  */
20
20
  export const publisher = () => google.androidpublisher('v3');
21
+ const REPORTING_API_BASE = 'https://playdeveloperreporting.googleapis.com/v1beta1';
22
+ const METRIC_SET_RESOURCE = {
23
+ anrRate: 'anrRateMetricSet',
24
+ crashRate: 'crashRateMetricSet',
25
+ errorCount: 'errorCountMetricSet',
26
+ };
27
+ const DEFAULT_METRICS = {
28
+ anrRate: ['anrRate', 'userPerceivedAnrRate', 'distinctUsers'],
29
+ crashRate: ['crashRate', 'userPerceivedCrashRate', 'distinctUsers'],
30
+ errorCount: ['errorReportCount', 'distinctUsers'],
31
+ };
32
+ function dateToReportingDateTime(date, timeZone) {
33
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
34
+ if (!match)
35
+ throw new Error(`날짜는 YYYY-MM-DD 형식이어야 해: ${date}`);
36
+ return {
37
+ year: Number(match[1]),
38
+ month: Number(match[2]),
39
+ day: Number(match[3]),
40
+ timeZone: { id: timeZone },
41
+ };
42
+ }
21
43
  export async function withEdit(auth, packageName, fn, commit = false) {
22
44
  const res = await publisher().edits.insert({ auth, packageName });
23
45
  const editId = res.data.id;
@@ -36,6 +58,31 @@ export async function withEdit(auth, packageName, fn, commit = false) {
36
58
  }
37
59
  }
38
60
  }
61
+ // ─── Play Developer Reporting API / Android vitals ───
62
+ export async function getStatistics(auth, packageName, query) {
63
+ const metricSet = query.metricSet ?? 'anrRate';
64
+ const resource = METRIC_SET_RESOURCE[metricSet];
65
+ const timeZone = query.timeZone ?? 'America/Los_Angeles';
66
+ const url = `${REPORTING_API_BASE}/apps/${encodeURIComponent(packageName)}/${resource}:query`;
67
+ const res = await auth.request({
68
+ url,
69
+ method: 'POST',
70
+ data: {
71
+ timelineSpec: {
72
+ aggregationPeriod: query.aggregationPeriod ?? 'DAILY',
73
+ startTime: dateToReportingDateTime(query.startDate, timeZone),
74
+ endTime: dateToReportingDateTime(query.endDate, timeZone),
75
+ },
76
+ dimensions: query.dimensions ?? ['versionCode'],
77
+ metrics: query.metrics ?? DEFAULT_METRICS[metricSet],
78
+ ...(query.filter ? { filter: query.filter } : {}),
79
+ ...(query.pageSize ? { pageSize: query.pageSize } : {}),
80
+ ...(query.pageToken ? { pageToken: query.pageToken } : {}),
81
+ ...(query.userCohort ? { userCohort: query.userCohort } : {}),
82
+ },
83
+ });
84
+ return res.data;
85
+ }
39
86
  // ─── 앱 목록 ───
40
87
  export async function getAppDetails(auth, packageName) {
41
88
  return withEdit(auth, packageName, async (editId) => {
@@ -4,6 +4,7 @@ import * as appstoreScreenshots from '../appstore/screenshots.js';
4
4
  import { createAppleOneTimePurchase, createAppleSubscription, updateAppleProduct, deleteAppleProduct, listAppleProducts, } from '@onesub/providers';
5
5
  import { requireAppStoreCreds } from '../helpers.js';
6
6
  import { buildAppStoreReleasePlan } from '../checks/plan.js';
7
+ import { validateAppStoreWhatsNew, formatIssuesForUser } from '../lib/text-validators.js';
7
8
  export function registerAppstoreTools(server) {
8
9
  server.tool('appstore_list_apps', 'App Store Connect 앱 목록 조회', {}, async () => {
9
10
  const apps = await appstore.listApps();
@@ -80,6 +81,25 @@ export function registerAppstoreTools(server) {
80
81
  ],
81
82
  };
82
83
  });
84
+ server.tool('appstore_attach_latest_build', [
85
+ 'App Store 버전에 최신 VALID 빌드를 자동으로 attach — list_builds → 필터 → attach 의 3-step 을 1회로 단축.',
86
+ '내부: versionId 로 appId 역추적 → listBuilds 에서 processingState=VALID 만 필터 → buildNumber 숫자 최대값 선택 → attach.',
87
+ 'PROCESSING 중인 빌드를 실수로 attach 해서 심사 제출 시 깨지는 케이스를 차단.',
88
+ 'minBuildNumber 옵션으로 floor 지정 가능 (예: 1.4.x 빌드만 attach).',
89
+ ].join(' '), {
90
+ versionId: z.string().describe('App Store 버전 ID (appstore_create_version 또는 list_versions 결과)'),
91
+ minBuildNumber: z.number().int().optional().describe('attach 후보 최소 buildNumber (예: 186 — 이전 빌드 무시)'),
92
+ }, async ({ versionId, minBuildNumber }) => {
93
+ const result = await appstore.attachLatestValidBuild(versionId, { minBuildNumber });
94
+ return {
95
+ content: [
96
+ {
97
+ type: 'text',
98
+ text: `✅ 최신 VALID 빌드 #${result.buildNumber} (id=${result.attachedBuildId}) 가 버전 ${versionId} 에 연결됐어.\n\n${JSON.stringify(result, null, 2)}`,
99
+ },
100
+ ],
101
+ };
102
+ });
83
103
  server.tool('appstore_get_metadata', 'App Store 버전 메타데이터 (설명문, 키워드, What\'s New)', { versionId: z.string().describe('버전 ID') }, async ({ versionId }) => {
84
104
  const localizations = await appstore.getVersionLocalizations(versionId);
85
105
  return { content: [{ type: 'text', text: JSON.stringify(localizations, null, 2) }] };
@@ -97,6 +117,19 @@ export function registerAppstoreTools(server) {
97
117
  if (Object.keys(cleaned).length === 0) {
98
118
  throw new Error('수정할 필드를 하나 이상 지정해줘 (whatsNew, description, keywords, promotionalText, supportUrl, marketingUrl).');
99
119
  }
120
+ // whatsNew 가 포함될 때만 사전 lint — 다른 필드(description/keywords)는 별도 정책.
121
+ if (typeof cleaned.whatsNew === 'string') {
122
+ const validation = validateAppStoreWhatsNew(cleaned.whatsNew);
123
+ if (!validation.ok) {
124
+ return {
125
+ content: [{
126
+ type: 'text',
127
+ text: `❌ whatsNew 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
128
+ }],
129
+ isError: true,
130
+ };
131
+ }
132
+ }
100
133
  const result = await appstore.updateVersionLocalization(localizationId, cleaned);
101
134
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
102
135
  });
@@ -139,6 +172,17 @@ export function registerAppstoreTools(server) {
139
172
  locale: z.string().describe('로캘 (예: ko, en-US, ja)'),
140
173
  whatsNew: z.string().describe("'이 버전의 새로운 기능' 텍스트 (4000자 이내)"),
141
174
  }, async ({ versionId, locale, whatsNew }) => {
175
+ // ── 사전 lint — Apple 409 INVALID_CHARACTERS 등 round-trip 낭비 차단.
176
+ const validation = validateAppStoreWhatsNew(whatsNew);
177
+ if (!validation.ok) {
178
+ return {
179
+ content: [{
180
+ type: 'text',
181
+ text: `❌ What's New 사전 검증 실패 — API 호출 안 함\n\n${formatIssuesForUser(validation.issues)}\n\n수정 후 다시 호출해주세요.`,
182
+ }],
183
+ isError: true,
184
+ };
185
+ }
142
186
  const result = await appstore.updateVersionWhatsNew(versionId, locale, { whatsNew });
143
187
  return { content: [{ type: 'text', text: `✅ ${locale} 로캘의 What's New가 업데이트됐어.\n\n${JSON.stringify(result, null, 2)}` }] };
144
188
  });
@@ -431,11 +475,44 @@ export function registerAppstoreTools(server) {
431
475
  '내부 흐름: POST /reviewSubmissions(또는 CREATED 상태 재사용) → POST /reviewSubmissionItems(version attach) → PATCH submitted=true.',
432
476
  'appId와 platform은 versionId에서 자동 조회 — 별도 입력 불필요.',
433
477
  '⚠️ 비가역 작업: 제출 후엔 Apple 심사가 시작되며, 메타데이터/스크린샷/빌드를 더 못 바꿈 (REJECTED/METADATA_REJECTED 시 다시 편집 가능).',
478
+ '안전 가드: confirm 생략/false 시 dry-run preview 만 반환 (versionString·빌드·whatsNew 발췌). 실제 제출은 confirm: true 로 재호출.',
434
479
  '사전 조건: 버전이 PREPARE_FOR_SUBMISSION 또는 DEVELOPER_REJECTED 상태, 빌드 attached, 모든 필수 메타데이터 채워짐.',
435
480
  'appstore_check_submission_risks로 사전 점검 권장.',
436
481
  ].join(' '), {
437
482
  versionId: z.string().describe('App Store 버전 ID (appstore_list_versions 결과)'),
438
- }, async ({ versionId }) => {
483
+ confirm: z.boolean().optional().describe('true 명시 시에만 실제 심사 제출. 생략/false 면 dry-run preview 만 반환 (비가역 사고 차단).'),
484
+ }, async ({ versionId, confirm }) => {
485
+ if (!confirm) {
486
+ // ── dry-run preview — versionString·빌드·whatsNew 발췌를 사용자에게 보여주고 재호출 유도.
487
+ const preview = await appstore.buildSubmitForReviewPreview(versionId);
488
+ const lines = [];
489
+ lines.push('🛑 심사 제출 dry-run — 아직 실제 제출 안 함.');
490
+ lines.push('');
491
+ lines.push(` versionId : ${preview.versionId}`);
492
+ lines.push(` versionString: ${preview.versionString ?? '(조회 실패)'}`);
493
+ lines.push(` state : ${preview.state ?? '(조회 실패)'}`);
494
+ lines.push(` appId : ${preview.appId}`);
495
+ lines.push(` platform : ${preview.platform}`);
496
+ if (preview.attachedBuild) {
497
+ lines.push(` attachedBuild: #${preview.attachedBuild.buildNumber ?? '?'} (id=${preview.attachedBuild.id}, state=${preview.attachedBuild.processingState ?? '?'})`);
498
+ }
499
+ else {
500
+ lines.push(` attachedBuild: ⚠️ 미연결 — appstore_attach_latest_build 필요`);
501
+ }
502
+ if (preview.whatsNewByLocale.length === 0) {
503
+ lines.push(` whatsNew : ⚠️ 등록된 로컬라이제이션 없음`);
504
+ }
505
+ else {
506
+ lines.push(` whatsNew :`);
507
+ for (const wn of preview.whatsNewByLocale) {
508
+ lines.push(` [${wn.locale}] (${wn.length}자) "${wn.excerpt}"`);
509
+ }
510
+ }
511
+ lines.push('');
512
+ lines.push('실제 제출하려면 같은 versionId 로 `confirm: true` 옵션을 추가해 재호출하세요.');
513
+ lines.push('⚠️ 제출 후엔 cancel_review 가 큐 진입(WAITING_FOR_REVIEW) 시점에 막힐 수 있어요 (실측: 1.4.2→3, 1.4.5→6).');
514
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
515
+ }
439
516
  const result = await appstore.submitVersionForReview(versionId);
440
517
  return { content: [{ type: 'text', text: `✅ 버전 ${versionId} 심사 제출 완료 (state: ${result.state}). App Store Connect에서 진행 상태 확인 가능.\n\n${JSON.stringify(result, null, 2)}` }] };
441
518
  });
@@ -1,5 +1,31 @@
1
1
  import { getMcpOAuthClient } from '../auth/constants.js';
2
- import { startAuth, ensureFreshAccessToken } from '../auth/google-auth.js';
2
+ import { startAuth, ensureFreshAccessToken, getTokensLastRefreshMs } from '../auth/google-auth.js';
3
+ /**
4
+ * tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
5
+ * Google 정책: 7일 미사용 시 미인증 앱 refresh_token revoke 가능.
6
+ * 14일 초과 시 강한 권고, 7일~14일 부드러운 안내.
7
+ */
8
+ function formatLastRefreshHint(lastMs) {
9
+ if (lastMs === null)
10
+ return { label: '(파일 mtime 조회 실패)' };
11
+ const ageMs = Date.now() - lastMs;
12
+ const days = Math.floor(ageMs / 86_400_000);
13
+ const hours = Math.floor((ageMs % 86_400_000) / 3_600_000);
14
+ const label = days > 0 ? `${days}d ${hours}h 전 갱신` : `${hours}h 전 갱신`;
15
+ if (days >= 14) {
16
+ return {
17
+ label,
18
+ recommendation: `⚠️ 14일 이상 미사용 — Google 정책상 refresh_token revoke 위험. 안전을 위해 'mimi_seed_auth_start' 로 재인증 권장.`,
19
+ };
20
+ }
21
+ if (days >= 7) {
22
+ return {
23
+ label,
24
+ recommendation: `ℹ️ 7일 이상 미사용 — 한 번 더 갱신하지 않으면 곧 revoke 가능. 다음 도구 호출이 자동 갱신해주지만, 장기 미사용 예정이면 미리 인증 갱신 권장.`,
25
+ };
26
+ }
27
+ return { label };
28
+ }
3
29
  export function registerAuthTools(server) {
4
30
  server.tool('mimi_seed_auth_start', [
5
31
  'Google OAuth 로그인 링크를 발급하고 백그라운드 콜백 서버를 시작.',
@@ -28,6 +54,9 @@ export function registerAuthTools(server) {
28
54
  });
29
55
  server.tool('mimi_seed_auth_status', 'Mimi Seed MCP 인증 상태 확인 (만료 시 refresh_token으로 자동 갱신 시도)', {}, async () => {
30
56
  const r = await ensureFreshAccessToken();
57
+ const refreshHint = formatLastRefreshHint(getTokensLastRefreshMs());
58
+ const refreshLine = ` 마지막 갱신: ${refreshHint.label}`;
59
+ const recommendation = refreshHint.recommendation ? `\n\n${refreshHint.recommendation}` : '';
31
60
  switch (r.status) {
32
61
  case 'unauthenticated':
33
62
  return {
@@ -40,14 +69,19 @@ export function registerAuthTools(server) {
40
69
  };
41
70
  case 'fresh': {
42
71
  const min = Math.round(r.msUntilExpiry / 60000);
43
- return { content: [{ type: 'text', text: `✅ 인증 유효 (${min}분 남음).` }] };
72
+ return {
73
+ content: [{
74
+ type: 'text',
75
+ text: `✅ 인증 유효 (${min}분 남음).\n${refreshLine}${recommendation}`,
76
+ }],
77
+ };
44
78
  }
45
79
  case 'refreshed': {
46
80
  const min = Math.round(r.msUntilExpiry / 60000);
47
81
  return {
48
82
  content: [{
49
83
  type: 'text',
50
- text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).`,
84
+ text: `✅ 토큰 만료 → refresh_token으로 자동 갱신 완료 (${min}분 남음).\n${refreshLine}${recommendation}`,
51
85
  }],
52
86
  };
53
87
  }
@@ -59,6 +93,7 @@ export function registerAuthTools(server) {
59
93
  ` 코드: ${r.error.code}\n` +
60
94
  ` ${r.error.message}\n` +
61
95
  (r.error.hint ? ` → ${r.error.hint}\n` : '') +
96
+ `${refreshLine}\n` +
62
97
  '\n터미널에서 재로그인:\n npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
63
98
  }],
64
99
  };