@yoonion/mimi-seed-mcp 0.15.5 → 0.16.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/README.md CHANGED
@@ -58,6 +58,7 @@ npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth # BigQuery
58
58
  npx -y @yoonion/mimi-seed-mcp mimi-seed-jenkins-auth # Jenkins (저장 전 서버 프로브)
59
59
  npx -y @yoonion/mimi-seed-mcp mimi-seed-googleads-auth # Google Ads (저장 전 실제 호출로 검증)
60
60
  npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram / Threads
61
+ npx -y @yoonion/mimi-seed-mcp mimi-seed-tiktok-business-auth # TikTok Business Organic API
61
62
  ```
62
63
 
63
64
  각 자격증명을 **어디서 어떻게 발급받는지**는 [`docs/credentials.md`](../../docs/credentials.md) 참고.
@@ -74,7 +75,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
74
75
 
75
76
  ---
76
77
 
77
- ## 제공 도구 (150+ 개 · 19개 영역)
78
+ ## 제공 도구 (150+ 개 · 21개 영역)
78
79
 
79
80
  | 영역 | 도구 수 | 주요 도구 |
80
81
  |------|---------|-----------|
@@ -89,8 +90,10 @@ export ANTHROPIC_API_KEY=sk-ant-...
89
90
  | Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
90
91
  | Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
91
92
  | Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
93
+ | GCP Billing | 4 | `gcp_get_billing_info` / `gcp_list_billing_projects` / `gcp_list_budgets` / `gcp_create_budget` |
92
94
  | BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
93
- | Threads | 6 | `threads_post` / `threads_post_carousel` / `threads_refresh_token` |
95
+ | Threads | 7 | `threads_post` / `threads_post_video` / `threads_post_carousel` / `threads_refresh_token` |
96
+ | TikTok Business | 7 | `tiktok_business_plan_video_post` / `tiktok_business_publish_video` / `tiktok_business_get_publish_status` |
94
97
  | 점검 / 위험 | 4 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `screenshot_validate` / `release_status` |
95
98
  | Instagram | 4 | `instagram_post_image` / `instagram_post_carousel` / `instagram_save_config` |
96
99
  | Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
@@ -69,10 +69,12 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
69
69
  | Firebase apps + services (incl. web) | `select:firebase_list_android_apps,firebase_list_ios_apps,firebase_list_web_apps,firebase_create_web_app,firebase_get_web_config,firebase_enable_service,firebase_list_enabled_services,firebase_delete_android_app,firebase_delete_ios_app,firebase_delete_web_app` |
70
70
  | Analytics wiring (Firebase ↔ GA4 ↔ BigQuery) | `select:firebase_link_analytics,firebase_get_analytics_details,ga4_list_account_summaries,ga4_list_properties,ga4_create_property,ga4_list_data_streams,ga4_create_data_stream,ga4_plan_bigquery_link,ga4_create_bigquery_link,ga4_run_report` |
71
71
  | BigQuery | `select:bigquery_auth_status,bigquery_list_datasets,bigquery_list_tables,bigquery_get_table_schema,bigquery_run_query` |
72
+ | GCP billing (Blaze 여부 · 비용 범위 · 예산) | `select:gcp_get_billing_info,gcp_list_billing_projects,gcp_list_budgets,gcp_create_budget` |
72
73
  | AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
73
74
  | Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
74
75
  | Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
75
- | Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_carousel` |
76
+ | Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_video,threads_post_carousel` |
77
+ | TikTok Business video publish | `select:tiktok_business_auth_status,tiktok_business_get_account,tiktok_business_get_video_settings,tiktok_business_plan_video_post,tiktok_business_publish_video,tiktok_business_get_publish_status,tiktok_business_list_publish_audits` |
76
78
  | Jenkins credentials + jobs | `select:jenkins_status,jenkins_save_config,jenkins_list_credentials,jenkins_create_credential,jenkins_delete_credential,jenkins_upload_keystore,jenkins_upload_playstore_sa,jenkins_list_jobs,jenkins_get_job_config,jenkins_create_job,jenkins_update_job` |
77
79
  | CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds,ci_cancel_build` |
78
80
  | Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_upload_playstore_sa` |
@@ -86,7 +88,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
86
88
 
87
89
  Before any task, call **`mimi_seed_status`** — the setup doctor. It scans your service
88
90
  credentials (Google OAuth · Play SA · App Store · Jenkins · CI · Google Ads · Facebook · Instagram ·
89
- Threads · BigQuery) and returns a ✅/❌ report plus the exact next tool to call for anything
91
+ Threads · TikTok Business · BigQuery) and returns a ✅/❌ report plus the exact next tool to call for anything
90
92
  missing. This avoids a late `401`/`403` deep into a workflow.
91
93
 
92
94
  > If the repo has a **`.mimi-seed.json`** manifest at its root, `mimi_seed_status` (and
@@ -110,6 +112,7 @@ every credential, which also tells them where to obtain each token
110
112
  | GitHub / GitLab CI | `mimi-seed auth ci` |
111
113
  | Google Ads | `mimi-seed auth googleads` — needs the `adwords` OAuth scope; an old token may need `mimi-seed auth login --domains googleads` (adds the grant, keeps the rest) |
112
114
  | Facebook / Instagram / Threads | `mimi-seed auth facebook` / `mimi-seed auth instagram` / `mimi-seed auth threads` |
115
+ | TikTok Business | `mimi-seed auth tiktok` → verify with `tiktok_business_auth_status` and `tiktok_business_get_account` |
113
116
 
114
117
  `mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
115
118
  all three Meta platforms in one pass.
@@ -146,6 +149,8 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
146
149
  | `google-ads.json` | Google Ads developer token + customer id |
147
150
  | `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
148
151
  | `social-profiles/<profile>.json` | Named Facebook/Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
152
+ | `tiktok-business.json` | TikTok API for Business app credentials + short-term/refresh tokens (0600) |
153
+ | `tiktok-business/plans`, `tiktok-business/audit`, `tiktok-business/locks` | Expiring post plans, deduplication/audit records, and atomic publish reservations; signed URL query strings are not written to audit records |
149
154
 
150
155
  Notes that matter in practice:
151
156
 
@@ -181,7 +186,8 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
181
186
  | **Google Ads (UAC)** | `googleads_list_campaigns` · `googleads_get_uac_report` · `googleads_get_campaign_report` · `googleads_config_status` |
182
187
  | **Search Console** | `gsc_inspect_url` · `gsc_search_analytics` · `gsc_submit_sitemap` |
183
188
  | **Android signing** | `android_signing_setup` · `android_generate_keystore` · `jenkins_upload_playstore_sa` |
184
- | **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_refresh_token` |
189
+ | **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_post_video` · `threads_refresh_token` |
190
+ | **TikTok Business** | `tiktok_business_auth_status` · `tiktok_business_plan_video_post` · `tiktok_business_publish_video` · `tiktok_business_get_publish_status` |
185
191
  | **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
186
192
  | **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
187
193
  | **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
@@ -0,0 +1,50 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ /** `01F1F4-FD007B-2973A7` / `billingAccounts/01F1F4-...` 어느 형태로 줘도 정규화. */
3
+ export declare function normalizeBillingAccount(input: string): string;
4
+ export type BillingInfo = {
5
+ projectId: string;
6
+ billingEnabled: boolean;
7
+ billingAccountName: string | null;
8
+ /** 사람이 읽을 판정 — Blaze/Spark 오판을 막기 위해 문장으로 준다. */
9
+ verdict: string;
10
+ };
11
+ export declare function getBillingInfo(auth: OAuth2Client, projectId: string): Promise<BillingInfo>;
12
+ /**
13
+ * 결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.
14
+ * 여기서 프로젝트가 여러 개면 계정 전체 예산은 의미가 없고 프로젝트 필터를 써야 한다.
15
+ */
16
+ export declare function listBillingProjects(auth: OAuth2Client, billingAccount: string, quotaProjectId?: string): Promise<{
17
+ billingAccount: string;
18
+ count: number;
19
+ shared: boolean;
20
+ projects: {
21
+ projectId: string | null | undefined;
22
+ billingEnabled: boolean;
23
+ }[];
24
+ }>;
25
+ export declare function listBudgets(auth: OAuth2Client, billingAccount: string, quotaProjectId?: string): Promise<{
26
+ name: string | null | undefined;
27
+ displayName: string | null | undefined;
28
+ amount: string;
29
+ projects: string[];
30
+ thresholds: (number | null | undefined)[];
31
+ }[]>;
32
+ export type CreateBudgetInput = {
33
+ billingAccount: string;
34
+ displayName: string;
35
+ /** 통화 단위 정수 금액 (KRW 는 소수 없음 — 10000 = ₩10,000). */
36
+ amountUnits: number;
37
+ currencyCode?: string;
38
+ /** 감시할 프로젝트 ID 목록. **비우면 결제 계정 전체** — 공용 계정이면 소음이 된다. */
39
+ projectIds?: string[];
40
+ /** 알림 임계 비율 (0~1). 기본 0.5 / 0.9 / 1.0. */
41
+ thresholds?: number[];
42
+ /** quota 주체 프로젝트 — Billing Budget API 를 켜 둔 프로젝트. quotaHeaders 주석 참고. */
43
+ quotaProjectId?: string;
44
+ };
45
+ export declare function createBudget(auth: OAuth2Client, input: CreateBudgetInput): Promise<{
46
+ name: string | null | undefined;
47
+ displayName: string | null | undefined;
48
+ projects: string[];
49
+ thresholds: number[];
50
+ }>;
@@ -0,0 +1,138 @@
1
+ import { google } from '../lib/googleapis-lite.js';
2
+ /**
3
+ * Cloud Billing + Billing Budgets 래퍼.
4
+ *
5
+ * 앱마다 전용 Firebase/GCP 프로젝트를 만드는 컨벤션이라, 새 프로젝트가 생길 때마다
6
+ * "이 프로젝트가 Blaze 인가 → 결제 계정이 공용인가 → 예산 알림을 걸었나"가 반복 작업이 된다.
7
+ *
8
+ * 실제로 겪은 함정 2개가 이 모듈의 존재 이유다:
9
+ *
10
+ * 1. **Billing API 가 꺼져 있으면 조회 자체가 403** 이고, 그 403 을 "Spark 이라서"로
11
+ * 오해하기 쉽다. 실제로는 이미 Blaze 였다. `getBillingInfo` 는 이 구분을 명확히 한다
12
+ * (API 미활성 → firebase_enable_service 안내 / 활성인데 billingEnabled=false → 진짜 Spark).
13
+ *
14
+ * 2. **결제 계정이 회사 공용이면 계정 전체 예산은 쓸 수 없다.** 기존 지출(BigQuery export 등)만으로
15
+ * 즉시 임계를 넘겨 알림이 소음이 된다. 그래서 `createBudget` 은 `projects` 필터를 1급으로 받는다 —
16
+ * 프로젝트 범위 예산이 신규 앱 감시의 기본형이다.
17
+ *
18
+ * ⚠️ 예산은 **알림만 한다. 지출을 막지 않는다.** 하드 차단은 Pub/Sub → 결제 해제 함수뿐이고
19
+ * 그건 앱을 죽이는 조치다. 실질 방어는 각 서비스의 상한(예: functions maxInstances)이다.
20
+ *
21
+ * ⚠️ 권한은 **결제 계정 레벨 IAM** 이다(프로젝트 IAM 과 별개). 서비스 계정 키로는 대개 403 이고
22
+ * 사용자 OAuth(cloud-platform)로 호출해야 한다.
23
+ */
24
+ const billing = () => google.cloudbilling('v1');
25
+ const budgets = () => google.billingbudgets('v1');
26
+ /**
27
+ * 🔴 quota 프로젝트 지정이 **필수**다.
28
+ *
29
+ * 사용자 OAuth 로 호출하면 GCP 는 quota/billing 을 **OAuth 클라이언트의 프로젝트**에 청구한다.
30
+ * mimi-seed 의 OAuth 클라이언트 프로젝트에는 Cloud Billing API 가 없으므로, 지정하지 않으면
31
+ * 대상 프로젝트가 아무리 정상이어도 항상 이렇게 실패한다 (2026-08-04 실측):
32
+ *
33
+ * "Cloud Billing API has not been used in project <OAuth 클라이언트 프로젝트 번호> before or it is disabled"
34
+ *
35
+ * 이 메시지의 프로젝트 번호는 **우리가 조회하려는 프로젝트가 아니라 OAuth 클라이언트 쪽**이다 —
36
+ * 그래서 "대상 프로젝트에서 API 를 켰는데도 왜 403 이냐"로 헤매게 된다.
37
+ * `x-goog-user-project` 로 우리가 통제하는(그리고 API 를 켜 둔) 프로젝트를 quota 주체로 넘긴다.
38
+ */
39
+ function quotaHeaders(quotaProjectId) {
40
+ return quotaProjectId ? { headers: { 'x-goog-user-project': quotaProjectId } } : {};
41
+ }
42
+ /** `01F1F4-FD007B-2973A7` / `billingAccounts/01F1F4-...` 어느 형태로 줘도 정규화. */
43
+ export function normalizeBillingAccount(input) {
44
+ const trimmed = input.trim();
45
+ return trimmed.startsWith('billingAccounts/') ? trimmed : `billingAccounts/${trimmed}`;
46
+ }
47
+ export async function getBillingInfo(auth, projectId) {
48
+ // 조회 대상 프로젝트 자신을 quota 주체로 쓴다 — 거기 Billing API 가 켜져 있어야 한다.
49
+ const res = await billing().projects.getBillingInfo({
50
+ auth,
51
+ name: `projects/${projectId}`,
52
+ ...quotaHeaders(projectId),
53
+ });
54
+ const enabled = res.data.billingEnabled ?? false;
55
+ const account = res.data.billingAccountName ?? null;
56
+ return {
57
+ projectId,
58
+ billingEnabled: enabled,
59
+ billingAccountName: account,
60
+ verdict: enabled
61
+ ? `Blaze — 결제 계정 ${account} 연결됨. Cloud Functions/Run 배포 가능.`
62
+ : 'Spark — 결제 계정 미연결. Cloud Functions 등 유료 서비스 배포 불가.',
63
+ };
64
+ }
65
+ /**
66
+ * 결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.
67
+ * 여기서 프로젝트가 여러 개면 계정 전체 예산은 의미가 없고 프로젝트 필터를 써야 한다.
68
+ */
69
+ export async function listBillingProjects(auth, billingAccount, quotaProjectId) {
70
+ const name = normalizeBillingAccount(billingAccount);
71
+ const res = await billing().billingAccounts.projects.list({
72
+ auth,
73
+ name,
74
+ pageSize: 200,
75
+ ...quotaHeaders(quotaProjectId),
76
+ });
77
+ const projects = (res.data.projectBillingInfo ?? []).map((p) => ({
78
+ projectId: p.projectId,
79
+ billingEnabled: p.billingEnabled ?? false,
80
+ }));
81
+ return {
82
+ billingAccount: name,
83
+ count: projects.length,
84
+ shared: projects.length > 1,
85
+ projects,
86
+ };
87
+ }
88
+ export async function listBudgets(auth, billingAccount, quotaProjectId) {
89
+ const parent = normalizeBillingAccount(billingAccount);
90
+ const res = await budgets().billingAccounts.budgets.list({
91
+ auth,
92
+ parent,
93
+ pageSize: 200,
94
+ ...quotaHeaders(quotaProjectId),
95
+ });
96
+ return (res.data.budgets ?? []).map((b) => ({
97
+ name: b.name,
98
+ displayName: b.displayName,
99
+ amount: b.amount?.specifiedAmount
100
+ ? `${b.amount.specifiedAmount.units ?? '0'} ${b.amount.specifiedAmount.currencyCode ?? ''}`.trim()
101
+ : b.amount?.lastPeriodAmount
102
+ ? '(직전 기간 금액)'
103
+ : '(미지정)',
104
+ projects: b.budgetFilter?.projects ?? [],
105
+ thresholds: (b.thresholdRules ?? []).map((t) => t.thresholdPercent),
106
+ }));
107
+ }
108
+ export async function createBudget(auth, input) {
109
+ const parent = normalizeBillingAccount(input.billingAccount);
110
+ const thresholds = input.thresholds?.length ? input.thresholds : [0.5, 0.9, 1.0];
111
+ const res = await budgets().billingAccounts.budgets.create({
112
+ auth,
113
+ parent,
114
+ requestBody: {
115
+ displayName: input.displayName,
116
+ budgetFilter: {
117
+ // projects 는 `projects/<번호 또는 ID>` 형태를 받는다.
118
+ ...(input.projectIds?.length
119
+ ? { projects: input.projectIds.map((p) => (p.startsWith('projects/') ? p : `projects/${p}`)) }
120
+ : {}),
121
+ },
122
+ amount: {
123
+ specifiedAmount: {
124
+ currencyCode: input.currencyCode ?? 'KRW',
125
+ units: String(input.amountUnits),
126
+ },
127
+ },
128
+ thresholdRules: thresholds.map((t) => ({ thresholdPercent: t })),
129
+ },
130
+ ...quotaHeaders(input.quotaProjectId),
131
+ });
132
+ return {
133
+ name: res.data.name,
134
+ displayName: res.data.displayName,
135
+ projects: res.data.budgetFilter?.projects ?? [],
136
+ thresholds,
137
+ };
138
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ const SUBCOMMANDS = {
17
17
  'mimi-seed-jenkins-auth': () => import('./jenkins/setup-cli.js'),
18
18
  'mimi-seed-googleads-auth': () => import('./googleads/setup-cli.js'),
19
19
  'mimi-seed-social-auth': () => import('./social/setup-cli.js'),
20
+ 'mimi-seed-tiktok-business-auth': () => import('./tiktok-business/setup-cli.js'),
20
21
  'mimi-seed-firebase': () => import('./firebase/cli.js'),
21
22
  'mimi-seed-admob': () => import('./admob/cli.js'),
22
23
  'mimi-seed-ga4': () => import('./ga4/cli.js'),
@@ -16,6 +16,8 @@ import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.j
16
16
  import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
17
  import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
18
  import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
+ import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
+ import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
19
21
  import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
20
22
  import { firebase } from 'googleapis/build/src/apis/firebase/index.js';
21
23
  import { iam } from 'googleapis/build/src/apis/iam/index.js';
@@ -30,6 +32,8 @@ export declare const google: {
30
32
  analyticsdata: typeof analyticsdata;
31
33
  androidpublisher: typeof androidpublisher;
32
34
  bigquery: typeof bigquery;
35
+ billingbudgets: typeof billingbudgets;
36
+ cloudbilling: typeof cloudbilling;
33
37
  cloudresourcemanager: typeof cloudresourcemanager;
34
38
  firebase: typeof firebase;
35
39
  iam: typeof iam;
@@ -16,6 +16,8 @@ import { analyticsadmin } from 'googleapis/build/src/apis/analyticsadmin/index.j
16
16
  import { analyticsdata } from 'googleapis/build/src/apis/analyticsdata/index.js';
17
17
  import { androidpublisher } from 'googleapis/build/src/apis/androidpublisher/index.js';
18
18
  import { bigquery } from 'googleapis/build/src/apis/bigquery/index.js';
19
+ import { billingbudgets } from 'googleapis/build/src/apis/billingbudgets/index.js';
20
+ import { cloudbilling } from 'googleapis/build/src/apis/cloudbilling/index.js';
19
21
  import { cloudresourcemanager } from 'googleapis/build/src/apis/cloudresourcemanager/index.js';
20
22
  import { auth, firebase } from 'googleapis/build/src/apis/firebase/index.js';
21
23
  import { iam } from 'googleapis/build/src/apis/iam/index.js';
@@ -29,6 +31,8 @@ export const google = {
29
31
  analyticsdata,
30
32
  androidpublisher,
31
33
  bigquery,
34
+ billingbudgets,
35
+ cloudbilling,
32
36
  cloudresourcemanager,
33
37
  firebase,
34
38
  iam,
@@ -11,6 +11,8 @@ import { loadConfig as loadGoogleAdsConfig } from '../googleads/config.js';
11
11
  import { loadFacebookConfig } from '../facebook/config.js';
12
12
  import { loadInstagramConfig } from '../instagram/config.js';
13
13
  import { loadThreadsConfig } from '../threads/config.js';
14
+ import { loadTikTokBusinessConfig } from '../tiktok-business/config.js';
15
+ import { tokenFreshness as tiktokTokenFreshness } from '../tiktok-business/auth.js';
14
16
  import { metaTokenFreshness } from '../lib/meta-auth.js';
15
17
  import { readPackageRootText } from '../lib/package-root.js';
16
18
  import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
@@ -114,7 +116,7 @@ export function registerAuthTools(server) {
114
116
  // ── 전체 연결 상태 진단 ────────────────────────────────────────────────────
115
117
  server.tool('mimi_seed_status', [
116
118
  '⭐ 새 세션을 시작하거나 "뭐가 연결됐지?" 라는 질문엔 이 도구를 먼저 호출하세요.',
117
- '전체 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / BigQuery)',
119
+ '전체 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / TikTok Business / BigQuery)',
118
120
  '설정 상태를 한 번에 스캔해 ✅ / ❌ 트래픽 라이트 리포트와 번호 매긴 다음 단계를 반환합니다.',
119
121
  '미설정 서비스마다 어떤 도구를 호출하면 되는지 구체적으로 알려줍니다.',
120
122
  ].join(' '), {}, async () => {
@@ -197,7 +199,22 @@ export function registerAuthTools(server) {
197
199
  const threadsTarget = resolveSocialConfigTarget('threads');
198
200
  const threads = loadThreadsConfig();
199
201
  lines.push(renderMetaConnection('Threads', threads, `${threadsTarget.profile ? `profile: ${threadsTarget.profile}, ` : ''}userId: ${threads?.userId ?? ''}`, `mimi-seed auth threads${threadsTarget.profile ? ` --profile ${threadsTarget.profile}` : ''}`));
200
- // 10. BigQueryresolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
202
+ // 10. TikTok Business 네트워크 없이 refresh token 만료 상태까지 판정.
203
+ const tiktok = loadTikTokBusinessConfig();
204
+ const tiktokFreshness = tiktokTokenFreshness(tiktok);
205
+ if (!tiktok) {
206
+ lines.push('❌ TikTok Business — 미설정 → mimi-seed auth tiktok (선택)');
207
+ }
208
+ else if (tiktokFreshness.state === 'refresh_expired') {
209
+ lines.push(`❌ TikTok Business — refresh token 만료 (${tiktok.openId}) → mimi-seed auth tiktok`);
210
+ }
211
+ else if (tiktokFreshness.state === 'access_expired') {
212
+ lines.push(`⚠️ TikTok Business — 연결됨, 다음 호출에서 access token 자동 갱신 (${tiktok.openId})`);
213
+ }
214
+ else {
215
+ lines.push(`✅ TikTok Business — Organic API 연결됨 (${tiktok.openId})`);
216
+ }
217
+ // 11. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
201
218
  // 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
202
219
  const bqAuth = resolveBigQueryAuth();
203
220
  if (bqAuth?.source === 'service-account') {
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerBillingTools(server: McpServer): void;
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import * as billing from '../billing/tools.js';
3
+ import { requireAuth } from '../helpers.js';
4
+ import { CLOUD_PLATFORM_SCOPE } from '../auth/scopes.js';
5
+ import { jsonResult } from '../lib/mcp-response.js';
6
+ export function registerBillingTools(server) {
7
+ server.tool('gcp_get_billing_info', [
8
+ '프로젝트의 결제 상태 조회 — Blaze(결제 계정 연결) 여부와 연결된 결제 계정 ID.',
9
+ 'Cloud Functions/Run 배포 전 필수 확인.',
10
+ '⚠️ Cloud Billing API 가 꺼져 있으면 403 이 난다 — 그건 "Spark 이라서"가 아니라 조회 자체가 막힌 것이다.',
11
+ '그 경우 firebase_enable_service(projectId, "cloudbilling.googleapis.com") 로 먼저 켠 뒤 다시 호출.',
12
+ ].join(' '), {
13
+ projectId: z.string().describe('GCP/Firebase 프로젝트 ID'),
14
+ }, async ({ projectId }) => {
15
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
16
+ return jsonResult(await billing.getBillingInfo(auth, projectId));
17
+ });
18
+ server.tool('gcp_list_billing_projects', [
19
+ '결제 계정에 붙은 프로젝트 목록 = **비용 범위 확인**.',
20
+ '프로젝트가 2개 이상이면(shared=true) 그 계정은 공용이므로 **계정 전체 예산은 쓰지 말 것** —',
21
+ '기존 지출만으로 임계를 즉시 넘겨 알림이 소음이 된다. gcp_create_budget 에 projectIds 를 넘겨 범위를 좁힌다.',
22
+ ].join(' '), {
23
+ billingAccount: z
24
+ .string()
25
+ .describe('결제 계정 ID (예: 01F1F4-FD007B-2973A7 또는 billingAccounts/01F1F4-...)'),
26
+ quotaProjectId: z
27
+ .string()
28
+ .optional()
29
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
30
+ }, async ({ billingAccount, quotaProjectId }) => {
31
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
32
+ return jsonResult(await billing.listBillingProjects(auth, billingAccount, quotaProjectId));
33
+ });
34
+ server.tool('gcp_list_budgets', '결제 계정의 예산 목록 (금액·프로젝트 필터·알림 임계). 중복 생성 전 확인용.', {
35
+ billingAccount: z.string().describe('결제 계정 ID'),
36
+ quotaProjectId: z
37
+ .string()
38
+ .optional()
39
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
40
+ }, async ({ billingAccount, quotaProjectId }) => {
41
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
42
+ return jsonResult(await billing.listBudgets(auth, billingAccount, quotaProjectId));
43
+ });
44
+ server.tool('gcp_create_budget', [
45
+ '예산 + 알림 임계 생성.',
46
+ '⚠️ **예산은 알림만 한다. 지출을 막지 않는다.** 하드 차단은 Pub/Sub→결제해제 함수뿐이고 그건 앱을 죽인다.',
47
+ '실질 방어는 각 서비스의 상한(예: Cloud Functions maxInstances)이고 예산은 트립와이어다.',
48
+ '⚠️ GCP 에 **일 예산은 없다** — 기간은 월(기본)/분기/연/사용자지정뿐. "하루 N원"을 원하면 월 환산하거나,',
49
+ '실사용이 적을 때는 작은 월 예산을 알람으로 쓰는 게 더 빨리 잡힌다.',
50
+ 'projectIds 를 반드시 고려할 것 — 공용 결제 계정에서 생략하면 알림이 소음이 된다(gcp_list_billing_projects 로 먼저 확인).',
51
+ '권한은 결제 계정 레벨 IAM 이라 서비스 계정 키로는 대개 403 — 사용자 OAuth 로 호출된다.',
52
+ ].join(' '), {
53
+ billingAccount: z.string().describe('결제 계정 ID'),
54
+ displayName: z.string().describe('예산 이름 (예: "my-app functions 감시")'),
55
+ amountUnits: z
56
+ .number()
57
+ .int()
58
+ .positive()
59
+ .describe('통화 단위 정수 금액. KRW 는 소수 없음 — 10000 = ₩10,000'),
60
+ currencyCode: z.string().optional().describe('통화 코드 (기본 KRW). 결제 계정 통화와 일치해야 한다'),
61
+ projectIds: z
62
+ .array(z.string())
63
+ .optional()
64
+ .describe('감시할 프로젝트 ID 목록. 생략하면 결제 계정 전체 — 공용 계정이면 권장하지 않음'),
65
+ thresholds: z
66
+ .array(z.number().min(0).max(1))
67
+ .optional()
68
+ .describe('알림 임계 비율 0~1 (기본 [0.5, 0.9, 1.0])'),
69
+ quotaProjectId: z
70
+ .string()
71
+ .optional()
72
+ .describe('quota 주체 프로젝트 — Cloud Billing/Budget API 를 켜 둔 프로젝트 ID. 생략하면 OAuth 클라이언트 프로젝트로 quota 가 잡혀 403 이 난다(에러 메시지의 프로젝트 번호는 조회 대상이 아니라 OAuth 쪽이다).'),
73
+ }, async ({ billingAccount, displayName, amountUnits, currencyCode, projectIds, thresholds, quotaProjectId, }) => {
74
+ const auth = await requireAuth(CLOUD_PLATFORM_SCOPE);
75
+ const budget = await billing.createBudget(auth, {
76
+ billingAccount,
77
+ displayName,
78
+ amountUnits,
79
+ currencyCode,
80
+ projectIds,
81
+ thresholds,
82
+ quotaProjectId,
83
+ });
84
+ return {
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: [
89
+ '✓ 예산 생성 완료',
90
+ '',
91
+ `**name**: \`${budget.name}\``,
92
+ `**displayName**: ${budget.displayName}`,
93
+ `**범위**: ${budget.projects.length ? budget.projects.join(', ') : '결제 계정 전체'}`,
94
+ `**알림 임계**: ${budget.thresholds.map((t) => `${Math.round(t * 100)}%`).join(' / ')}`,
95
+ '',
96
+ '⚠️ 이 예산은 **알림만** 한다 — 임계를 넘어도 지출은 계속된다.',
97
+ ].join('\n'),
98
+ },
99
+ ],
100
+ };
101
+ });
102
+ }
@@ -94,6 +94,30 @@ export function registerThreadsTools(server) {
94
94
  }],
95
95
  };
96
96
  });
97
+ server.tool('threads_post_video', [
98
+ 'public URL의 영상을 Threads에 게시합니다.',
99
+ 'Meta가 videoUrl을 직접 가져가므로 외부에서 인증 없이 접근 가능한 URL이어야 합니다.',
100
+ '3-step API: VIDEO container 생성 → 처리 완료 자동 대기 → threads_publish.',
101
+ '공개 게시 작업이므로 같은 턴에 사용자의 명시 승인을 받은 뒤 호출하세요.',
102
+ ].join(' '), {
103
+ videoUrl: z.string().url().describe('Threads가 가져갈 영상의 public URL'),
104
+ text: z.string().max(500).default('').describe('게시할 텍스트 (선택, 최대 500자)'),
105
+ altText: z.string().optional().describe('영상 접근성 대체 텍스트 (선택)'),
106
+ profile: profileSchema,
107
+ }, async ({ videoUrl, text, altText, profile }) => {
108
+ const cfg = requireThreadsConfig({ profile });
109
+ const result = await api.postVideo(cfg, videoUrl, text, altText);
110
+ return {
111
+ content: [{
112
+ type: 'text',
113
+ text: [
114
+ '✅ 영상 게시 완료',
115
+ ` media_id: ${result.id}`,
116
+ result.permalink ? ` URL: ${result.permalink}` : '',
117
+ ].filter(Boolean).join('\n'),
118
+ }],
119
+ };
120
+ });
97
121
  // 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
98
122
  server.tool('threads_current_config', '현재 선택된 Threads 연결 설정을 확인합니다. 프로젝트 매핑 또는 profile 인자를 따릅니다.', { profile: profileSchema }, async ({ profile }) => {
99
123
  const options = { profile };
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTikTokBusinessTools(server: McpServer): void;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { getBusinessAccount, getVideoSettings, inspectAccessToken } from '../tiktok-business/api.js';
3
+ import { ensureFreshTikTokConfig, ensureTikTokPublishConfig, safeTokenInspection, tokenFreshness, } from '../tiktok-business/auth.js';
4
+ import { safeTikTokBusinessConfig } from '../tiktok-business/config.js';
5
+ import { checkPublishStatus, listPublishAudits, planVideoPost, publishPlannedVideo, } from '../tiktok-business/posting.js';
6
+ import { jsonResult } from '../lib/mcp-response.js';
7
+ const absolutePath = z.string().min(1).refine((value) => value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value), {
8
+ message: '절대경로만 허용합니다.',
9
+ });
10
+ const httpsUrl = z.string().url().refine((value) => new URL(value).protocol === 'https:', {
11
+ message: 'HTTPS URL만 허용합니다.',
12
+ });
13
+ export function registerTikTokBusinessTools(server) {
14
+ server.tool('tiktok_business_auth_status', [
15
+ 'TikTok API for Business Organic API 연결과 권한 상태를 확인합니다.',
16
+ '만료 5분 전이면 refresh token으로 access token을 자동 갱신합니다.',
17
+ '토큰·앱 시크릿은 응답하지 않습니다. 미연결이면 `mimi-seed auth tiktok`을 실행하세요.',
18
+ ].join(' '), {}, async () => {
19
+ const config = await ensureFreshTikTokConfig();
20
+ const tokenInfo = await inspectAccessToken(config);
21
+ return jsonResult({
22
+ connected: true,
23
+ ...safeTikTokBusinessConfig(config),
24
+ freshness: tokenFreshness(config).state,
25
+ tokenInfo: safeTokenInspection(config, tokenInfo),
26
+ });
27
+ });
28
+ server.tool('tiktok_business_get_account', '연결된 owned TikTok Business Account 프로필을 조회하고 현재 토큰으로 접근 가능한지 검증합니다.', {}, async () => jsonResult(await getBusinessAccount(await ensureFreshTikTokConfig())));
29
+ server.tool('tiktok_business_get_video_settings', '게시 전에 계정별 최대 영상 길이와 댓글·듀엣·스티치 설정을 조회합니다.', {}, async () => jsonResult(await getVideoSettings(await ensureTikTokPublishConfig())));
30
+ server.tool('tiktok_business_plan_video_post', [
31
+ 'TikTok 공개 게시 전용 계획을 만들고 30분 동안 로컬에 보관합니다.',
32
+ '로컬 원본을 ffprobe로 검사하고 SHA-256으로 중복 게시를 차단하며 계정별 영상 설정과 대조합니다.',
33
+ 'videoUrl은 TikTok 앱에서 검증한 도메인의 HTTPS URL이어야 하고 최소 30분 이상 유효해야 합니다.',
34
+ '이 도구는 TikTok에 게시하지 않습니다. 검토 후 tiktok_business_publish_video를 별도로 호출하세요.',
35
+ ].join(' '), {
36
+ sourceFilePath: absolutePath.describe('검증·중복 판정할 로컬 .mp4/.mov/.webm 절대경로'),
37
+ videoUrl: httpsUrl.describe('TikTok 서버가 가져갈 영상의 검증된 HTTPS URL'),
38
+ customThumbnailUrl: httpsUrl.optional().describe('선택 썸네일 HTTPS URL'),
39
+ caption: z.string().max(2_200).optional().describe('UTF-16 기준 최대 2,200자, 멘션 최대 30개'),
40
+ isBrandOrganic: z.boolean().default(false).describe('브랜드 자체 홍보 콘텐츠 여부'),
41
+ isBrandedContent: z.boolean().default(false).describe('유료 파트너십/브랜디드 콘텐츠 여부'),
42
+ disableComment: z.boolean().default(false),
43
+ disableDuet: z.boolean().default(false),
44
+ disableStitch: z.boolean().default(false),
45
+ thumbnailOffsetMs: z.number().int().min(0).optional().describe('영상 시작 기준 썸네일 오프셋(ms)'),
46
+ isAiGenerated: z.boolean().default(true).describe('AI 생성·변형 영상 고지'),
47
+ ffmpegPath: z.string().optional().describe('FFmpeg 절대경로. 생략하면 PATH의 ffprobe 사용'),
48
+ }, async (input) => jsonResult(await planVideoPost(input)));
49
+ server.tool('tiktok_business_publish_video', [
50
+ '검증된 30분짜리 계획을 owned TikTok Business Account에 공개 게시합니다.',
51
+ '외부 공개 작업이므로 같은 턴에 사용자의 명시 승인을 받은 뒤 confirmPublish=true로 호출해야 합니다.',
52
+ 'POST 타임아웃은 결과 불명으로 감사 로그에 남고 자동 재시도를 막습니다.',
53
+ '성공 응답은 보통 pending이며 tiktok_business_get_publish_status로 완료를 확인하세요.',
54
+ ].join(' '), {
55
+ planId: z.string().regex(/^[a-f0-9]{32}$/),
56
+ confirmPublish: z.boolean().default(false).describe('사용자의 공개 게시 명시 승인 확인'),
57
+ }, async ({ planId, confirmPublish }) => jsonResult(await publishPlannedVideo(planId, confirmPublish)));
58
+ server.tool('tiktok_business_get_publish_status', 'TikTok 게시 처리 상태를 조회하고 로컬 감사 로그의 pending/published/failed 상태를 갱신합니다.', { publishId: z.string().min(1).max(128).describe('publish 응답의 share_id 또는 publish_id') }, async ({ publishId }) => jsonResult(await checkPublishStatus(publishId)));
59
+ server.tool('tiktok_business_list_publish_audits', '로컬 TikTok 게시 감사 로그를 최신순으로 조회합니다. 토큰·앱 시크릿·서명 URL 쿼리는 저장하거나 반환하지 않습니다.', { limit: z.number().int().min(1).max(100).default(20) }, async ({ limit }) => jsonResult(listPublishAudits(limit)));
60
+ }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { registerFirebaseTools } from './registers/firebase.js';
3
3
  import { registerAdmobTools } from './registers/admob.js';
4
4
  import { registerPlaystoreTools } from './registers/playstore.js';
5
5
  import { registerIamTools } from './registers/iam.js';
6
+ import { registerBillingTools } from './registers/billing.js';
6
7
  import { registerAppstoreTools } from './registers/appstore.js';
7
8
  import { registerChecksTools } from './registers/checks.js';
8
9
  import { registerAiTools } from './registers/ai.js';
@@ -18,6 +19,7 @@ import { registerGa4Tools } from './registers/ga4.js';
18
19
  import { registerJenkinsTools } from './registers/jenkins.js';
19
20
  import { registerAndroidTools } from './registers/android.js';
20
21
  import { registerVideoTools } from './registers/video.js';
22
+ import { registerTikTokBusinessTools } from './registers/tiktok.js';
21
23
  import { registerPrompts } from './prompts.js';
22
24
  import { registerResources } from './resources.js';
23
25
  /**
@@ -37,6 +39,7 @@ export function buildServer(version) {
37
39
  registerAdmobTools(server);
38
40
  registerPlaystoreTools(server);
39
41
  registerIamTools(server);
42
+ registerBillingTools(server);
40
43
  registerAppstoreTools(server);
41
44
  registerChecksTools(server);
42
45
  registerAiTools(server);
@@ -52,6 +55,7 @@ export function buildServer(version) {
52
55
  registerJenkinsTools(server);
53
56
  registerAndroidTools(server);
54
57
  registerVideoTools(server);
58
+ registerTikTokBusinessTools(server);
55
59
  registerPrompts(server);
56
60
  registerResources(server);
57
61
  return server;
@@ -20,4 +20,6 @@ export interface PublishResult {
20
20
  }
21
21
  /** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
22
22
  export declare function postText(cfg: ThreadsConfig, text: string, imageUrl?: string): Promise<PublishResult>;
23
+ /** public URL의 영상을 Threads 미디어 컨테이너로 만든 뒤 처리 완료 후 게시한다. */
24
+ export declare function postVideo(cfg: ThreadsConfig, videoUrl: string, text?: string, altText?: string): Promise<PublishResult>;
23
25
  export declare function postCarousel(cfg: ThreadsConfig, imageUrls: string[], text: string): Promise<PublishResult>;
@@ -126,6 +126,20 @@ export async function postText(cfg, text, imageUrl) {
126
126
  await waitForContainer(cfg, container.id); // 미디어만 처리 대기
127
127
  return publish(cfg, container.id);
128
128
  }
129
+ /** public URL의 영상을 Threads 미디어 컨테이너로 만든 뒤 처리 완료 후 게시한다. */
130
+ export async function postVideo(cfg, videoUrl, text = '', altText) {
131
+ const params = {
132
+ media_type: 'VIDEO',
133
+ video_url: videoUrl,
134
+ text,
135
+ access_token: cfg.accessToken,
136
+ };
137
+ if (altText)
138
+ params.alt_text = altText;
139
+ const container = await thFetch(cfg.accessToken, '/me/threads', params, 'POST');
140
+ await waitForContainer(cfg, container.id);
141
+ return publish(cfg, container.id);
142
+ }
129
143
  export async function postCarousel(cfg, imageUrls, text) {
130
144
  if (imageUrls.length < 2 || imageUrls.length > 20) {
131
145
  throw new Error(`캐러셀은 2~20장의 이미지가 필요합니다 (받은 수: ${imageUrls.length}).`);