@yoonion/mimi-seed-mcp 0.9.3 → 0.10.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
@@ -57,7 +57,7 @@ npx -y @yoonion/mimi-seed-mcp mimi-seed-playstore-auth # Google Play 서비
57
57
  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
- npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram
60
+ npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram / Threads
61
61
  ```
62
62
 
63
63
  각 자격증명을 **어디서 어떻게 발급받는지**는 [`docs/credentials.md`](../../docs/credentials.md) 참고.
@@ -74,7 +74,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
74
74
 
75
75
  ---
76
76
 
77
- ## 제공 도구 (150+ 개 · 17개 영역)
77
+ ## 제공 도구 (150+ 개 · 18개 영역)
78
78
 
79
79
  | 영역 | 도구 수 | 주요 도구 |
80
80
  |------|---------|-----------|
@@ -90,6 +90,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
90
90
  | Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
91
91
  | Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
92
92
  | BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
93
+ | Threads | 6 | `threads_post` / `threads_post_carousel` / `threads_refresh_token` |
93
94
  | 점검 / 위험 | 4 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `screenshot_validate` / `release_status` |
94
95
  | Instagram | 4 | `instagram_post_image` / `instagram_post_carousel` / `instagram_save_config` |
95
96
  | Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
@@ -15,6 +15,10 @@ const SCOPES = [
15
15
  'https://www.googleapis.com/auth/adwords', // Google Ads API (googleads_* tools)
16
16
  'https://www.googleapis.com/auth/webmasters', // Search Console API (gsc_* tools, 사이트맵 제출 포함)
17
17
  'https://www.googleapis.com/auth/analytics.edit', // GA4 Analytics Admin API (ga4_* tools — property/data stream 생성·조회)
18
+ // GA4 **Data API**(analyticsdata — ga4_run_report)는 analytics.edit 을 받지 않는다.
19
+ // Admin API 전용 스코프이기 때문. 이게 빠져 있으면 property 목록은 보이는데 리포트만
20
+ // 403 으로 막혀서, "API 가 꺼졌나" 로 오진하기 쉽다. (2026-07 실사고)
21
+ 'https://www.googleapis.com/auth/analytics.readonly',
18
22
  ];
19
23
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
20
24
  // rebrand so existing auth sessions don't force a re-login; new writes go to
@@ -1,3 +1,4 @@
1
+ import { metaApiError } from '../lib/meta-auth.js';
1
2
  const BASE = 'https://graph.facebook.com/v21.0';
2
3
  async function fbPost(pageAccessToken, endpoint, params) {
3
4
  const body = new URLSearchParams({ ...params, access_token: pageAccessToken });
@@ -12,21 +13,28 @@ async function fbPost(pageAccessToken, endpoint, params) {
12
13
  json = JSON.parse(text);
13
14
  }
14
15
  catch {
15
- throw new Error(`HTTP ${res.status}: ${text}`);
16
+ throw metaApiError('facebook', res.status, text);
16
17
  }
17
18
  if (json.error) {
18
19
  const err = json.error;
19
- throw new Error(`${err.message} (code ${err.code})`);
20
+ throw metaApiError('facebook', res.status, `${err.message} (code ${err.code})`, err.code);
20
21
  }
21
22
  return json;
22
23
  }
23
24
  async function fbGet(pageAccessToken, endpoint, params = {}) {
24
25
  const qs = new URLSearchParams({ ...params, access_token: pageAccessToken });
25
26
  const res = await fetch(`${BASE}${endpoint}?${qs}`);
26
- const json = await res.json();
27
+ const text = await res.text();
28
+ let json;
29
+ try {
30
+ json = JSON.parse(text);
31
+ }
32
+ catch {
33
+ throw metaApiError('facebook', res.status, text);
34
+ }
27
35
  if (json.error) {
28
36
  const err = json.error;
29
- throw new Error(`${err.message} (code ${err.code})`);
37
+ throw metaApiError('facebook', res.status, `${err.message} (code ${err.code})`, err.code);
30
38
  }
31
39
  return json;
32
40
  }
@@ -92,7 +100,8 @@ export async function listAccessiblePages(userAccessToken) {
92
100
  });
93
101
  const res = await fetch(`${BASE}/me/accounts?${qs}`);
94
102
  const json = await res.json();
95
- if (json.error)
96
- throw new Error(`${json.error.message} (code ${json.error.code})`);
103
+ if (json.error) {
104
+ throw metaApiError('facebook', res.status, `${json.error.message} (code ${json.error.code})`, json.error.code);
105
+ }
97
106
  return json.data ?? [];
98
107
  }
@@ -4,7 +4,11 @@ import os from 'node:os';
4
4
  const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'facebook.json');
5
5
  export function loadFacebookConfig() {
6
6
  try {
7
- return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
7
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
+ if (typeof cfg.pageAccessToken !== 'string' || !cfg.pageAccessToken ||
9
+ typeof cfg.pageId !== 'string' || !cfg.pageId)
10
+ return null;
11
+ return cfg;
8
12
  }
9
13
  catch {
10
14
  return null;
package/dist/ga4/cli.js CHANGED
@@ -53,7 +53,9 @@ runDomainCli({
53
53
  bundleId: flag(p, 'bundle'),
54
54
  }),
55
55
  streams: async (p) => ga4.listDataStreams(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'property')),
56
- report: async (p) => ga4.runReport(await requireAuth(ga4.GA4_SCOPE), requireFlag(p, 'property'), {
56
+ report: async (p) =>
57
+ // Data API 는 analytics.readonly — Admin 스코프(analytics.edit)로는 403 이다
58
+ ga4.runReport(await requireAuth(ga4.GA4_DATA_SCOPE), requireFlag(p, 'property'), {
57
59
  startDate: requireFlag(p, 'start'),
58
60
  endDate: requireFlag(p, 'end'),
59
61
  dimensions: flagList(p, 'dimensions'),
@@ -1,6 +1,15 @@
1
1
  import type { OAuth2Client } from 'google-auth-library';
2
- /** GA4 도구가 요구하는 OAuth 스코프 — requireAuth() pre-flight 검사에 사용. */
2
+ /** Admin API(analyticsadmin property/data stream 생성·조회) 요구하는 스코프. */
3
3
  export declare const GA4_SCOPE = "https://www.googleapis.com/auth/analytics.edit";
4
+ /**
5
+ * Data API(analyticsdata — runReport)가 요구하는 스코프.
6
+ *
7
+ * ⚠️ analytics.edit 은 **Admin API 전용**이라 Data API 가 받아주지 않는다. 둘을 같은
8
+ * 스코프로 묶으면 pre-flight 는 통과시켜 놓고 구글이 403 을 던져서, "API 가 꺼졌나"로
9
+ * 오진하게 된다 — property 목록은 멀쩡히 보이는데 리포트만 막히는 형태라 더 헷갈린다.
10
+ * (2026-07 실사고: 맑음 D1 지표를 뽑으려다 Analytics API 활성화 문제로 오인.)
11
+ */
12
+ export declare const GA4_DATA_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
4
13
  export type Ga4Auth = OAuth2Client;
5
14
  export type DataStreamPlatform = 'web' | 'android' | 'ios';
6
15
  /** '123' | 'accounts/123' → 'accounts/123' */
package/dist/ga4/tools.js CHANGED
@@ -11,8 +11,17 @@ import { google } from 'googleapis';
11
11
  */
12
12
  const admin = () => google.analyticsadmin('v1beta');
13
13
  const data = () => google.analyticsdata('v1beta');
14
- /** GA4 도구가 요구하는 OAuth 스코프 — requireAuth() pre-flight 검사에 사용. */
14
+ /** Admin API(analyticsadmin property/data stream 생성·조회) 요구하는 스코프. */
15
15
  export const GA4_SCOPE = 'https://www.googleapis.com/auth/analytics.edit';
16
+ /**
17
+ * Data API(analyticsdata — runReport)가 요구하는 스코프.
18
+ *
19
+ * ⚠️ analytics.edit 은 **Admin API 전용**이라 Data API 가 받아주지 않는다. 둘을 같은
20
+ * 스코프로 묶으면 pre-flight 는 통과시켜 놓고 구글이 403 을 던져서, "API 가 꺼졌나"로
21
+ * 오진하게 된다 — property 목록은 멀쩡히 보이는데 리포트만 막히는 형태라 더 헷갈린다.
22
+ * (2026-07 실사고: 맑음 D1 지표를 뽑으려다 Analytics API 활성화 문제로 오인.)
23
+ */
24
+ export const GA4_DATA_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
16
25
  // ─── 경로 정규화 (순수 함수 — 테스트 대상) ───
17
26
  /** '123' | 'accounts/123' → 'accounts/123' */
18
27
  export function normalizeAccountName(accountId) {
@@ -1,3 +1,4 @@
1
+ import { metaApiError } from '../lib/meta-auth.js';
1
2
  // 두 가지 Meta API:
2
3
  // IGAA... = Instagram API with Instagram Login (2024 신규) — graph.instagram.com
3
4
  // EAA... = Instagram Graph API via Facebook Login — graph.facebook.com
@@ -24,14 +25,16 @@ async function igFetch(token, endpoint, params, method = 'GET') {
24
25
  const text = await res.text();
25
26
  if (!res.ok) {
26
27
  let msg = text;
28
+ let code;
27
29
  try {
28
30
  const parsed = JSON.parse(text);
29
31
  if (parsed.error) {
32
+ code = parsed.error.code;
30
33
  msg = `${parsed.error.message} (code ${parsed.error.code}${parsed.error.error_subcode ? `/${parsed.error.error_subcode}` : ''})`;
31
34
  }
32
35
  }
33
36
  catch { /* fall through with raw text */ }
34
- throw new Error(`Instagram API ${res.status}: ${msg}`);
37
+ throw metaApiError('instagram', res.status, msg, code);
35
38
  }
36
39
  return JSON.parse(text);
37
40
  }
@@ -4,7 +4,11 @@ import os from 'node:os';
4
4
  const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'instagram.json');
5
5
  export function loadInstagramConfig() {
6
6
  try {
7
- return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
7
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
+ if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
9
+ typeof cfg.userId !== 'string' || !cfg.userId)
10
+ return null;
11
+ return cfg;
8
12
  }
9
13
  catch {
10
14
  return null;
@@ -0,0 +1,12 @@
1
+ export type MetaPlatform = 'facebook' | 'instagram' | 'threads';
2
+ export interface MetaTokenFreshness {
3
+ state: 'unknown' | 'fresh' | 'expiring' | 'expired';
4
+ daysRemaining?: number;
5
+ }
6
+ /** 저장된 만료 시각만 보는 빠른 로컬 판정. 실제 유효성은 provider API가 최종 판정한다. */
7
+ export declare function metaTokenFreshness(expiresAt: string | undefined, now?: number): MetaTokenFreshness;
8
+ export declare function metaExpiryMessage(expiresAt: string | undefined, fix: string): string;
9
+ /** Provider 원문은 유지하되 access token 모양은 절대 오류 메시지에 남기지 않는다. */
10
+ export declare function redactMetaSecrets(message: string): string;
11
+ /** Meta OAuth 만료/철회를 사용자가 바로 복구할 수 있는 명령으로 번역한다. */
12
+ export declare function metaApiError(platform: MetaPlatform, status: number, message: string, code?: number): Error;
@@ -0,0 +1,60 @@
1
+ const LABEL = {
2
+ facebook: 'Facebook',
3
+ instagram: 'Instagram',
4
+ threads: 'Threads',
5
+ };
6
+ const FIX = {
7
+ facebook: 'mimi-seed auth facebook',
8
+ instagram: 'mimi-seed auth instagram',
9
+ threads: 'mimi-seed auth threads',
10
+ };
11
+ const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
12
+ /** 저장된 만료 시각만 보는 빠른 로컬 판정. 실제 유효성은 provider API가 최종 판정한다. */
13
+ export function metaTokenFreshness(expiresAt, now = Date.now()) {
14
+ if (!expiresAt)
15
+ return { state: 'unknown' };
16
+ const expiresMs = Date.parse(expiresAt);
17
+ if (!Number.isFinite(expiresMs))
18
+ return { state: 'unknown' };
19
+ const remainingMs = expiresMs - now;
20
+ if (remainingMs <= 0)
21
+ return { state: 'expired', daysRemaining: 0 };
22
+ const daysRemaining = Math.max(1, Math.ceil(remainingMs / 86_400_000));
23
+ if (remainingMs <= EXPIRING_SOON_MS)
24
+ return { state: 'expiring', daysRemaining };
25
+ return { state: 'fresh', daysRemaining };
26
+ }
27
+ export function metaExpiryMessage(expiresAt, fix) {
28
+ const freshness = metaTokenFreshness(expiresAt);
29
+ if (freshness.state === 'expired')
30
+ return `❌ 토큰 만료 — ${fix}`;
31
+ if (freshness.state === 'expiring') {
32
+ return `⚠️ 토큰 ${freshness.daysRemaining}일 남음 — 지금 갱신 권장: ${fix}`;
33
+ }
34
+ if (freshness.state === 'fresh')
35
+ return `토큰: ${freshness.daysRemaining}일 남음`;
36
+ return `⚠️ 토큰 만료일 미상 — 계정 조회로 검증하고 필요하면 ${fix}`;
37
+ }
38
+ /** Provider 원문은 유지하되 access token 모양은 절대 오류 메시지에 남기지 않는다. */
39
+ export function redactMetaSecrets(message) {
40
+ return message
41
+ .replace(/(["']?access_token["']?\s*[:=]\s*["']?)([^"'&\s,}]+)/gi, '$1[REDACTED]')
42
+ .replace(/(\bBearer\s+)[A-Za-z0-9._|~-]+/gi, '$1[REDACTED]')
43
+ .replace(/\b(?:EAA|IGAA)[A-Za-z0-9_-]+\b/g, '[REDACTED]')
44
+ .replace(/\bTH[A-Za-z0-9_|-]{8,}\b/g, '[REDACTED]');
45
+ }
46
+ /** Meta OAuth 만료/철회를 사용자가 바로 복구할 수 있는 명령으로 번역한다. */
47
+ export function metaApiError(platform, status, message, code) {
48
+ const safeMessage = redactMetaSecrets(message);
49
+ const needsReconnect = code === 190 ||
50
+ status === 401 ||
51
+ /(?:session|access token|token).*(?:expired|invalid|revoked)|error validating access token/i.test(safeMessage);
52
+ if (needsReconnect) {
53
+ return new Error([
54
+ `❌ ${LABEL[platform]} 연결이 만료되었거나 취소되었습니다.`,
55
+ `복구: ${FIX[platform]}`,
56
+ `Meta 응답: ${safeMessage}`,
57
+ ].join('\n'));
58
+ }
59
+ return new Error(`${LABEL[platform]} API ${status}: ${safeMessage}`);
60
+ }
@@ -10,6 +10,8 @@ import { loadCiConfig } from '../ci/config.js';
10
10
  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
+ import { loadThreadsConfig } from '../threads/config.js';
14
+ import { metaTokenFreshness } from '../lib/meta-auth.js';
13
15
  import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
14
16
  import { syncRemoteCredentials } from '../remote-sync.js';
15
17
  import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
@@ -39,6 +41,21 @@ function formatLastRefreshHint(lastMs) {
39
41
  }
40
42
  return { label };
41
43
  }
44
+ function renderMetaConnection(label, config, detail, fix) {
45
+ const padded = label.padEnd(18);
46
+ if (!config)
47
+ return `❌ ${padded} — 미설정 → ${fix} (선택)`;
48
+ const freshness = metaTokenFreshness(config.expiresAt);
49
+ if (freshness.state === 'expired')
50
+ return `❌ ${padded} — 토큰 만료 → ${fix}`;
51
+ if (freshness.state === 'expiring') {
52
+ return `⚠️ ${padded} — ${freshness.daysRemaining}일 후 만료 (${detail}) → ${fix}`;
53
+ }
54
+ if (freshness.state === 'unknown') {
55
+ return `⚠️ ${padded} — 설정됨, 만료일 미상 (${detail}) → 계정 조회 도구로 검증`;
56
+ }
57
+ return `✅ ${padded} — 연결됨 (${detail}, ${freshness.daysRemaining}일 남음)`;
58
+ }
42
59
  // 매니페스트 서비스별 셋업 명령. status/doctor 가 "정확한 다음 명령"으로 안내한다.
43
60
  const MANIFEST_FIX = {
44
61
  oauth: () => 'mimi_seed_auth_start (Google 로그인 — Firebase/AdMob/Play/GSC/GA4)',
@@ -94,7 +111,7 @@ export function registerAuthTools(server) {
94
111
  // ── 전체 연결 상태 진단 ────────────────────────────────────────────────────
95
112
  server.tool('mimi_seed_status', [
96
113
  '⭐ 새 세션을 시작하거나 "뭐가 연결됐지?" 라는 질문엔 이 도구를 먼저 호출하세요.',
97
- '9개 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / BigQuery)',
114
+ '10개 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / BigQuery)',
98
115
  '설정 상태를 한 번에 스캔해 ✅ / ❌ 트래픽 라이트 리포트와 번호 매긴 다음 단계를 반환합니다.',
99
116
  '미설정 서비스마다 어떤 도구를 호출하면 되는지 구체적으로 알려줍니다.',
100
117
  ].join(' '), {}, async () => {
@@ -161,21 +178,14 @@ export function registerAuthTools(server) {
161
178
  }
162
179
  // 7. Facebook
163
180
  const fb = loadFacebookConfig();
164
- if (fb) {
165
- lines.push(`✅ Facebook — 연결됨 (pageId: ${fb.pageId})`);
166
- }
167
- else {
168
- lines.push('❌ Facebook — 미설정 → facebook_save_config (선택)');
169
- }
181
+ lines.push(renderMetaConnection('Facebook', fb, `pageId: ${fb?.pageId ?? ''}`, 'mimi-seed auth facebook'));
170
182
  // 8. Instagram
171
183
  const ig = loadInstagramConfig();
172
- if (ig) {
173
- lines.push(`✅ Instagram — 연결됨 (userId: ${ig.userId})`);
174
- }
175
- else {
176
- lines.push('❌ Instagram 미설정 instagram_save_config (선택)');
177
- }
178
- // 9. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
184
+ lines.push(renderMetaConnection('Instagram', ig, `userId: ${ig?.userId ?? ''}`, 'mimi-seed auth instagram'));
185
+ // 9. Threads
186
+ const threads = loadThreadsConfig();
187
+ lines.push(renderMetaConnection('Threads', threads, `userId: ${threads?.userId ?? ''}`, 'mimi-seed auth threads'));
188
+ // 10. BigQuery resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
179
189
  // 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
180
190
  const bqAuth = resolveBigQueryAuth();
181
191
  if (bqAuth?.source === 'service-account') {
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { loadFacebookConfig, requireFacebookConfig } from '../facebook/config.js';
3
3
  import { connectFacebook } from '../facebook/setup.js';
4
4
  import * as api from '../facebook/api.js';
5
+ import { metaExpiryMessage } from '../lib/meta-auth.js';
5
6
  export function registerFacebookTools(server) {
6
7
  server.tool('facebook_save_config', [
7
8
  'Facebook 페이지 액세스 토큰을 ~/.mimi-seed/facebook.json (mode 0600)에 저장합니다.',
@@ -41,9 +42,6 @@ export function registerFacebookTools(server) {
41
42
  server.tool('facebook_get_page', 'Facebook 페이지 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
42
43
  const cfg = requireFacebookConfig();
43
44
  const page = await api.getPage(cfg);
44
- const remainingDays = cfg.expiresAt
45
- ? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
46
- : null;
47
45
  return {
48
46
  content: [{
49
47
  type: 'text',
@@ -52,13 +50,7 @@ export function registerFacebookTools(server) {
52
50
  page.category ? ` 카테고리: ${page.category}` : '',
53
51
  page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
54
52
  page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
55
- remainingDays !== null
56
- ? remainingDays > 7
57
- ? ` 토큰: ${remainingDays}일 남음`
58
- : remainingDays > 0
59
- ? ` ⚠️ 토큰 ${remainingDays}일 남음 — 갱신 필요`
60
- : ` ❌ 토큰 만료 — facebook_save_config로 재저장`
61
- : '',
53
+ ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth facebook')}`,
62
54
  ].filter(Boolean).join('\n'),
63
55
  }],
64
56
  };
@@ -117,7 +109,7 @@ export function registerFacebookTools(server) {
117
109
  type: 'text',
118
110
  text: [
119
111
  `페이지: ${cfg.pageName ?? '(미확인)'} (${cfg.pageId})`,
120
- cfg.expiresAt ? `토큰 만료(추정): ${cfg.expiresAt.slice(0, 10)}` : '',
112
+ metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth facebook'),
121
113
  ].filter(Boolean).join('\n'),
122
114
  }],
123
115
  };
@@ -78,7 +78,8 @@ export function registerGa4Tools(server) {
78
78
  dimensions: z.string().optional().describe('쉼표 구분 dimension (예: date,country). 생략 시 전체 합계'),
79
79
  metrics: z.string().optional().describe('쉼표 구분 metric (기본 activeUsers,eventCount)'),
80
80
  }, async ({ propertyId, startDate, endDate, dimensions, metrics }) => {
81
- const auth = await requireAuth(ga4Raw.GA4_SCOPE);
81
+ // Data API analytics.readonly 를 요구한다 — Admin 스코프로 검사하면 통과시켜 놓고 403 이 난다
82
+ const auth = await requireAuth(ga4Raw.GA4_DATA_SCOPE);
82
83
  const report = await ga4.runReport(auth, propertyId, {
83
84
  startDate,
84
85
  endDate,
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { requireInstagramConfig } from '../instagram/config.js';
3
3
  import { connectInstagram } from '../instagram/setup.js';
4
4
  import * as api from '../instagram/api.js';
5
+ import { metaExpiryMessage } from '../lib/meta-auth.js';
5
6
  export function registerInstagramTools(server) {
6
7
  server.tool('instagram_save_config', [
7
8
  'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
@@ -22,9 +23,6 @@ export function registerInstagramTools(server) {
22
23
  server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
23
24
  const cfg = requireInstagramConfig();
24
25
  const account = await api.getAccount(cfg);
25
- const remainingDays = cfg.expiresAt
26
- ? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
27
- : null;
28
26
  return {
29
27
  content: [{
30
28
  type: 'text',
@@ -34,13 +32,7 @@ export function registerInstagramTools(server) {
34
32
  account.account_type ? ` 타입: ${account.account_type}` : '',
35
33
  account.followers_count !== undefined ? ` 팔로워: ${account.followers_count.toLocaleString()}` : '',
36
34
  account.media_count !== undefined ? ` 게시물: ${account.media_count}` : '',
37
- remainingDays !== null
38
- ? remainingDays > 7
39
- ? ` 토큰: ${remainingDays}일 남음`
40
- : remainingDays > 0
41
- ? ` ⚠️ 토큰 ${remainingDays}일 남음 — 곧 갱신 필요`
42
- : ` ❌ 토큰 만료됨 — instagram_save_config로 재저장`
43
- : '',
35
+ ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth instagram')}`,
44
36
  ].filter(Boolean).join('\n'),
45
37
  }],
46
38
  };
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerThreadsTools(server: McpServer): void;
@@ -0,0 +1,109 @@
1
+ import { z } from 'zod';
2
+ import { loadThreadsConfig, requireThreadsConfig } from '../threads/config.js';
3
+ import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
4
+ import * as api from '../threads/api.js';
5
+ import { metaExpiryMessage } from '../lib/meta-auth.js';
6
+ export function registerThreadsTools(server) {
7
+ server.tool('threads_save_config', [
8
+ 'Threads 토큰을 ~/.mimi-seed/threads.json (mode 0600)에 저장합니다.',
9
+ 'accessToken은 Threads Graph API long-lived 토큰 (약 60일).',
10
+ 'userId 미입력 시 토큰으로 자동 조회 (GET /me).',
11
+ '저장 직후 토큰 유효성도 자동 검증.',
12
+ 'Instagram 과 별개 계정·별개 토큰입니다 (threads_basic, threads_content_publish 권한 필요).',
13
+ ].join(' '), {
14
+ accessToken: z.string().describe('Threads Graph API long-lived access token (약 60일)'),
15
+ userId: z.string().optional().describe('Threads user ID (생략 시 자동 조회)'),
16
+ assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
17
+ }, async ({ accessToken, userId, assumeIssuedNow }) => {
18
+ // 구현은 threads/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
19
+ const result = await connectThreads(accessToken, userId, assumeIssuedNow);
20
+ return { content: [{ type: 'text', text: result.text }] };
21
+ });
22
+ server.tool('threads_refresh_token', [
23
+ '저장된 Threads long-lived 토큰을 만료 전에 공식 refresh_access_token endpoint로 갱신합니다.',
24
+ '성공하면 새 토큰과 실제 expires_in을 저장하고 계정을 다시 검증합니다.',
25
+ '이미 만료·철회된 토큰은 갱신할 수 없으므로 mimi-seed auth threads로 재연결하세요.',
26
+ ].join(' '), {}, async () => {
27
+ const cfg = requireThreadsConfig();
28
+ const result = await refreshThreadsToken(cfg.accessToken);
29
+ return { content: [{ type: 'text', text: result.text }], isError: !result.ok };
30
+ });
31
+ server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
32
+ const cfg = requireThreadsConfig();
33
+ const account = await api.getAccount(cfg);
34
+ return {
35
+ content: [{
36
+ type: 'text',
37
+ text: [
38
+ `@${account.username}${account.name ? ` (${account.name})` : ''}`,
39
+ ` ID: ${account.id}`,
40
+ account.threads_biography ? ` 소개: ${account.threads_biography}` : '',
41
+ ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
42
+ ].filter(Boolean).join('\n'),
43
+ }],
44
+ };
45
+ });
46
+ server.tool('threads_post', [
47
+ 'Threads에 게시합니다. 텍스트 전용이 기본이며, imageUrl을 주면 이미지 게시.',
48
+ 'text에는 링크·멘션(@)·해시태그를 포함할 수 있고 줄바꿈 \\n 사용. 게시물당 최대 500자.',
49
+ 'imageUrl은 public URL이어야 합니다 (Graph API 제약). 로컬 파일은 미리 S3/R2/Cloudinary 등에 업로드 필요.',
50
+ '2-step API: container 생성(/threads) → threads_publish. 이미지는 처리 완료까지 자동 대기.',
51
+ ].join(' '), {
52
+ text: z.string().max(500).describe('게시할 텍스트 (최대 500자, 멘션/해시태그/줄바꿈 가능)'),
53
+ imageUrl: z.string().url().optional().describe('이미지의 public URL (생략 시 텍스트 전용 게시)'),
54
+ }, async ({ text, imageUrl }) => {
55
+ const cfg = requireThreadsConfig();
56
+ const result = await api.postText(cfg, text, imageUrl);
57
+ return {
58
+ content: [{
59
+ type: 'text',
60
+ text: [
61
+ '✅ 게시 완료',
62
+ ` media_id: ${result.id}`,
63
+ result.permalink ? ` URL: ${result.permalink}` : '',
64
+ ].filter(Boolean).join('\n'),
65
+ }],
66
+ };
67
+ });
68
+ server.tool('threads_post_carousel', [
69
+ '여러 이미지를 캐러셀로 Threads에 게시합니다. 2~20장 (인스타는 10장, Threads는 20장).',
70
+ '모든 이미지는 public URL이어야 함. 각 이미지 처리 완료까지 자동 대기.',
71
+ '3-step API: 각 이미지 children container → carousel container → publish.',
72
+ ].join(' '), {
73
+ imageUrls: z.array(z.string().url()).min(2).max(20).describe('이미지 URL 배열 (2~20장)'),
74
+ text: z.string().max(500).describe('캡션 텍스트 (최대 500자)'),
75
+ }, async ({ imageUrls, text }) => {
76
+ const cfg = requireThreadsConfig();
77
+ const result = await api.postCarousel(cfg, imageUrls, text);
78
+ return {
79
+ content: [{
80
+ type: 'text',
81
+ text: [
82
+ `✅ 캐러셀 ${imageUrls.length}장 게시 완료`,
83
+ ` media_id: ${result.id}`,
84
+ result.permalink ? ` URL: ${result.permalink}` : '',
85
+ ].filter(Boolean).join('\n'),
86
+ }],
87
+ };
88
+ });
89
+ // 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
90
+ server.tool('threads_current_config', '현재 저장된 Threads 연결 설정을 확인합니다 (~/.mimi-seed/threads.json).', {}, async () => {
91
+ const cfg = loadThreadsConfig();
92
+ if (!cfg) {
93
+ return {
94
+ content: [{ type: 'text', text: '❌ Threads 미설정 → threads_save_config 또는 mimi-seed auth threads' }],
95
+ };
96
+ }
97
+ return {
98
+ content: [{
99
+ type: 'text',
100
+ text: [
101
+ '✅ Threads 연결됨',
102
+ ` @${cfg.username ?? '(username 미저장)'}`,
103
+ ` userId: ${cfg.userId}`,
104
+ ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
105
+ ].filter(Boolean).join('\n'),
106
+ }],
107
+ };
108
+ });
109
+ }
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { registerBigqueryTools } from './registers/bigquery.js';
10
10
  import { registerAuthTools } from './registers/auth.js';
11
11
  import { registerCiTools } from './registers/ci.js';
12
12
  import { registerInstagramTools } from './registers/instagram.js';
13
+ import { registerThreadsTools } from './registers/threads.js';
13
14
  import { registerFacebookTools } from './registers/facebook.js';
14
15
  import { registerGoogleAdsTools } from './registers/googleads.js';
15
16
  import { registerGscTools } from './registers/gsc.js';
@@ -42,6 +43,7 @@ export function buildServer(version) {
42
43
  registerAuthTools(server);
43
44
  registerCiTools(server);
44
45
  registerInstagramTools(server);
46
+ registerThreadsTools(server);
45
47
  registerFacebookTools(server);
46
48
  registerGoogleAdsTools(server);
47
49
  registerGscTools(server);
@@ -9,25 +9,29 @@
9
9
  // mimi-seed-social-auth → 무엇을 연결할지 물어봄
10
10
  // mimi-seed-social-auth facebook → Facebook 만
11
11
  // mimi-seed-social-auth instagram → Instagram 만
12
+ // mimi-seed-social-auth threads → Threads 만
13
+ // mimi-seed-social-auth all → 세 플랫폼을 순서대로
12
14
  //
13
- // 주의: connectFacebook / connectInstagram 이 돌려주는 result.text 는 MCP 도구와 공유하는
14
- // 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
15
+ // 주의: connectFacebook / connectInstagram / connectThreads 이 돌려주는 result.text 는 MCP 도구와
16
+ // 공유하는 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
15
17
  import readline from 'node:readline';
16
18
  import { loadFacebookConfig } from '../facebook/config.js';
17
19
  import { loadInstagramConfig } from '../instagram/config.js';
20
+ import { loadThreadsConfig } from '../threads/config.js';
18
21
  import { connectFacebook } from '../facebook/setup.js';
19
22
  import { connectInstagram } from '../instagram/setup.js';
23
+ import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
20
24
  import { resolveLang } from '../lib/lang.js';
21
25
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
22
26
  const ko = {
23
- title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram)',
27
+ title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram / Threads)',
24
28
  already: (label, detail) => ` ✅ ${label} 이미 연결됨 (${detail})`,
25
29
  reconnect: ' 다시 설정할래? (y/N): ',
26
30
  skipped: ' 건너뜀.',
27
31
  cancelled: ' 취소됨.',
28
32
  verifying: ' 🔎 토큰 검증 중...',
29
33
  notSaved: '\n (검증 실패 — 저장하지 않았어.)',
30
- which: '\n 무엇을 연결할까? [f] Facebook [i] Instagram [b] 다: ',
34
+ which: '\n 무엇을 연결할까? [f] Facebook [i] Instagram [t] Threads [a] 전부: ',
31
35
  fbHeader: ' ── Facebook 페이지 ──',
32
36
  fbHowTo: ' Page Access Token 발급:',
33
37
  fbStep1: ' 1. Meta 앱 → Graph API Explorer',
@@ -44,16 +48,27 @@ const ko = {
44
48
  igNote: ' (userId 는 토큰에서 자동 조회돼. 토큰 수명은 약 60일.)',
45
49
  igAskToken: ' Access Token (IGAA… 또는 EAA…): ',
46
50
  igAskUserId: ' Instagram Business Account ID (선택, 엔터 시 자동 조회): ',
51
+ thHeader: ' ── Threads ──',
52
+ thHowTo: ' Threads Graph API long-lived 토큰 발급:',
53
+ thStep1: ' 1. developers.facebook.com → 앱 → "Use cases" 에서 Threads API 추가',
54
+ thStep2: ' 2. 권한: threads_basic, threads_content_publish',
55
+ thStep3: ' 3. Threads 로그인으로 authorize → short-lived → long-lived 토큰 교환',
56
+ thNote: ' (Instagram 과 **별개 계정·별개 토큰**. userId 는 토큰에서 자동 조회돼. 수명 약 60일.)',
57
+ thAskToken: ' Threads Access Token: ',
58
+ thAskUserId: ' Threads user ID (선택, 엔터 시 자동 조회): ',
59
+ thExistingAction: ' [Enter/y] 기존 토큰 자동 갱신 [r] 새 토큰으로 재연결 [n] 건너뛰기: ',
60
+ thRefreshing: ' 🔄 기존 Threads 토큰 갱신 중...',
61
+ thRefreshFallback: ' 자동 갱신에 실패했습니다. 새 토큰을 입력해 재연결할 수 있습니다.',
47
62
  };
48
63
  const en = {
49
- title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram)',
64
+ title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram / Threads)',
50
65
  already: (label, detail) => ` ✅ ${label} already connected (${detail})`,
51
66
  reconnect: ' Set it up again? (y/N): ',
52
67
  skipped: ' Skipped.',
53
68
  cancelled: ' Cancelled.',
54
69
  verifying: ' 🔎 Verifying the token...',
55
70
  notSaved: '\n (Verification failed — nothing was saved.)',
56
- which: '\n What do you want to connect? [f] Facebook [i] Instagram [b] both: ',
71
+ which: '\n What do you want to connect? [f] Facebook [i] Instagram [t] Threads [a] all: ',
57
72
  fbHeader: ' ── Facebook Page ──',
58
73
  fbHowTo: ' Get a Page Access Token:',
59
74
  fbStep1: ' 1. Meta app → Graph API Explorer',
@@ -70,6 +85,17 @@ const en = {
70
85
  igNote: ' (userId is looked up from the token. Tokens last about 60 days.)',
71
86
  igAskToken: ' Access Token (IGAA… or EAA…): ',
72
87
  igAskUserId: ' Instagram Business Account ID (optional, Enter to look it up): ',
88
+ thHeader: ' ── Threads ──',
89
+ thHowTo: ' Get a long-lived Threads Graph API token:',
90
+ thStep1: ' 1. developers.facebook.com → your app → add the Threads API use case',
91
+ thStep2: ' 2. Permissions: threads_basic, threads_content_publish',
92
+ thStep3: ' 3. Authorize with Threads login → exchange short-lived for a long-lived token',
93
+ thNote: ' (A **separate account and token** from Instagram. userId is looked up from the token. ~60 days.)',
94
+ thAskToken: ' Threads Access Token: ',
95
+ thAskUserId: ' Threads user ID (optional, Enter to look it up): ',
96
+ thExistingAction: ' [Enter/y] refresh current token [r] reconnect with a new token [n] skip: ',
97
+ thRefreshing: ' 🔄 Refreshing the existing Threads token...',
98
+ thRefreshFallback: ' Automatic refresh failed. You can reconnect with a new token.',
73
99
  };
74
100
  const M = resolveLang() === 'en' ? en : ko;
75
101
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -145,6 +171,45 @@ async function setupInstagram() {
145
171
  console.log(M.notSaved);
146
172
  return result.ok;
147
173
  }
174
+ async function setupThreads() {
175
+ console.log('');
176
+ console.log(M.thHeader);
177
+ const existing = loadThreadsConfig();
178
+ if (existing) {
179
+ console.log(M.already('Threads', existing.username ?? existing.userId));
180
+ const action = (await ask(M.thExistingAction)).toLowerCase();
181
+ if (action === 'n')
182
+ return true;
183
+ if (action !== 'r') {
184
+ console.log(M.thRefreshing);
185
+ const refreshed = await refreshThreadsToken(existing.accessToken);
186
+ console.log('');
187
+ console.log(indent(refreshed.text));
188
+ if (refreshed.ok)
189
+ return true;
190
+ console.log(M.thRefreshFallback);
191
+ }
192
+ }
193
+ console.log(M.thHowTo);
194
+ console.log(M.thStep1);
195
+ console.log(M.thStep2);
196
+ console.log(M.thStep3);
197
+ console.log(M.thNote);
198
+ console.log('');
199
+ const token = await ask(M.thAskToken);
200
+ if (!token) {
201
+ console.log(M.skipped);
202
+ return true;
203
+ }
204
+ const userId = await ask(M.thAskUserId);
205
+ console.log(M.verifying);
206
+ const result = await connectThreads(token, userId || undefined);
207
+ console.log('');
208
+ console.log(indent(result.text));
209
+ if (!result.ok)
210
+ console.log(M.notSaved);
211
+ return result.ok;
212
+ }
148
213
  function indent(text) {
149
214
  return text
150
215
  .split('\n')
@@ -162,6 +227,15 @@ async function main() {
162
227
  else if (target === 'instagram' || target === 'ig') {
163
228
  ok = await setupInstagram();
164
229
  }
230
+ else if (target === 'threads' || target === 'th') {
231
+ ok = await setupThreads();
232
+ }
233
+ else if (target === 'all' || target === 'meta') {
234
+ const fb = await setupFacebook();
235
+ const ig = await setupInstagram();
236
+ const th = await setupThreads();
237
+ ok = fb && ig && th;
238
+ }
165
239
  else {
166
240
  const which = await ask(M.which);
167
241
  const c = which.toLowerCase();
@@ -169,11 +243,20 @@ async function main() {
169
243
  ok = await setupFacebook();
170
244
  else if (c === 'i')
171
245
  ok = await setupInstagram();
246
+ else if (c === 't')
247
+ ok = await setupThreads();
172
248
  else if (c === 'b') {
249
+ // 기존 b=Facebook+Instagram 동작을 유지한다.
173
250
  const fb = await setupFacebook();
174
251
  const ig = await setupInstagram();
175
252
  ok = fb && ig;
176
253
  }
254
+ else if (c === 'a') {
255
+ const fb = await setupFacebook();
256
+ const ig = await setupInstagram();
257
+ const th = await setupThreads();
258
+ ok = fb && ig && th;
259
+ }
177
260
  else {
178
261
  console.log(M.cancelled);
179
262
  }
@@ -0,0 +1,23 @@
1
+ import type { ThreadsConfig } from './config.js';
2
+ export interface ThreadsAccount {
3
+ id: string;
4
+ username: string;
5
+ name?: string;
6
+ threads_profile_picture_url?: string;
7
+ threads_biography?: string;
8
+ }
9
+ export declare function getAccount(cfg: ThreadsConfig): Promise<ThreadsAccount>;
10
+ export declare function fetchUserId(accessToken: string): Promise<string>;
11
+ export interface RefreshedToken {
12
+ accessToken: string;
13
+ expiresInSeconds: number;
14
+ }
15
+ /** 만료 전 long-lived Threads 토큰을 새 60일 토큰으로 갱신한다. */
16
+ export declare function refreshAccessToken(accessToken: string): Promise<RefreshedToken>;
17
+ export interface PublishResult {
18
+ id: string;
19
+ permalink?: string;
20
+ }
21
+ /** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
22
+ export declare function postText(cfg: ThreadsConfig, text: string, imageUrl?: string): Promise<PublishResult>;
23
+ export declare function postCarousel(cfg: ThreadsConfig, imageUrls: string[], text: string): Promise<PublishResult>;
@@ -0,0 +1,147 @@
1
+ import { metaApiError } from '../lib/meta-auth.js';
2
+ // Threads Graph API. Instagram 과 달리 base 가 하나뿐이라 토큰 prefix 분기가 없다.
3
+ // graph.threads.net/v1.0
4
+ // 게시 흐름은 인스타와 같은 2단계: container 생성(/threads) → publish(/threads_publish).
5
+ // 차이: (1) 텍스트 전용 게시가 1급 시민(media_type=TEXT), (2) 미디어 컨테이너는 처리 시간이
6
+ // 필요해 publish 전에 status 를 폴링해야 한다, (3) 캐러셀 최대 20장(인스타는 10장).
7
+ const BASE = 'https://graph.threads.net/v1.0';
8
+ const AUTH_BASE = 'https://graph.threads.net';
9
+ async function thFetch(token, endpoint, params, method = 'GET') {
10
+ const url = new URL(`${BASE}${endpoint}`);
11
+ let body;
12
+ if (method === 'GET') {
13
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
14
+ }
15
+ else {
16
+ body = new URLSearchParams(params).toString();
17
+ }
18
+ const res = await fetch(url.toString(), {
19
+ method,
20
+ body,
21
+ headers: method === 'POST' ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
22
+ });
23
+ const text = await res.text();
24
+ if (!res.ok) {
25
+ let msg = text;
26
+ let code;
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (parsed.error) {
30
+ code = parsed.error.code;
31
+ msg = `${parsed.error.message} (code ${parsed.error.code}${parsed.error.error_subcode ? `/${parsed.error.error_subcode}` : ''})`;
32
+ }
33
+ }
34
+ catch { /* fall through with raw text */ }
35
+ throw metaApiError('threads', res.status, msg, code);
36
+ }
37
+ return JSON.parse(text);
38
+ }
39
+ export async function getAccount(cfg) {
40
+ return thFetch(cfg.accessToken, `/${cfg.userId}`, {
41
+ fields: 'id,username,name,threads_profile_picture_url,threads_biography',
42
+ access_token: cfg.accessToken,
43
+ });
44
+ }
45
+ // 토큰만으로 Threads user ID 자동 조회 — GET /me?fields=id,username
46
+ export async function fetchUserId(accessToken) {
47
+ const res = await thFetch(accessToken, '/me', { fields: 'id,username', access_token: accessToken });
48
+ if (!res.id) {
49
+ throw new Error('Threads user ID를 조회하지 못했습니다. 토큰에 threads_basic 권한이 있는지 확인하세요.');
50
+ }
51
+ return res.id;
52
+ }
53
+ /** 만료 전 long-lived Threads 토큰을 새 60일 토큰으로 갱신한다. */
54
+ export async function refreshAccessToken(accessToken) {
55
+ const url = new URL(`${AUTH_BASE}/refresh_access_token`);
56
+ url.searchParams.set('grant_type', 'th_refresh_token');
57
+ url.searchParams.set('access_token', accessToken);
58
+ const res = await fetch(url);
59
+ const text = await res.text();
60
+ if (!res.ok) {
61
+ let message = text;
62
+ let code;
63
+ try {
64
+ const parsed = JSON.parse(text);
65
+ if (parsed.error) {
66
+ code = parsed.error.code;
67
+ message = `${parsed.error.message} (code ${parsed.error.code})`;
68
+ }
69
+ }
70
+ catch { /* raw response */ }
71
+ throw metaApiError('threads', res.status, message, code);
72
+ }
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ }
77
+ catch {
78
+ throw new Error('Threads 토큰 갱신 응답을 해석하지 못했습니다. 새 토큰을 발급해 다시 연결하세요.');
79
+ }
80
+ if (!parsed.access_token || !Number.isFinite(parsed.expires_in) || parsed.expires_in <= 0) {
81
+ throw new Error('Threads 토큰 갱신 응답에 access_token 또는 expires_in이 없습니다.');
82
+ }
83
+ return { accessToken: parsed.access_token, expiresInSeconds: parsed.expires_in };
84
+ }
85
+ async function fetchPermalink(cfg, mediaId) {
86
+ try {
87
+ const meta = await thFetch(cfg.accessToken, `/${mediaId}`, { fields: 'permalink', access_token: cfg.accessToken });
88
+ return meta.permalink;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ // 미디어(이미지/비디오/캐러셀) 컨테이너는 서버 처리 후에야 publish 가 된다.
95
+ // status 가 FINISHED 가 될 때까지 폴링한다. 텍스트 컨테이너는 즉시 FINISHED 라 호출자가 생략한다.
96
+ async function waitForContainer(cfg, containerId) {
97
+ const MAX_ATTEMPTS = 20;
98
+ const INTERVAL_MS = 3000; // 최대 ~60초 대기
99
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
100
+ const { status, error_message } = await thFetch(cfg.accessToken, `/${containerId}`, { fields: 'status,error_message', access_token: cfg.accessToken });
101
+ if (status === 'FINISHED')
102
+ return;
103
+ if (status === 'ERROR' || status === 'EXPIRED') {
104
+ throw new Error(`미디어 처리 실패 (${status})${error_message ? `: ${error_message}` : ''}`);
105
+ }
106
+ await new Promise((r) => setTimeout(r, INTERVAL_MS));
107
+ }
108
+ throw new Error('미디어 처리 대기 시간 초과 (60초). 잠시 후 다시 시도하세요.');
109
+ }
110
+ async function publish(cfg, creationId) {
111
+ const published = await thFetch(cfg.accessToken, `/${cfg.userId}/threads_publish`, { creation_id: creationId, access_token: cfg.accessToken }, 'POST');
112
+ return { id: published.id, permalink: await fetchPermalink(cfg, published.id) };
113
+ }
114
+ /** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
115
+ export async function postText(cfg, text, imageUrl) {
116
+ const params = {
117
+ media_type: imageUrl ? 'IMAGE' : 'TEXT',
118
+ text,
119
+ access_token: cfg.accessToken,
120
+ };
121
+ if (imageUrl)
122
+ params.image_url = imageUrl;
123
+ const container = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, params, 'POST');
124
+ if (imageUrl)
125
+ await waitForContainer(cfg, container.id); // 미디어만 처리 대기
126
+ return publish(cfg, container.id);
127
+ }
128
+ export async function postCarousel(cfg, imageUrls, text) {
129
+ if (imageUrls.length < 2 || imageUrls.length > 20) {
130
+ throw new Error(`캐러셀은 2~20장의 이미지가 필요합니다 (받은 수: ${imageUrls.length}).`);
131
+ }
132
+ // Step 1: 각 이미지를 children container 로 생성 (sequential — 부분 실패 시 명확)
133
+ const childIds = [];
134
+ for (const url of imageUrls) {
135
+ const child = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, { media_type: 'IMAGE', image_url: url, is_carousel_item: 'true', access_token: cfg.accessToken }, 'POST');
136
+ childIds.push(child.id);
137
+ }
138
+ // children 이 모두 처리될 때까지 대기 (아무거나 처리 중이면 carousel 생성이 실패한다)
139
+ for (const id of childIds)
140
+ await waitForContainer(cfg, id);
141
+ // Step 2: carousel container 생성
142
+ const carousel = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, { media_type: 'CAROUSEL', children: childIds.join(','), text, access_token: cfg.accessToken }, 'POST');
143
+ // 부모 컨테이너도 비동기로 조립되므로 완료 전에 publish 하지 않는다.
144
+ await waitForContainer(cfg, carousel.id);
145
+ // Step 3: publish
146
+ return publish(cfg, carousel.id);
147
+ }
@@ -0,0 +1,9 @@
1
+ export interface ThreadsConfig {
2
+ accessToken: string;
3
+ userId: string;
4
+ expiresAt?: string;
5
+ username?: string;
6
+ }
7
+ export declare function loadThreadsConfig(): ThreadsConfig | null;
8
+ export declare function saveThreadsConfig(cfg: ThreadsConfig): void;
9
+ export declare function requireThreadsConfig(): ThreadsConfig;
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'threads.json');
5
+ export function loadThreadsConfig() {
6
+ try {
7
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
+ if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
9
+ typeof cfg.userId !== 'string' || !cfg.userId)
10
+ return null;
11
+ return cfg;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export function saveThreadsConfig(cfg) {
18
+ const dir = path.dirname(CONFIG_PATH);
19
+ if (!fs.existsSync(dir)) {
20
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ }
22
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
23
+ if (process.platform !== 'win32') {
24
+ fs.chmodSync(CONFIG_PATH, 0o600);
25
+ }
26
+ }
27
+ export function requireThreadsConfig() {
28
+ const cfg = loadThreadsConfig();
29
+ if (!cfg) {
30
+ throw new Error('Threads 설정이 없습니다.\n' +
31
+ 'threads_save_config 도구로 먼저 설정해주세요.\n' +
32
+ '예: threads_save_config(accessToken="...", userId="...")');
33
+ }
34
+ return cfg;
35
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectResult } from '../facebook/setup.js';
2
+ export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
3
+ /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
4
+ export declare function refreshThreadsToken(currentAccessToken: string): Promise<ConnectResult>;
@@ -0,0 +1,93 @@
1
+ // Threads 연결 — userId 자동 조회 → 토큰 검증 → 검증 성공 시에만 저장.
2
+ // MCP 도구(threads_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
3
+ import { saveThreadsConfig } from './config.js';
4
+ import * as api from './api.js';
5
+ const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
6
+ export async function connectThreads(accessToken, userId, assumeIssuedNow = true) {
7
+ let resolvedUserId = userId;
8
+ if (!resolvedUserId) {
9
+ try {
10
+ resolvedUserId = await api.fetchUserId(accessToken);
11
+ }
12
+ catch (err) {
13
+ return {
14
+ ok: false,
15
+ text: [
16
+ '❌ userId 자동 조회 실패',
17
+ ` ${err.message}`,
18
+ '',
19
+ 'userId를 명시적으로 전달하거나 토큰을 다시 확인하세요.',
20
+ ].join('\n'),
21
+ };
22
+ }
23
+ }
24
+ const expiresAt = assumeIssuedNow
25
+ ? new Date(Date.now() + SIXTY_DAYS_MS).toISOString()
26
+ : undefined;
27
+ try {
28
+ const account = await api.getAccount({ accessToken, userId: resolvedUserId });
29
+ saveThreadsConfig({
30
+ accessToken,
31
+ userId: resolvedUserId,
32
+ expiresAt,
33
+ username: account.username,
34
+ });
35
+ return {
36
+ ok: true,
37
+ text: [
38
+ '✅ Threads 연결 확인 완료',
39
+ ` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
40
+ ` ID: ${account.id}`,
41
+ expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
42
+ ]
43
+ .filter(Boolean)
44
+ .join('\n'),
45
+ };
46
+ }
47
+ catch (err) {
48
+ // 검증 실패 → 저장하지 않는다.
49
+ return {
50
+ ok: false,
51
+ text: [
52
+ '❌ 토큰 검증 실패',
53
+ ` userId: ${resolvedUserId}`,
54
+ ` ${err.message}`,
55
+ '',
56
+ '토큰을 다시 발급받거나 userId를 직접 지정해보세요.',
57
+ ].join('\n'),
58
+ };
59
+ }
60
+ }
61
+ /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
62
+ export async function refreshThreadsToken(currentAccessToken) {
63
+ try {
64
+ const refreshed = await api.refreshAccessToken(currentAccessToken);
65
+ const userId = await api.fetchUserId(refreshed.accessToken);
66
+ const account = await api.getAccount({ accessToken: refreshed.accessToken, userId });
67
+ const expiresAt = new Date(Date.now() + refreshed.expiresInSeconds * 1000).toISOString();
68
+ saveThreadsConfig({
69
+ accessToken: refreshed.accessToken,
70
+ userId,
71
+ username: account.username,
72
+ expiresAt,
73
+ });
74
+ return {
75
+ ok: true,
76
+ text: [
77
+ '✅ Threads 토큰 갱신 완료',
78
+ ` 계정: @${account.username}`,
79
+ ` 새 만료일: ${expiresAt.slice(0, 10)}`,
80
+ ].join('\n'),
81
+ };
82
+ }
83
+ catch (err) {
84
+ return {
85
+ ok: false,
86
+ text: [
87
+ '❌ Threads 토큰 자동 갱신 실패 — 기존 설정은 보존했습니다.',
88
+ ` ${err.message}`,
89
+ ' 복구: mimi-seed auth threads 에서 새 토큰으로 다시 연결하세요.',
90
+ ].join('\n'),
91
+ };
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {