@yoonion/mimi-seed-mcp 0.3.24 → 0.3.26

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,32 @@
1
+ import { JWT } from 'google-auth-library';
2
+ import type { OAuth2Client } from 'google-auth-library';
3
+ export interface ServiceAccountKey {
4
+ type?: string;
5
+ client_email?: string;
6
+ private_key?: string;
7
+ project_id?: string;
8
+ }
9
+ /** 서비스 계정 키 JSON 문자열을 검증 후 저장. 형식이 아니면 throw. */
10
+ export declare function saveBigQueryServiceAccountJson(json: string): ServiceAccountKey;
11
+ /** 저장된 BigQuery 서비스 계정 키. 없거나 형식 오류면 null. */
12
+ export declare function getBigQueryServiceAccountKey(): ServiceAccountKey | null;
13
+ /** 저장된 BigQuery 서비스 계정 키 삭제. 없으면 false. */
14
+ export declare function deleteBigQueryServiceAccountJson(): boolean;
15
+ export type BigQueryAuthSource = 'service-account' | 'user-oauth';
16
+ export interface BigQueryAuth {
17
+ client: OAuth2Client | JWT;
18
+ source: BigQueryAuthSource;
19
+ /** 서비스 계정이면 client_email, 사용자 OAuth면 null. */
20
+ clientEmail: string | null;
21
+ /** 서비스 계정 키의 project_id (있으면). */
22
+ projectId: string | null;
23
+ }
24
+ /**
25
+ * BigQuery 인증 클라이언트 해석. 우선순위:
26
+ * 1. BigQuery 서비스 계정 (~/.mimi-seed/bigquery-service-account.json)
27
+ * 2. 사용자 OAuth (~/.mimi-seed/tokens.json)
28
+ * 둘 다 없으면 null.
29
+ */
30
+ export declare function resolveBigQueryAuth(): BigQueryAuth | null;
31
+ /** BigQuery 인증을 강제. 없으면 안내 메시지와 함께 throw. */
32
+ export declare function requireBigQueryAuth(): BigQueryAuth;
@@ -0,0 +1,100 @@
1
+ import { JWT } from 'google-auth-library';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { getAuthenticatedClient } from './google-auth.js';
6
+ // BigQuery 전용 서비스 계정 키 저장 위치.
7
+ // 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서 면제되므로,
8
+ // 사용자 OAuth 가 `invalid_rapt` 로 갱신 거부되는 환경에서도 안정적으로 동작한다.
9
+ const CONFIG_DIR = path.join(os.homedir(), '.mimi-seed');
10
+ const BQ_SA_PATH = path.join(CONFIG_DIR, 'bigquery-service-account.json');
11
+ // jobs.create(쿼리 작업 생성) + 데이터셋 읽기에 필요한 최소 스코프.
12
+ const BQ_SCOPES = [
13
+ 'https://www.googleapis.com/auth/bigquery.readonly',
14
+ 'https://www.googleapis.com/auth/cloud-platform',
15
+ ];
16
+ function safeReadSa(p) {
17
+ try {
18
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
19
+ if (parsed.type === 'service_account' && parsed.client_email && parsed.private_key) {
20
+ return parsed;
21
+ }
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** 서비스 계정 키 JSON 문자열을 검증 후 저장. 형식이 아니면 throw. */
29
+ export function saveBigQueryServiceAccountJson(json) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(json);
33
+ }
34
+ catch {
35
+ throw new Error('JSON 파싱 실패 — 올바른 서비스 계정 키 파일이 아닙니다.');
36
+ }
37
+ if (parsed.type !== 'service_account' || !parsed.client_email || !parsed.private_key) {
38
+ throw new Error('서비스 계정 키 형식이 아닙니다 (type="service_account", client_email, private_key 필요).');
39
+ }
40
+ if (!fs.existsSync(CONFIG_DIR))
41
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
42
+ fs.writeFileSync(BQ_SA_PATH, json, { mode: 0o600 });
43
+ return parsed;
44
+ }
45
+ /** 저장된 BigQuery 서비스 계정 키. 없거나 형식 오류면 null. */
46
+ export function getBigQueryServiceAccountKey() {
47
+ if (!fs.existsSync(BQ_SA_PATH))
48
+ return null;
49
+ return safeReadSa(BQ_SA_PATH);
50
+ }
51
+ /** 저장된 BigQuery 서비스 계정 키 삭제. 없으면 false. */
52
+ export function deleteBigQueryServiceAccountJson() {
53
+ if (!fs.existsSync(BQ_SA_PATH))
54
+ return false;
55
+ fs.unlinkSync(BQ_SA_PATH);
56
+ return true;
57
+ }
58
+ function makeJwt(sa) {
59
+ return new JWT({ email: sa.client_email, key: sa.private_key, scopes: BQ_SCOPES });
60
+ }
61
+ /**
62
+ * BigQuery 인증 클라이언트 해석. 우선순위:
63
+ * 1. BigQuery 서비스 계정 (~/.mimi-seed/bigquery-service-account.json)
64
+ * 2. 사용자 OAuth (~/.mimi-seed/tokens.json)
65
+ * 둘 다 없으면 null.
66
+ */
67
+ export function resolveBigQueryAuth() {
68
+ const sa = getBigQueryServiceAccountKey();
69
+ if (sa) {
70
+ return {
71
+ client: makeJwt(sa),
72
+ source: 'service-account',
73
+ clientEmail: sa.client_email ?? null,
74
+ projectId: sa.project_id ?? null,
75
+ };
76
+ }
77
+ const oauth = getAuthenticatedClient();
78
+ if (oauth) {
79
+ return { client: oauth, source: 'user-oauth', clientEmail: null, projectId: null };
80
+ }
81
+ return null;
82
+ }
83
+ const BQ_AUTH_HINT = [
84
+ '❌ BigQuery 인증이 설정되지 않았어.',
85
+ '',
86
+ '방법 1 — 서비스 계정 (권장: Google Workspace 재인증 정책의 영향을 받지 않음):',
87
+ ' npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth',
88
+ '',
89
+ '방법 2 — Google 계정 OAuth:',
90
+ ' npx -y @yoonion/mimi-seed-mcp mimi-seed-auth',
91
+ '',
92
+ '그 다음에 다시 물어봐줘.',
93
+ ].join('\n');
94
+ /** BigQuery 인증을 강제. 없으면 안내 메시지와 함께 throw. */
95
+ export function requireBigQueryAuth() {
96
+ const auth = resolveBigQueryAuth();
97
+ if (!auth)
98
+ throw new Error(BQ_AUTH_HINT);
99
+ return auth;
100
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import readline from 'node:readline';
3
+ import fs from 'node:fs';
4
+ import { saveBigQueryServiceAccountJson, getBigQueryServiceAccountKey, } from './bigquery-auth.js';
5
+ import * as bigquery from '../bigquery/tools.js';
6
+ import { JWT } from 'google-auth-library';
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ const ask = (q) => new Promise((r) => rl.question(q, r));
9
+ async function main() {
10
+ console.log('');
11
+ console.log(' 🤖 Mimi Seed — BigQuery 서비스 계정 연결');
12
+ console.log('');
13
+ console.log(' 서비스 계정 인증은 Google Workspace 의 재인증(reauth) 정책에서');
14
+ console.log(' 면제되므로, OAuth 가 invalid_rapt 로 막히는 환경에서도 동작해.');
15
+ console.log('');
16
+ const existing = getBigQueryServiceAccountKey();
17
+ if (existing) {
18
+ console.log(` ✅ 이미 연결됨 (${existing.client_email})`);
19
+ const answer = await ask(' 다시 설정할래? (y/N): ');
20
+ if (answer.toLowerCase() !== 'y') {
21
+ rl.close();
22
+ return;
23
+ }
24
+ }
25
+ console.log(' BigQuery 를 읽을 수 있는 서비스 계정 키 JSON 이 필요해:');
26
+ console.log(' 1. GCP Console → IAM & Admin → Service Accounts');
27
+ console.log(' 2. 서비스 계정 선택(또는 생성) → Keys → Add Key → JSON');
28
+ console.log(' 3. 그 서비스 계정에 프로젝트 IAM 역할 부여:');
29
+ console.log(' • roles/bigquery.jobUser (쿼리 작업 생성)');
30
+ console.log(' • roles/bigquery.dataViewer (데이터셋 읽기)');
31
+ console.log(' 4. 다운로드한 JSON 파일 경로를 입력해');
32
+ console.log('');
33
+ const jsonPath = await ask(' 서비스 계정 JSON 파일 경로: ');
34
+ const trimmedPath = jsonPath.trim().replace(/^["']|["']$/g, '');
35
+ if (!fs.existsSync(trimmedPath)) {
36
+ console.log(` ❌ 파일 없음: ${trimmedPath}`);
37
+ rl.close();
38
+ process.exit(1);
39
+ }
40
+ const json = fs.readFileSync(trimmedPath, 'utf-8');
41
+ let parsed;
42
+ try {
43
+ parsed = saveBigQueryServiceAccountJson(json);
44
+ }
45
+ catch (e) {
46
+ console.log(` ❌ ${e instanceof Error ? e.message : String(e)}`);
47
+ rl.close();
48
+ process.exit(1);
49
+ }
50
+ console.log('');
51
+ console.log(` ✅ 저장 완료! (${parsed.client_email})`);
52
+ if (parsed.project_id)
53
+ console.log(` 서비스 계정 프로젝트: ${parsed.project_id}`);
54
+ console.log('');
55
+ // 선택적 연결 테스트 — projectId 를 입력하면 datasets.list 로 권한 확인.
56
+ const testProject = await ask(` 연결 테스트할 GCP 프로젝트 ID (엔터 시 건너뜀${parsed.project_id ? `, 기본 ${parsed.project_id}` : ''}): `);
57
+ const projectId = testProject.trim() || parsed.project_id || '';
58
+ if (projectId) {
59
+ console.log(` 🔎 ${projectId} 데이터셋 조회 중...`);
60
+ try {
61
+ const jwt = new JWT({
62
+ email: parsed.client_email,
63
+ key: parsed.private_key,
64
+ scopes: [
65
+ 'https://www.googleapis.com/auth/bigquery.readonly',
66
+ 'https://www.googleapis.com/auth/cloud-platform',
67
+ ],
68
+ });
69
+ const datasets = await bigquery.listDatasets(jwt, projectId);
70
+ console.log(` ✅ 접근 OK — 데이터셋 ${datasets.length}개`);
71
+ for (const d of datasets.slice(0, 10))
72
+ console.log(` • ${d.datasetId} (${d.location})`);
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : String(e);
76
+ console.log(` ⚠️ 접근 실패: ${msg}`);
77
+ console.log('');
78
+ console.log(' IAM 역할이 없으면 아래를 실행해 (프로젝트 소유자 계정에서):');
79
+ console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
80
+ console.log(` --member="serviceAccount:${parsed.client_email}" --role="roles/bigquery.jobUser"`);
81
+ console.log(` gcloud projects add-iam-policy-binding ${projectId} \\`);
82
+ console.log(` --member="serviceAccount:${parsed.client_email}" --role="roles/bigquery.dataViewer"`);
83
+ }
84
+ }
85
+ console.log('');
86
+ console.log(' 이제 Claude Code 에서:');
87
+ console.log(' "BigQuery 로 GA4 트래픽 분석해줘"');
88
+ console.log('');
89
+ rl.close();
90
+ }
91
+ main();
@@ -1,4 +1,4 @@
1
- export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
1
+ export type AuthErrorCode = 'UNAUTHENTICATED' | 'NO_REFRESH_TOKEN' | 'INVALID_GRANT' | 'RAPT_REQUIRED' | 'INVALID_CLIENT' | 'UNAUTHORIZED_CLIENT' | 'REFRESH_NETWORK_ERROR' | 'REFRESH_UNKNOWN' | 'CALLBACK_PORT_IN_USE' | 'CALLBACK_TIMEOUT' | 'BROWSER_OPEN_FAILED' | 'USER_DENIED' | 'CODE_EXCHANGE_FAILED' | 'TOKEN_RESPONSE_INVALID';
2
2
  export interface AuthErrorPayload {
3
3
  code: AuthErrorCode;
4
4
  message: string;
@@ -28,6 +28,17 @@ export function classifyError(e, ctx) {
28
28
  cause: raw,
29
29
  };
30
30
  }
31
+ if (oauthCode === 'invalid_rapt' || oauthCode === 'rapt_required') {
32
+ return {
33
+ code: 'RAPT_REQUIRED',
34
+ message: 'Google Workspace 재인증(reauth) 정책으로 토큰 갱신이 거부되었습니다 (invalid_rapt).',
35
+ hint: 'mimi-seed-auth 로 재로그인하거나, 재인증 정책의 영향을 받지 않는 서비스 계정 인증을 사용하세요 ' +
36
+ '(BigQuery: mimi-seed-bigquery-auth).',
37
+ retriable: false,
38
+ needsReauth: true,
39
+ cause: raw,
40
+ };
41
+ }
31
42
  if (oauthCode === 'invalid_client') {
32
43
  return {
33
44
  code: 'INVALID_CLIENT',
@@ -128,7 +139,7 @@ function extractOAuthErrorCode(e, raw) {
128
139
  return dataErr;
129
140
  }
130
141
  // message에 'invalid_grant' 같은 표준 코드가 박혀 있는 경우
131
- const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type)\b/);
142
+ const m = raw.match(/\b(invalid_grant|invalid_client|invalid_request|unauthorized_client|access_denied|unsupported_grant_type|invalid_rapt|rapt_required)\b/);
132
143
  return m?.[1];
133
144
  }
134
145
  function isNetworkError(e, raw) {
@@ -1,5 +1,7 @@
1
- import type { OAuth2Client } from 'google-auth-library';
2
- export declare function runQuery(auth: OAuth2Client, projectId: string, query: string, maxResults?: number): Promise<{
1
+ import type { OAuth2Client, JWT } from 'google-auth-library';
2
+ /** BigQuery 호출에 쓰이는 인증 클라이언트 사용자 OAuth 또는 서비스 계정 JWT. */
3
+ export type BigQueryAuthClient = OAuth2Client | JWT;
4
+ export declare function runQuery(auth: BigQueryAuthClient, projectId: string, query: string, maxResults?: number): Promise<{
3
5
  jobComplete: boolean | null | undefined;
4
6
  totalRows: string | null | undefined;
5
7
  schema: {
@@ -10,15 +12,15 @@ export declare function runQuery(auth: OAuth2Client, projectId: string, query: s
10
12
  [k: string]: any;
11
13
  }[];
12
14
  }>;
13
- export declare function listDatasets(auth: OAuth2Client, projectId: string): Promise<{
15
+ export declare function listDatasets(auth: BigQueryAuthClient, projectId: string): Promise<{
14
16
  datasetId: string | null | undefined;
15
17
  location: string | undefined;
16
18
  }[]>;
17
- export declare function listTables(auth: OAuth2Client, projectId: string, datasetId: string): Promise<{
19
+ export declare function listTables(auth: BigQueryAuthClient, projectId: string, datasetId: string): Promise<{
18
20
  tableId: string | null | undefined;
19
21
  type: string | undefined;
20
22
  }[]>;
21
- export declare function getTableSchema(auth: OAuth2Client, projectId: string, datasetId: string, tableId: string): Promise<{
23
+ export declare function getTableSchema(auth: BigQueryAuthClient, projectId: string, datasetId: string, tableId: string): Promise<{
22
24
  tableId: string;
23
25
  schema: {
24
26
  name: string | null | undefined;
@@ -0,0 +1,16 @@
1
+ import type { FacebookConfig } from './config.js';
2
+ export interface FacebookPage {
3
+ id: string;
4
+ name: string;
5
+ category?: string;
6
+ followers_count?: number;
7
+ fan_count?: number;
8
+ }
9
+ export interface PostResult {
10
+ id: string;
11
+ permalink?: string;
12
+ }
13
+ export declare function getPage(cfg: FacebookConfig): Promise<FacebookPage>;
14
+ export declare function postPhoto(cfg: FacebookConfig, imageUrl: string, caption: string): Promise<PostResult>;
15
+ export declare function postMultiPhoto(cfg: FacebookConfig, imageUrls: string[], caption: string): Promise<PostResult>;
16
+ export declare function listAccessiblePages(userAccessToken: string): Promise<FacebookPage[]>;
@@ -0,0 +1,98 @@
1
+ const BASE = 'https://graph.facebook.com/v21.0';
2
+ async function fbPost(pageAccessToken, endpoint, params) {
3
+ const body = new URLSearchParams({ ...params, access_token: pageAccessToken });
4
+ const res = await fetch(`${BASE}${endpoint}`, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
7
+ body: body.toString(),
8
+ });
9
+ const text = await res.text();
10
+ let json;
11
+ try {
12
+ json = JSON.parse(text);
13
+ }
14
+ catch {
15
+ throw new Error(`HTTP ${res.status}: ${text}`);
16
+ }
17
+ if (json.error) {
18
+ const err = json.error;
19
+ throw new Error(`${err.message} (code ${err.code})`);
20
+ }
21
+ return json;
22
+ }
23
+ async function fbGet(pageAccessToken, endpoint, params = {}) {
24
+ const qs = new URLSearchParams({ ...params, access_token: pageAccessToken });
25
+ const res = await fetch(`${BASE}${endpoint}?${qs}`);
26
+ const json = await res.json();
27
+ if (json.error) {
28
+ const err = json.error;
29
+ throw new Error(`${err.message} (code ${err.code})`);
30
+ }
31
+ return json;
32
+ }
33
+ export async function getPage(cfg) {
34
+ const data = await fbGet(cfg.pageAccessToken, `/${cfg.pageId}`, {
35
+ fields: 'id,name,category,followers_count,fan_count',
36
+ });
37
+ return data;
38
+ }
39
+ // Step 1: upload photo as unpublished, returns photo ID
40
+ async function uploadUnpublishedPhoto(cfg, imageUrl) {
41
+ const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/photos`, {
42
+ url: imageUrl,
43
+ published: 'false',
44
+ });
45
+ return result.id;
46
+ }
47
+ export async function postPhoto(cfg, imageUrl, caption) {
48
+ const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/photos`, {
49
+ url: imageUrl,
50
+ message: caption,
51
+ published: 'true',
52
+ });
53
+ const postId = result.post_id ?? result.id;
54
+ let permalink;
55
+ try {
56
+ const info = await fbGet(cfg.pageAccessToken, `/${postId}`, { fields: 'permalink_url' });
57
+ permalink = info.permalink_url;
58
+ }
59
+ catch { /* best-effort */ }
60
+ return { id: postId, permalink };
61
+ }
62
+ export async function postMultiPhoto(cfg, imageUrls, caption) {
63
+ if (imageUrls.length < 2 || imageUrls.length > 10) {
64
+ throw new Error('이미지는 2~10장이어야 합니다.');
65
+ }
66
+ // Step 1: upload each as unpublished photo
67
+ const photoIds = [];
68
+ for (const url of imageUrls) {
69
+ const id = await uploadUnpublishedPhoto(cfg, url);
70
+ photoIds.push(id);
71
+ }
72
+ // Step 2: create feed post with all photos attached
73
+ const attachedMedia = photoIds.map(id => JSON.stringify({ media_fbid: id }));
74
+ const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/feed`, {
75
+ message: caption,
76
+ attached_media: `[${attachedMedia.join(',')}]`,
77
+ });
78
+ const postId = result.id;
79
+ let permalink;
80
+ try {
81
+ const info = await fbGet(cfg.pageAccessToken, `/${postId}`, { fields: 'permalink_url' });
82
+ permalink = info.permalink_url;
83
+ }
84
+ catch { /* best-effort */ }
85
+ return { id: postId, permalink };
86
+ }
87
+ // Fetch all pages accessible with a User Access Token
88
+ export async function listAccessiblePages(userAccessToken) {
89
+ const qs = new URLSearchParams({
90
+ fields: 'id,name,category',
91
+ access_token: userAccessToken,
92
+ });
93
+ const res = await fetch(`${BASE}/me/accounts?${qs}`);
94
+ const json = await res.json();
95
+ if (json.error)
96
+ throw new Error(`${json.error.message} (code ${json.error.code})`);
97
+ return json.data ?? [];
98
+ }
@@ -0,0 +1,9 @@
1
+ export interface FacebookConfig {
2
+ pageAccessToken: string;
3
+ pageId: string;
4
+ pageName?: string;
5
+ expiresAt?: string;
6
+ }
7
+ export declare function loadFacebookConfig(): FacebookConfig | null;
8
+ export declare function saveFacebookConfig(cfg: FacebookConfig): void;
9
+ export declare function requireFacebookConfig(): FacebookConfig;
@@ -0,0 +1,31 @@
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', 'facebook.json');
5
+ export function loadFacebookConfig() {
6
+ try {
7
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export function saveFacebookConfig(cfg) {
14
+ const dir = path.dirname(CONFIG_PATH);
15
+ if (!fs.existsSync(dir)) {
16
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ }
18
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
19
+ if (process.platform !== 'win32') {
20
+ fs.chmodSync(CONFIG_PATH, 0o600);
21
+ }
22
+ }
23
+ export function requireFacebookConfig() {
24
+ const cfg = loadFacebookConfig();
25
+ if (!cfg) {
26
+ throw new Error('Facebook 설정이 없습니다.\n' +
27
+ 'facebook_save_config 도구로 먼저 설정해주세요.\n' +
28
+ '예: facebook_save_config(pageAccessToken="EAA...", pageId="...")');
29
+ }
30
+ return cfg;
31
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { registerBigqueryTools } from './registers/bigquery.js';
12
12
  import { registerAuthTools } from './registers/auth.js';
13
13
  import { registerCiTools } from './registers/ci.js';
14
14
  import { registerInstagramTools } from './registers/instagram.js';
15
+ import { registerFacebookTools } from './registers/facebook.js';
15
16
  import { registerPrompts } from './prompts.js';
16
17
  import { registerResources } from './resources.js';
17
18
  const server = new McpServer({
@@ -29,6 +30,7 @@ registerBigqueryTools(server);
29
30
  registerAuthTools(server);
30
31
  registerCiTools(server);
31
32
  registerInstagramTools(server);
33
+ registerFacebookTools(server);
32
34
  registerPrompts(server);
33
35
  registerResources(server);
34
36
  // `npx -y @yoonion/mimi-seed-mcp <subcommand>` 처리.
@@ -39,6 +41,7 @@ const SUBCOMMANDS = {
39
41
  'mimi-seed-auth': () => import('./auth/cli.js'),
40
42
  'mimi-seed-playstore-auth': () => import('./auth/playstore-setup-cli.js'),
41
43
  'mimi-seed-appstore-auth': () => import('./appstore/setup-cli.js'),
44
+ 'mimi-seed-bigquery-auth': () => import('./auth/bigquery-setup-cli.js'),
42
45
  };
43
46
  async function main() {
44
47
  const sub = process.argv[2];
@@ -1,38 +1,130 @@
1
1
  import { z } from 'zod';
2
2
  import * as bigquery from '../bigquery/tools.js';
3
- import { requireAuth } from '../helpers.js';
3
+ import { requireBigQueryAuth, resolveBigQueryAuth } from '../auth/bigquery-auth.js';
4
+ /**
5
+ * BigQuery 에러를 사람이 읽을 수 있게 가공.
6
+ * 403(accessDenied)은 인증 자체는 됐으나 IAM 역할이 없는 경우 — 어떤 역할을
7
+ * 어디에 부여해야 하는지 정확히 안내한다.
8
+ */
9
+ function describeBqError(e, auth, projectId) {
10
+ const msg = e instanceof Error ? e.message : String(e);
11
+ const obj = (e && typeof e === 'object' ? e : {});
12
+ const status = (obj.code ?? obj.status);
13
+ const is403 = status === 403 || status === '403' || /accessDenied|does not have|permission/i.test(msg);
14
+ if (is403) {
15
+ const who = auth.clientEmail
16
+ ? `서비스 계정 ${auth.clientEmail}`
17
+ : '현재 OAuth 사용자 계정';
18
+ const lines = [
19
+ `❌ BigQuery 접근 거부 (project: ${projectId})`,
20
+ ` ${msg}`,
21
+ '',
22
+ `${who}에 다음 IAM 역할이 필요해 — 프로젝트 ${projectId}에 부여:`,
23
+ ' • roles/bigquery.jobUser (쿼리 작업 생성)',
24
+ ' • roles/bigquery.dataViewer (데이터셋 읽기)',
25
+ ];
26
+ if (auth.clientEmail) {
27
+ lines.push('', 'gcloud 로 부여 (프로젝트 소유자 계정에서 1회):', ` gcloud projects add-iam-policy-binding ${projectId} \\`, ` --member="serviceAccount:${auth.clientEmail}" --role="roles/bigquery.jobUser"`, ` gcloud projects add-iam-policy-binding ${projectId} \\`, ` --member="serviceAccount:${auth.clientEmail}" --role="roles/bigquery.dataViewer"`);
28
+ }
29
+ return lines.join('\n');
30
+ }
31
+ // 토큰 만료/재인증 류 — 서비스 계정 등록 권유
32
+ if (/invalid_rapt|rapt_required|invalid_grant|reauth/i.test(msg)) {
33
+ return [
34
+ `❌ BigQuery 인증 갱신 실패: ${msg}`,
35
+ '',
36
+ 'Google Workspace 재인증 정책으로 OAuth 토큰 갱신이 막힌 상태일 수 있어.',
37
+ '서비스 계정은 이 정책의 영향을 받지 않아 — 등록 권장:',
38
+ ' npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth',
39
+ ].join('\n');
40
+ }
41
+ return `❌ BigQuery 오류: ${msg}`;
42
+ }
43
+ function errResult(text) {
44
+ return { content: [{ type: 'text', text }], isError: true };
45
+ }
4
46
  export function registerBigqueryTools(server) {
5
- server.tool('bigquery_run_query', 'BigQuery SQL 쿼리 실행 (SELECT). GA4 analytics_* 테이블 분석에 사용.', {
47
+ server.tool('bigquery_run_query', 'BigQuery SQL 쿼리 실행 (SELECT). GA4 analytics_* 테이블 분석에 사용. ' +
48
+ '서비스 계정(권장) 또는 사용자 OAuth 로 인증.', {
6
49
  projectId: z.string().describe('GCP 프로젝트 ID (예: ads-coffee)'),
7
50
  query: z.string().describe('실행할 StandardSQL 쿼리'),
8
51
  maxResults: z.number().optional().describe('최대 행 수 (기본 1000)'),
9
52
  }, async ({ projectId, query, maxResults }) => {
10
- const auth = requireAuth();
11
- const result = await bigquery.runQuery(auth, projectId, query, maxResults ?? 1000);
12
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
53
+ const auth = requireBigQueryAuth();
54
+ try {
55
+ const result = await bigquery.runQuery(auth.client, projectId, query, maxResults ?? 1000);
56
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
57
+ }
58
+ catch (e) {
59
+ return errResult(describeBqError(e, auth, projectId));
60
+ }
13
61
  });
14
62
  server.tool('bigquery_list_datasets', 'BigQuery 프로젝트의 데이터셋 목록 조회', {
15
63
  projectId: z.string().describe('GCP 프로젝트 ID'),
16
64
  }, async ({ projectId }) => {
17
- const auth = requireAuth();
18
- const datasets = await bigquery.listDatasets(auth, projectId);
19
- return { content: [{ type: 'text', text: JSON.stringify(datasets, null, 2) }] };
65
+ const auth = requireBigQueryAuth();
66
+ try {
67
+ const datasets = await bigquery.listDatasets(auth.client, projectId);
68
+ return { content: [{ type: 'text', text: JSON.stringify(datasets, null, 2) }] };
69
+ }
70
+ catch (e) {
71
+ return errResult(describeBqError(e, auth, projectId));
72
+ }
20
73
  });
21
74
  server.tool('bigquery_list_tables', 'BigQuery 데이터셋의 테이블 목록 조회', {
22
75
  projectId: z.string().describe('GCP 프로젝트 ID'),
23
76
  datasetId: z.string().describe('데이터셋 ID (예: analytics_530080532)'),
24
77
  }, async ({ projectId, datasetId }) => {
25
- const auth = requireAuth();
26
- const tables = await bigquery.listTables(auth, projectId, datasetId);
27
- return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
78
+ const auth = requireBigQueryAuth();
79
+ try {
80
+ const tables = await bigquery.listTables(auth.client, projectId, datasetId);
81
+ return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
82
+ }
83
+ catch (e) {
84
+ return errResult(describeBqError(e, auth, projectId));
85
+ }
28
86
  });
29
87
  server.tool('bigquery_get_table_schema', 'BigQuery 테이블 스키마(컬럼 정보) 조회', {
30
88
  projectId: z.string().describe('GCP 프로젝트 ID'),
31
89
  datasetId: z.string().describe('데이터셋 ID'),
32
90
  tableId: z.string().describe('테이블 ID'),
33
91
  }, async ({ projectId, datasetId, tableId }) => {
34
- const auth = requireAuth();
35
- const schema = await bigquery.getTableSchema(auth, projectId, datasetId, tableId);
36
- return { content: [{ type: 'text', text: JSON.stringify(schema, null, 2) }] };
92
+ const auth = requireBigQueryAuth();
93
+ try {
94
+ const schema = await bigquery.getTableSchema(auth.client, projectId, datasetId, tableId);
95
+ return { content: [{ type: 'text', text: JSON.stringify(schema, null, 2) }] };
96
+ }
97
+ catch (e) {
98
+ return errResult(describeBqError(e, auth, projectId));
99
+ }
100
+ });
101
+ server.tool('bigquery_auth_status', '현재 BigQuery 인증 상태 조회 — 어떤 인증(서비스 계정/OAuth)이 사용되는지 확인', {}, async () => {
102
+ const auth = resolveBigQueryAuth();
103
+ if (!auth) {
104
+ return {
105
+ content: [
106
+ {
107
+ type: 'text',
108
+ text: JSON.stringify({
109
+ authenticated: false,
110
+ hint: 'mimi-seed-bigquery-auth (서비스 계정) 또는 mimi-seed-auth (OAuth) 로 인증 필요',
111
+ }, null, 2),
112
+ },
113
+ ],
114
+ };
115
+ }
116
+ return {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: JSON.stringify({
121
+ authenticated: true,
122
+ source: auth.source,
123
+ clientEmail: auth.clientEmail,
124
+ serviceAccountProjectId: auth.projectId,
125
+ }, null, 2),
126
+ },
127
+ ],
128
+ };
37
129
  });
38
130
  }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerFacebookTools(server: McpServer): void;
@@ -0,0 +1,195 @@
1
+ import { z } from 'zod';
2
+ import { loadFacebookConfig, requireFacebookConfig, saveFacebookConfig } from '../facebook/config.js';
3
+ import * as api from '../facebook/api.js';
4
+ export function registerFacebookTools(server) {
5
+ server.tool('facebook_save_config', [
6
+ 'Facebook 페이지 액세스 토큰을 ~/.mimi-seed/facebook.json (mode 0600)에 저장합니다.',
7
+ 'pageAccessToken: Graph API Explorer 또는 /me/accounts로 발급한 Page Access Token (EAA...).',
8
+ 'pageId 미입력 시 토큰으로 자동 조회 (/me → id 필드).',
9
+ '저장 직후 페이지 정보를 조회해 토큰 유효성도 자동 검증.',
10
+ ].join(' '), {
11
+ pageAccessToken: z.string().describe('Facebook Page Access Token (EAA..., long-lived 권장)'),
12
+ pageId: z.string().optional().describe('Facebook Page ID (생략 시 토큰에서 자동 조회)'),
13
+ }, async ({ pageAccessToken, pageId }) => {
14
+ // Resolve pageId
15
+ let resolvedPageId = pageId;
16
+ if (!resolvedPageId) {
17
+ try {
18
+ const me = await api.listAccessiblePages(pageAccessToken);
19
+ if (me.length === 0) {
20
+ // Try /me directly (already a Page Access Token)
21
+ const res = await fetch(`https://graph.facebook.com/v21.0/me?fields=id,name&access_token=${pageAccessToken}`);
22
+ const data = await res.json();
23
+ if (data.error)
24
+ throw new Error(`${data.error.message} (code ${data.error.code})`);
25
+ resolvedPageId = data.id;
26
+ }
27
+ else if (me.length === 1) {
28
+ resolvedPageId = me[0].id;
29
+ }
30
+ else {
31
+ const list = me.map(p => ` • ${p.name} (${p.id})`).join('\n');
32
+ return {
33
+ content: [{
34
+ type: 'text',
35
+ text: [
36
+ '여러 페이지에 접근 가능합니다. pageId를 명시해주세요:',
37
+ list,
38
+ ].join('\n'),
39
+ }],
40
+ };
41
+ }
42
+ }
43
+ catch (err) {
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: `❌ pageId 자동 조회 실패: ${err.message}`,
48
+ }],
49
+ };
50
+ }
51
+ }
52
+ try {
53
+ const cfg = { pageAccessToken, pageId: resolvedPageId };
54
+ const page = await api.getPage(cfg);
55
+ saveFacebookConfig({
56
+ pageAccessToken,
57
+ pageId: resolvedPageId,
58
+ pageName: page.name,
59
+ expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
60
+ });
61
+ return {
62
+ content: [{
63
+ type: 'text',
64
+ text: [
65
+ `✅ Facebook 페이지 연결 완료`,
66
+ ` 페이지: ${page.name}`,
67
+ ` ID: ${page.id}`,
68
+ page.category ? ` 카테고리: ${page.category}` : '',
69
+ page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
70
+ page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
71
+ ].filter(Boolean).join('\n'),
72
+ }],
73
+ };
74
+ }
75
+ catch (err) {
76
+ return {
77
+ content: [{
78
+ type: 'text',
79
+ text: [
80
+ `❌ 토큰 검증 실패`,
81
+ ` ${err.message}`,
82
+ ``,
83
+ `Page Access Token을 다시 확인하거나 facebook_list_pages로 페이지 목록을 조회하세요.`,
84
+ ].join('\n'),
85
+ }],
86
+ };
87
+ }
88
+ });
89
+ server.tool('facebook_list_pages', 'User Access Token으로 접근 가능한 Facebook 페이지 목록을 조회합니다. 페이지별 Page Access Token도 함께 반환.', {
90
+ userAccessToken: z.string().describe('Facebook User Access Token (EAA...)'),
91
+ }, async ({ userAccessToken }) => {
92
+ const pages = await api.listAccessiblePages(userAccessToken);
93
+ if (pages.length === 0) {
94
+ return {
95
+ content: [{
96
+ type: 'text',
97
+ text: '접근 가능한 페이지가 없습니다. pages_show_list 권한이 있는 토큰인지 확인하세요.',
98
+ }],
99
+ };
100
+ }
101
+ return {
102
+ content: [{
103
+ type: 'text',
104
+ text: [
105
+ `접근 가능한 페이지 ${pages.length}개:`,
106
+ ...pages.map(p => ` • ${p.name} (ID: ${p.id})${p.category ? ` — ${p.category}` : ''}`),
107
+ ].join('\n'),
108
+ }],
109
+ };
110
+ });
111
+ server.tool('facebook_get_page', 'Facebook 페이지 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
112
+ const cfg = requireFacebookConfig();
113
+ const page = await api.getPage(cfg);
114
+ const remainingDays = cfg.expiresAt
115
+ ? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
116
+ : null;
117
+ return {
118
+ content: [{
119
+ type: 'text',
120
+ text: [
121
+ `${page.name} (ID: ${page.id})`,
122
+ page.category ? ` 카테고리: ${page.category}` : '',
123
+ page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
124
+ page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
125
+ remainingDays !== null
126
+ ? remainingDays > 7
127
+ ? ` 토큰: ${remainingDays}일 남음`
128
+ : remainingDays > 0
129
+ ? ` ⚠️ 토큰 ${remainingDays}일 남음 — 갱신 필요`
130
+ : ` ❌ 토큰 만료 — facebook_save_config로 재저장`
131
+ : '',
132
+ ].filter(Boolean).join('\n'),
133
+ }],
134
+ };
135
+ });
136
+ server.tool('facebook_post_photo', [
137
+ '단일 이미지를 Facebook 페이지에 게시합니다.',
138
+ 'imageUrl은 public URL이어야 합니다.',
139
+ ].join(' '), {
140
+ imageUrl: z.string().url().describe('이미지의 public URL (HTTPS 권장)'),
141
+ caption: z.string().describe('게시글 본문'),
142
+ }, async ({ imageUrl, caption }) => {
143
+ const cfg = requireFacebookConfig();
144
+ const result = await api.postPhoto(cfg, imageUrl, caption);
145
+ return {
146
+ content: [{
147
+ type: 'text',
148
+ text: [
149
+ `✅ 게시 완료`,
150
+ ` post_id: ${result.id}`,
151
+ result.permalink ? ` URL: ${result.permalink}` : '',
152
+ ].filter(Boolean).join('\n'),
153
+ }],
154
+ };
155
+ });
156
+ server.tool('facebook_post_multi_photo', [
157
+ '여러 이미지를 하나의 게시물로 Facebook 페이지에 게시합니다. 2~10장.',
158
+ '모든 이미지는 public URL이어야 합니다.',
159
+ '각 이미지를 unpublished photo로 업로드한 뒤 하나의 feed 게시물로 묶습니다.',
160
+ ].join(' '), {
161
+ imageUrls: z.array(z.string().url()).min(2).max(10).describe('이미지 URL 배열 (2~10장)'),
162
+ caption: z.string().describe('게시글 본문'),
163
+ }, async ({ imageUrls, caption }) => {
164
+ const cfg = requireFacebookConfig();
165
+ const result = await api.postMultiPhoto(cfg, imageUrls, caption);
166
+ return {
167
+ content: [{
168
+ type: 'text',
169
+ text: [
170
+ `✅ ${imageUrls.length}장 게시 완료`,
171
+ ` post_id: ${result.id}`,
172
+ result.permalink ? ` URL: ${result.permalink}` : '',
173
+ ].filter(Boolean).join('\n'),
174
+ }],
175
+ };
176
+ });
177
+ // Config load helper
178
+ server.tool('facebook_current_config', '현재 저장된 Facebook 페이지 설정을 확인합니다.', {}, async () => {
179
+ const cfg = loadFacebookConfig();
180
+ if (!cfg) {
181
+ return {
182
+ content: [{ type: 'text', text: '저장된 Facebook 설정 없음. facebook_save_config로 등록하세요.' }],
183
+ };
184
+ }
185
+ return {
186
+ content: [{
187
+ type: 'text',
188
+ text: [
189
+ `페이지: ${cfg.pageName ?? '(미확인)'} (${cfg.pageId})`,
190
+ cfg.expiresAt ? `토큰 만료(추정): ${cfg.expiresAt.slice(0, 10)}` : '',
191
+ ].filter(Boolean).join('\n'),
192
+ }],
193
+ };
194
+ });
195
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Mimi Seed MCP server — Firebase + AdMob + Google Play + App Store management for Claude Code / Cursor / any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mimi-seed-mcp": "dist/index.js",
8
8
  "mimi-seed-auth": "dist/auth/cli.js",
9
9
  "mimi-seed-appstore-auth": "dist/appstore/setup-cli.js",
10
- "mimi-seed-playstore-auth": "dist/auth/playstore-setup-cli.js"
10
+ "mimi-seed-playstore-auth": "dist/auth/playstore-setup-cli.js",
11
+ "mimi-seed-bigquery-auth": "dist/auth/bigquery-setup-cli.js"
11
12
  },
12
13
  "files": [
13
14
  "dist",