@yoonion/mimi-seed-mcp 0.13.0 → 0.13.1

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.
@@ -93,6 +93,11 @@ every credential, which also tells them where to obtain each token
93
93
  `mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
94
94
  all three Meta platforms in one pass.
95
95
 
96
+ Instagram and Threads can use named local profiles. Save one with
97
+ `mimi-seed auth instagram --profile <id>` or `mimi-seed auth threads --profile <id>`, then select it per project
98
+ with `.mimi-seed.json` → `socialProfiles.instagram` / `socialProfiles.threads`. MCP tools also accept an explicit
99
+ `profile`; explicit input wins over the project mapping.
100
+
96
101
  Each `mimi-seed auth <cred>` wraps the matching `npx -y @yoonion/mimi-seed-mcp mimi-seed-*-auth` binary, so
97
102
  either form works.
98
103
 
@@ -118,7 +123,8 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
118
123
  | `bigquery-service-account.json` | BigQuery SA (exempt from Workspace reauth; OAuth is the fallback) |
119
124
  | `jenkins.json`, `ci.json` | Jenkins / GitHub-GitLab CI connection |
120
125
  | `google-ads.json` | Google Ads developer token + customer id |
121
- | `facebook.json`, `instagram.json`, `threads.json` | Page / account access tokens for the social post tools (Threads is a separate Meta account/token) |
126
+ | `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
127
+ | `social-profiles/<profile>.json` | Named Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
122
128
 
123
129
  Notes that matter in practice:
124
130
 
@@ -1,9 +1,10 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface InstagramConfig {
2
3
  accessToken: string;
3
4
  userId: string;
4
5
  expiresAt?: string;
5
6
  username?: string;
6
7
  }
7
- export declare function loadInstagramConfig(): InstagramConfig | null;
8
- export declare function saveInstagramConfig(cfg: InstagramConfig): void;
9
- export declare function requireInstagramConfig(): InstagramConfig;
8
+ export declare function loadInstagramConfig(options?: SocialConfigOptions): InstagramConfig | null;
9
+ export declare function saveInstagramConfig(cfg: InstagramConfig, options?: SocialConfigOptions): void;
10
+ export declare function requireInstagramConfig(options?: SocialConfigOptions): InstagramConfig;
@@ -1,35 +1,22 @@
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', 'instagram.json');
5
- export function loadInstagramConfig() {
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 {
1
+ import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
2
+ export function loadInstagramConfig(options = {}) {
3
+ const cfg = loadSocialPlatformConfig('instagram', options);
4
+ if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
5
+ typeof cfg.userId !== 'string' || !cfg.userId)
14
6
  return null;
15
- }
7
+ return cfg;
16
8
  }
17
- export function saveInstagramConfig(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
- }
9
+ export function saveInstagramConfig(cfg, options = {}) {
10
+ saveSocialPlatformConfig('instagram', cfg, options);
26
11
  }
27
- export function requireInstagramConfig() {
28
- const cfg = loadInstagramConfig();
12
+ export function requireInstagramConfig(options = {}) {
13
+ const cfg = loadInstagramConfig(options);
29
14
  if (!cfg) {
15
+ const target = resolveSocialConfigTarget('instagram', options);
16
+ const profileHint = target.profile ? `, profile="${target.profile}"` : '';
30
17
  throw new Error('Instagram 설정이 없습니다.\n' +
31
18
  'instagram_save_config 도구로 먼저 설정해주세요.\n' +
32
- '예: instagram_save_config(accessToken="...", userId="...")');
19
+ `예: instagram_save_config(accessToken="...", userId="..."${profileHint})`);
33
20
  }
34
21
  return cfg;
35
22
  }
@@ -1,4 +1,5 @@
1
1
  import type { ConnectResult } from '../facebook/setup.js';
2
+ import type { SocialConfigOptions } from '../social/profile-store.js';
2
3
  /** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
3
4
  export declare function detectApiType(accessToken: string): string;
4
- export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
5
+ export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
@@ -2,12 +2,15 @@
2
2
  // MCP 도구(instagram_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
3
3
  import { saveInstagramConfig } from './config.js';
4
4
  import * as api from './api.js';
5
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
5
6
  const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
6
7
  /** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
7
8
  export function detectApiType(accessToken) {
8
9
  return accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
9
10
  }
10
- export async function connectInstagram(accessToken, userId, assumeIssuedNow = true) {
11
+ export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}) {
12
+ // 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
13
+ const target = resolveSocialConfigTarget('instagram', options);
11
14
  const apiType = detectApiType(accessToken);
12
15
  let resolvedUserId = userId;
13
16
  if (!resolvedUserId) {
@@ -36,11 +39,12 @@ export async function connectInstagram(accessToken, userId, assumeIssuedNow = tr
36
39
  userId: resolvedUserId,
37
40
  expiresAt,
38
41
  username: account.username,
39
- });
42
+ }, options);
40
43
  return {
41
44
  ok: true,
42
45
  text: [
43
46
  `✅ Instagram 연결 확인 완료 (${apiType})`,
47
+ ` 저장 대상: ${socialTargetLabel(target)}`,
44
48
  ` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
45
49
  ` ID: ${account.id}`,
46
50
  account.account_type ? ` 타입: ${account.account_type}` : '',
@@ -1,4 +1,6 @@
1
1
  export declare const MANIFEST_FILENAME = ".mimi-seed.json";
2
+ export declare const SOCIAL_PROFILE_ID_PATTERN: RegExp;
3
+ export type SocialPlatform = 'instagram' | 'threads';
2
4
  /** 매니페스트가 인지하는 서비스 id. status 매칭 로직이 이 키를 기준으로 동작한다. */
3
5
  export type ManifestServiceId = 'oauth' | 'bigquery' | 'playstore' | 'appstore' | 'jenkins';
4
6
  export interface ManifestService {
@@ -20,6 +22,8 @@ export interface ProjectManifest {
20
22
  displayName?: string;
21
23
  description?: string;
22
24
  services?: Partial<Record<ManifestServiceId, ManifestService>>;
25
+ /** 플랫폼별로 ~/.mimi-seed/social-profiles/<id>.json 을 선택한다. */
26
+ socialProfiles?: Partial<Record<SocialPlatform, string>>;
23
27
  }
24
28
  export interface LoadedManifest {
25
29
  manifest: ProjectManifest;
@@ -34,3 +38,7 @@ export interface LoadedManifest {
34
38
  export declare function findProjectManifest(startDir?: string, maxDepth?: number): LoadedManifest | null;
35
39
  /** 매니페스트 services 를 [id, service] 배열로. 없으면 빈 배열. */
36
40
  export declare function manifestServiceEntries(m: ProjectManifest): Array<[ManifestServiceId, ManifestService]>;
41
+ /** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
42
+ export declare function isValidSocialProfileId(value: string): boolean;
43
+ /** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
44
+ export declare function manifestSocialProfile(m: ProjectManifest, platform: SocialPlatform): string | null;
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  export const MANIFEST_FILENAME = '.mimi-seed.json';
12
+ export const SOCIAL_PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
13
  /**
13
14
  * startDir 에서 위로 올라가며 첫 번째 `.mimi-seed.json` 을 찾는다.
14
15
  * MCP stdio 서버의 cwd 는 보통 프로젝트 루트지만, 하위 폴더에서 실행돼도 잡히게 상향 탐색한다.
@@ -50,3 +51,24 @@ export function manifestServiceEntries(m) {
50
51
  .filter((k) => svc[k] != null)
51
52
  .map((k) => [k, svc[k]]);
52
53
  }
54
+ /** 파일명으로 안전하게 사용할 수 있는 공개 프로필 id인지 확인한다. */
55
+ export function isValidSocialProfileId(value) {
56
+ return SOCIAL_PROFILE_ID_PATTERN.test(value);
57
+ }
58
+ /** 매니페스트에 지정된 플랫폼 프로필. 잘못된 값은 조용히 무시하지 않고 오류로 막는다. */
59
+ export function manifestSocialProfile(m, platform) {
60
+ const profiles = m.socialProfiles;
61
+ if (profiles === undefined)
62
+ return null;
63
+ if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
64
+ throw new Error(`${MANIFEST_FILENAME}의 socialProfiles는 객체여야 합니다.`);
65
+ }
66
+ const value = profiles[platform];
67
+ if (value === undefined)
68
+ return null;
69
+ if (typeof value !== 'string' || !isValidSocialProfileId(value)) {
70
+ throw new Error(`${MANIFEST_FILENAME}의 socialProfiles.${platform} 값이 올바르지 않습니다. ` +
71
+ '영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
72
+ }
73
+ return value;
74
+ }
@@ -15,6 +15,7 @@ import { metaTokenFreshness } from '../lib/meta-auth.js';
15
15
  import { readPackageRootText } from '../lib/package-root.js';
16
16
  import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
17
17
  import { syncRemoteCredentials } from '../remote-sync.js';
18
+ import { resolveSocialConfigTarget } from '../social/profile-store.js';
18
19
  import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
19
20
  /**
20
21
  * tokens.json mtime → "Nd Hh ago" 형식 문자열 + 재인증 권고.
@@ -188,11 +189,13 @@ export function registerAuthTools(server) {
188
189
  const fb = loadFacebookConfig();
189
190
  lines.push(renderMetaConnection('Facebook', fb, `pageId: ${fb?.pageId ?? ''}`, 'mimi-seed auth facebook'));
190
191
  // 8. Instagram
192
+ const igTarget = resolveSocialConfigTarget('instagram');
191
193
  const ig = loadInstagramConfig();
192
- lines.push(renderMetaConnection('Instagram', ig, `userId: ${ig?.userId ?? ''}`, 'mimi-seed auth instagram'));
194
+ lines.push(renderMetaConnection('Instagram', ig, `${igTarget.profile ? `profile: ${igTarget.profile}, ` : ''}userId: ${ig?.userId ?? ''}`, `mimi-seed auth instagram${igTarget.profile ? ` --profile ${igTarget.profile}` : ''}`));
193
195
  // 9. Threads
196
+ const threadsTarget = resolveSocialConfigTarget('threads');
194
197
  const threads = loadThreadsConfig();
195
- lines.push(renderMetaConnection('Threads', threads, `userId: ${threads?.userId ?? ''}`, 'mimi-seed auth threads'));
198
+ lines.push(renderMetaConnection('Threads', threads, `${threadsTarget.profile ? `profile: ${threadsTarget.profile}, ` : ''}userId: ${threads?.userId ?? ''}`, `mimi-seed auth threads${threadsTarget.profile ? ` --profile ${threadsTarget.profile}` : ''}`));
196
199
  // 10. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
197
200
  // 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
198
201
  const bqAuth = resolveBigQueryAuth();
@@ -3,25 +3,29 @@ import { requireInstagramConfig } from '../instagram/config.js';
3
3
  import { connectInstagram } from '../instagram/setup.js';
4
4
  import * as api from '../instagram/api.js';
5
5
  import { metaExpiryMessage } from '../lib/meta-auth.js';
6
+ import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
6
7
  export function registerInstagramTools(server) {
8
+ const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.instagram, 없으면 기존 기본 설정');
7
9
  server.tool('instagram_save_config', [
8
10
  'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
9
11
  'accessToken은 두 형식 모두 자동 감지:',
10
12
  ' IGAA... = Instagram API with Instagram Login (Meta 신규, FB Page 불필요)',
11
13
  ' EAA... = Instagram Graph API via Facebook Login (FB Page+IG Business 연결 필요)',
12
14
  'userId 미입력 시 토큰으로 자동 조회 (/me 또는 /me/accounts).',
15
+ 'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
13
16
  '저장 직후 토큰 유효성도 자동 검증.',
14
17
  ].join(' '), {
15
18
  accessToken: z.string().describe('Long-lived access token (60일)'),
16
19
  userId: z.string().optional().describe('Instagram Business Account ID (생략 시 자동 조회)'),
17
20
  assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
18
- }, async ({ accessToken, userId, assumeIssuedNow }) => {
21
+ profile: profileSchema,
22
+ }, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
19
23
  // 구현은 instagram/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
20
- const result = await connectInstagram(accessToken, userId, assumeIssuedNow);
24
+ const result = await connectInstagram(accessToken, userId, assumeIssuedNow, { profile });
21
25
  return { content: [{ type: 'text', text: result.text }] };
22
26
  });
23
- server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
24
- const cfg = requireInstagramConfig();
27
+ server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
28
+ const cfg = requireInstagramConfig({ profile });
25
29
  const account = await api.getAccount(cfg);
26
30
  return {
27
31
  content: [{
@@ -45,8 +49,9 @@ export function registerInstagramTools(server) {
45
49
  ].join(' '), {
46
50
  imageUrl: z.string().url().describe('이미지의 public URL (HTTPS 권장)'),
47
51
  caption: z.string().describe('캡션 — 해시태그/멘션/줄바꿈 포함 가능'),
48
- }, async ({ imageUrl, caption }) => {
49
- const cfg = requireInstagramConfig();
52
+ profile: profileSchema,
53
+ }, async ({ imageUrl, caption, profile }) => {
54
+ const cfg = requireInstagramConfig({ profile });
50
55
  const result = await api.postImage(cfg, imageUrl, caption);
51
56
  return {
52
57
  content: [{
@@ -67,8 +72,9 @@ export function registerInstagramTools(server) {
67
72
  ].join(' '), {
68
73
  imageUrls: z.array(z.string().url()).min(2).max(10).describe('이미지 URL 배열 (2~10장)'),
69
74
  caption: z.string().describe('캡션'),
70
- }, async ({ imageUrls, caption }) => {
71
- const cfg = requireInstagramConfig();
75
+ profile: profileSchema,
76
+ }, async ({ imageUrls, caption, profile }) => {
77
+ const cfg = requireInstagramConfig({ profile });
72
78
  const result = await api.postCarousel(cfg, imageUrls, caption);
73
79
  return {
74
80
  content: [{
@@ -3,33 +3,38 @@ import { loadThreadsConfig, requireThreadsConfig } from '../threads/config.js';
3
3
  import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
4
4
  import * as api from '../threads/api.js';
5
5
  import { metaExpiryMessage } from '../lib/meta-auth.js';
6
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
7
+ import { SOCIAL_PROFILE_ID_PATTERN } from '../lib/project-manifest.js';
6
8
  export function registerThreadsTools(server) {
9
+ const profileSchema = z.string().regex(SOCIAL_PROFILE_ID_PATTERN).optional().describe('저장/사용할 소셜 프로필 ID. 생략 시 .mimi-seed.json의 socialProfiles.threads, 없으면 기존 기본 설정');
7
10
  server.tool('threads_save_config', [
8
11
  'Threads 토큰을 ~/.mimi-seed/threads.json (mode 0600)에 저장합니다.',
9
12
  'accessToken은 Threads Graph API long-lived 토큰 (약 60일).',
10
13
  'userId 미입력 시 토큰으로 자동 조회 (GET /me).',
14
+ 'profile 지정 시 ~/.mimi-seed/social-profiles/<profile>.json에 저장합니다.',
11
15
  '저장 직후 토큰 유효성도 자동 검증.',
12
16
  'Instagram 과 별개 계정·별개 토큰입니다 (threads_basic, threads_content_publish 권한 필요).',
13
17
  ].join(' '), {
14
18
  accessToken: z.string().describe('Threads Graph API long-lived access token (약 60일)'),
15
19
  userId: z.string().optional().describe('Threads user ID (생략 시 자동 조회)'),
16
20
  assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
17
- }, async ({ accessToken, userId, assumeIssuedNow }) => {
21
+ profile: profileSchema,
22
+ }, async ({ accessToken, userId, assumeIssuedNow, profile }) => {
18
23
  // 구현은 threads/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
19
- const result = await connectThreads(accessToken, userId, assumeIssuedNow);
24
+ const result = await connectThreads(accessToken, userId, assumeIssuedNow, { profile });
20
25
  return { content: [{ type: 'text', text: result.text }] };
21
26
  });
22
27
  server.tool('threads_refresh_token', [
23
28
  '저장된 Threads long-lived 토큰을 만료 전에 공식 refresh_access_token endpoint로 갱신합니다.',
24
29
  '성공하면 새 토큰과 실제 expires_in을 저장하고 계정을 다시 검증합니다.',
25
30
  '이미 만료·철회된 토큰은 갱신할 수 없으므로 mimi-seed auth threads로 재연결하세요.',
26
- ].join(' '), {}, async () => {
27
- const cfg = requireThreadsConfig();
28
- const result = await refreshThreadsToken(cfg.accessToken);
31
+ ].join(' '), { profile: profileSchema }, async ({ profile }) => {
32
+ const cfg = requireThreadsConfig({ profile });
33
+ const result = await refreshThreadsToken(cfg.accessToken, { profile });
29
34
  return { content: [{ type: 'text', text: result.text }], isError: !result.ok };
30
35
  });
31
- server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
32
- const cfg = requireThreadsConfig();
36
+ server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', { profile: profileSchema }, async ({ profile }) => {
37
+ const cfg = requireThreadsConfig({ profile });
33
38
  const account = await api.getAccount(cfg);
34
39
  return {
35
40
  content: [{
@@ -51,8 +56,9 @@ export function registerThreadsTools(server) {
51
56
  ].join(' '), {
52
57
  text: z.string().max(500).describe('게시할 텍스트 (최대 500자, 멘션/해시태그/줄바꿈 가능)'),
53
58
  imageUrl: z.string().url().optional().describe('이미지의 public URL (생략 시 텍스트 전용 게시)'),
54
- }, async ({ text, imageUrl }) => {
55
- const cfg = requireThreadsConfig();
59
+ profile: profileSchema,
60
+ }, async ({ text, imageUrl, profile }) => {
61
+ const cfg = requireThreadsConfig({ profile });
56
62
  const result = await api.postText(cfg, text, imageUrl);
57
63
  return {
58
64
  content: [{
@@ -72,8 +78,9 @@ export function registerThreadsTools(server) {
72
78
  ].join(' '), {
73
79
  imageUrls: z.array(z.string().url()).min(2).max(20).describe('이미지 URL 배열 (2~20장)'),
74
80
  text: z.string().max(500).describe('캡션 텍스트 (최대 500자)'),
75
- }, async ({ imageUrls, text }) => {
76
- const cfg = requireThreadsConfig();
81
+ profile: profileSchema,
82
+ }, async ({ imageUrls, text, profile }) => {
83
+ const cfg = requireThreadsConfig({ profile });
77
84
  const result = await api.postCarousel(cfg, imageUrls, text);
78
85
  return {
79
86
  content: [{
@@ -87,11 +94,16 @@ export function registerThreadsTools(server) {
87
94
  };
88
95
  });
89
96
  // 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
90
- server.tool('threads_current_config', '현재 저장된 Threads 연결 설정을 확인합니다 (~/.mimi-seed/threads.json).', {}, async () => {
91
- const cfg = loadThreadsConfig();
97
+ server.tool('threads_current_config', '현재 선택된 Threads 연결 설정을 확인합니다. 프로젝트 매핑 또는 profile 인자를 따릅니다.', { profile: profileSchema }, async ({ profile }) => {
98
+ const options = { profile };
99
+ const target = resolveSocialConfigTarget('threads', options);
100
+ const cfg = loadThreadsConfig(options);
92
101
  if (!cfg) {
93
102
  return {
94
- content: [{ type: 'text', text: '❌ Threads 미설정 → threads_save_config 또는 mimi-seed auth threads' }],
103
+ content: [{
104
+ type: 'text',
105
+ text: `❌ Threads ${socialTargetLabel(target)} 미설정 → threads_save_config 또는 mimi-seed auth threads`,
106
+ }],
95
107
  };
96
108
  }
97
109
  return {
@@ -99,6 +111,7 @@ export function registerThreadsTools(server) {
99
111
  type: 'text',
100
112
  text: [
101
113
  '✅ Threads 연결됨',
114
+ ` 대상: ${socialTargetLabel(target)}`,
102
115
  ` @${cfg.username ?? '(username 미저장)'}`,
103
116
  ` userId: ${cfg.userId}`,
104
117
  ` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
@@ -0,0 +1,17 @@
1
+ import { type SocialPlatform } from '../lib/project-manifest.js';
2
+ export interface SocialConfigOptions {
3
+ /** 명시하면 프로젝트 매니페스트 매핑보다 우선한다. */
4
+ profile?: string;
5
+ /** 매니페스트 상향 탐색 시작점. 기본값은 현재 작업 디렉터리. */
6
+ startDir?: string;
7
+ /** 테스트 및 격리 실행용 홈 디렉터리. */
8
+ homeDir?: string;
9
+ }
10
+ export interface SocialConfigTarget {
11
+ filePath: string;
12
+ profile: string | null;
13
+ }
14
+ export declare function resolveSocialConfigTarget(platform: SocialPlatform, options?: SocialConfigOptions): SocialConfigTarget;
15
+ export declare function loadSocialPlatformConfig<T>(platform: SocialPlatform, options?: SocialConfigOptions): T | null;
16
+ export declare function saveSocialPlatformConfig<T extends object>(platform: SocialPlatform, config: T, options?: SocialConfigOptions): SocialConfigTarget;
17
+ export declare function socialTargetLabel(target: SocialConfigTarget): string;
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { findProjectManifest, isValidSocialProfileId, manifestSocialProfile, } from '../lib/project-manifest.js';
5
+ function validateProfileId(profile) {
6
+ if (!isValidSocialProfileId(profile)) {
7
+ throw new Error('소셜 프로필 ID가 올바르지 않습니다. ' +
8
+ '영문자·숫자로 시작하는 1~64자의 영문자/숫자/점/밑줄/하이픈만 사용할 수 있습니다.');
9
+ }
10
+ return profile;
11
+ }
12
+ export function resolveSocialConfigTarget(platform, options = {}) {
13
+ const homeDir = options.homeDir ?? os.homedir();
14
+ const root = path.join(homeDir, '.mimi-seed');
15
+ let profile = null;
16
+ if (options.profile !== undefined) {
17
+ profile = validateProfileId(options.profile);
18
+ }
19
+ else {
20
+ const loaded = findProjectManifest(options.startDir ?? process.cwd());
21
+ if (loaded)
22
+ profile = manifestSocialProfile(loaded.manifest, platform);
23
+ }
24
+ return profile
25
+ ? { profile, filePath: path.join(root, 'social-profiles', `${profile}.json`) }
26
+ : { profile: null, filePath: path.join(root, `${platform}.json`) };
27
+ }
28
+ export function loadSocialPlatformConfig(platform, options = {}) {
29
+ const target = resolveSocialConfigTarget(platform, options);
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
32
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
33
+ return null;
34
+ if (target.profile) {
35
+ return parsed[platform] ?? null;
36
+ }
37
+ return parsed;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function saveSocialPlatformConfig(platform, config, options = {}) {
44
+ const target = resolveSocialConfigTarget(platform, options);
45
+ const dir = path.dirname(target.filePath);
46
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ let value = config;
48
+ if (target.profile) {
49
+ let existing = {};
50
+ if (fs.existsSync(target.filePath)) {
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(target.filePath, 'utf-8'));
53
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
54
+ throw new Error('객체 형식이 아닙니다.');
55
+ }
56
+ existing = parsed;
57
+ }
58
+ catch (error) {
59
+ throw new Error(`기존 소셜 프로필 파일을 읽을 수 없어 덮어쓰지 않았습니다: ${target.filePath} ` +
60
+ `(${error instanceof Error ? error.message : String(error)})`);
61
+ }
62
+ }
63
+ value = { ...existing, [platform]: config };
64
+ }
65
+ fs.writeFileSync(target.filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
66
+ if (process.platform !== 'win32')
67
+ fs.chmodSync(target.filePath, 0o600);
68
+ return target;
69
+ }
70
+ export function socialTargetLabel(target) {
71
+ return target.profile ? `프로필 '${target.profile}'` : '기본 프로필';
72
+ }
@@ -11,6 +11,7 @@
11
11
  // mimi-seed-social-auth instagram → Instagram 만
12
12
  // mimi-seed-social-auth threads → Threads 만
13
13
  // mimi-seed-social-auth all → 세 플랫폼을 순서대로
14
+ // mimi-seed-social-auth instagram --profile my-app → 명시한 프로필에 저장
14
15
  //
15
16
  // 주의: connectFacebook / connectInstagram / connectThreads 이 돌려주는 result.text 는 MCP 도구와
16
17
  // 공유하는 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
@@ -22,6 +23,7 @@ import { connectFacebook } from '../facebook/setup.js';
22
23
  import { connectInstagram } from '../instagram/setup.js';
23
24
  import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
24
25
  import { resolveLang } from '../lib/lang.js';
26
+ import { resolveSocialConfigTarget } from './profile-store.js';
25
27
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
26
28
  const ko = {
27
29
  title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram / Threads)',
@@ -59,6 +61,8 @@ const ko = {
59
61
  thExistingAction: ' [Enter/y] 기존 토큰 자동 갱신 [r] 새 토큰으로 재연결 [n] 건너뛰기: ',
60
62
  thRefreshing: ' 🔄 기존 Threads 토큰 갱신 중...',
61
63
  thRefreshFallback: ' 자동 갱신에 실패했습니다. 새 토큰을 입력해 재연결할 수 있습니다.',
64
+ profile: (id) => ` 소셜 프로필: ${id}`,
65
+ invalidArgs: ' 사용법: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
62
66
  };
63
67
  const en = {
64
68
  title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram / Threads)',
@@ -96,6 +100,8 @@ const en = {
96
100
  thExistingAction: ' [Enter/y] refresh current token [r] reconnect with a new token [n] skip: ',
97
101
  thRefreshing: ' 🔄 Refreshing the existing Threads token...',
98
102
  thRefreshFallback: ' Automatic refresh failed. You can reconnect with a new token.',
103
+ profile: (id) => ` Social profile: ${id}`,
104
+ invalidArgs: ' Usage: mimi-seed-social-auth [facebook|instagram|threads|all] [--profile <id>]',
99
105
  };
100
106
  const M = resolveLang() === 'en' ? en : ko;
101
107
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -145,10 +151,14 @@ async function setupFacebook() {
145
151
  console.log(M.notSaved);
146
152
  return result.ok;
147
153
  }
148
- async function setupInstagram() {
154
+ async function setupInstagram(profile) {
149
155
  console.log('');
150
156
  console.log(M.igHeader);
151
- const existing = loadInstagramConfig();
157
+ const options = { profile };
158
+ const target = resolveSocialConfigTarget('instagram', options);
159
+ if (target.profile)
160
+ console.log(M.profile(target.profile));
161
+ const existing = loadInstagramConfig(options);
152
162
  if (existing && !(await confirmReconnect('Instagram', existing.username ?? existing.userId))) {
153
163
  return true;
154
164
  }
@@ -164,17 +174,21 @@ async function setupInstagram() {
164
174
  }
165
175
  const userId = await ask(M.igAskUserId);
166
176
  console.log(M.verifying);
167
- const result = await connectInstagram(token, userId || undefined);
177
+ const result = await connectInstagram(token, userId || undefined, true, options);
168
178
  console.log('');
169
179
  console.log(indent(result.text));
170
180
  if (!result.ok)
171
181
  console.log(M.notSaved);
172
182
  return result.ok;
173
183
  }
174
- async function setupThreads() {
184
+ async function setupThreads(profile) {
175
185
  console.log('');
176
186
  console.log(M.thHeader);
177
- const existing = loadThreadsConfig();
187
+ const options = { profile };
188
+ const target = resolveSocialConfigTarget('threads', options);
189
+ if (target.profile)
190
+ console.log(M.profile(target.profile));
191
+ const existing = loadThreadsConfig(options);
178
192
  if (existing) {
179
193
  console.log(M.already('Threads', existing.username ?? existing.userId));
180
194
  const action = (await ask(M.thExistingAction)).toLowerCase();
@@ -182,7 +196,7 @@ async function setupThreads() {
182
196
  return true;
183
197
  if (action !== 'r') {
184
198
  console.log(M.thRefreshing);
185
- const refreshed = await refreshThreadsToken(existing.accessToken);
199
+ const refreshed = await refreshThreadsToken(existing.accessToken, options);
186
200
  console.log('');
187
201
  console.log(indent(refreshed.text));
188
202
  if (refreshed.ok)
@@ -203,7 +217,7 @@ async function setupThreads() {
203
217
  }
204
218
  const userId = await ask(M.thAskUserId);
205
219
  console.log(M.verifying);
206
- const result = await connectThreads(token, userId || undefined);
220
+ const result = await connectThreads(token, userId || undefined, true, options);
207
221
  console.log('');
208
222
  console.log(indent(result.text));
209
223
  if (!result.ok)
@@ -217,7 +231,19 @@ function indent(text) {
217
231
  .join('\n');
218
232
  }
219
233
  async function main() {
220
- const target = (process.argv[2] ?? '').toLowerCase();
234
+ const args = process.argv.slice(2);
235
+ const profileIndex = args.indexOf('--profile');
236
+ const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
237
+ if (profileIndex >= 0 && !profile) {
238
+ console.error(M.invalidArgs);
239
+ process.exit(1);
240
+ }
241
+ const positional = args.filter((arg, index) => arg !== '--profile' && (profileIndex < 0 || index !== profileIndex + 1));
242
+ if (positional.length > 1 || args.some((arg) => arg.startsWith('--') && arg !== '--profile')) {
243
+ console.error(M.invalidArgs);
244
+ process.exit(1);
245
+ }
246
+ const target = (positional[0] ?? '').toLowerCase();
221
247
  console.log('');
222
248
  console.log(M.title);
223
249
  let ok = true;
@@ -225,15 +251,15 @@ async function main() {
225
251
  ok = await setupFacebook();
226
252
  }
227
253
  else if (target === 'instagram' || target === 'ig') {
228
- ok = await setupInstagram();
254
+ ok = await setupInstagram(profile);
229
255
  }
230
256
  else if (target === 'threads' || target === 'th') {
231
- ok = await setupThreads();
257
+ ok = await setupThreads(profile);
232
258
  }
233
259
  else if (target === 'all' || target === 'meta') {
234
260
  const fb = await setupFacebook();
235
- const ig = await setupInstagram();
236
- const th = await setupThreads();
261
+ const ig = await setupInstagram(profile);
262
+ const th = await setupThreads(profile);
237
263
  ok = fb && ig && th;
238
264
  }
239
265
  else {
@@ -242,19 +268,19 @@ async function main() {
242
268
  if (c === 'f')
243
269
  ok = await setupFacebook();
244
270
  else if (c === 'i')
245
- ok = await setupInstagram();
271
+ ok = await setupInstagram(profile);
246
272
  else if (c === 't')
247
- ok = await setupThreads();
273
+ ok = await setupThreads(profile);
248
274
  else if (c === 'b') {
249
275
  // 기존 b=Facebook+Instagram 동작을 유지한다.
250
276
  const fb = await setupFacebook();
251
- const ig = await setupInstagram();
277
+ const ig = await setupInstagram(profile);
252
278
  ok = fb && ig;
253
279
  }
254
280
  else if (c === 'a') {
255
281
  const fb = await setupFacebook();
256
- const ig = await setupInstagram();
257
- const th = await setupThreads();
282
+ const ig = await setupInstagram(profile);
283
+ const th = await setupThreads(profile);
258
284
  ok = fb && ig && th;
259
285
  }
260
286
  else {
@@ -1,9 +1,10 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
1
2
  export interface ThreadsConfig {
2
3
  accessToken: string;
3
4
  userId: string;
4
5
  expiresAt?: string;
5
6
  username?: string;
6
7
  }
7
- export declare function loadThreadsConfig(): ThreadsConfig | null;
8
- export declare function saveThreadsConfig(cfg: ThreadsConfig): void;
9
- export declare function requireThreadsConfig(): ThreadsConfig;
8
+ export declare function loadThreadsConfig(options?: SocialConfigOptions): ThreadsConfig | null;
9
+ export declare function saveThreadsConfig(cfg: ThreadsConfig, options?: SocialConfigOptions): void;
10
+ export declare function requireThreadsConfig(options?: SocialConfigOptions): ThreadsConfig;
@@ -1,35 +1,22 @@
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 {
1
+ import { loadSocialPlatformConfig, resolveSocialConfigTarget, saveSocialPlatformConfig, } from '../social/profile-store.js';
2
+ export function loadThreadsConfig(options = {}) {
3
+ const cfg = loadSocialPlatformConfig('threads', options);
4
+ if (typeof cfg?.accessToken !== 'string' || !cfg.accessToken ||
5
+ typeof cfg.userId !== 'string' || !cfg.userId)
14
6
  return null;
15
- }
7
+ return cfg;
16
8
  }
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
- }
9
+ export function saveThreadsConfig(cfg, options = {}) {
10
+ saveSocialPlatformConfig('threads', cfg, options);
26
11
  }
27
- export function requireThreadsConfig() {
28
- const cfg = loadThreadsConfig();
12
+ export function requireThreadsConfig(options = {}) {
13
+ const cfg = loadThreadsConfig(options);
29
14
  if (!cfg) {
15
+ const target = resolveSocialConfigTarget('threads', options);
16
+ const profileHint = target.profile ? `, profile="${target.profile}"` : '';
30
17
  throw new Error('Threads 설정이 없습니다.\n' +
31
18
  'threads_save_config 도구로 먼저 설정해주세요.\n' +
32
- '예: threads_save_config(accessToken="...", userId="...")');
19
+ `예: threads_save_config(accessToken="...", userId="..."${profileHint})`);
33
20
  }
34
21
  return cfg;
35
22
  }
@@ -1,4 +1,5 @@
1
1
  import type { ConnectResult } from '../facebook/setup.js';
2
- export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
2
+ import type { SocialConfigOptions } from '../social/profile-store.js';
3
+ export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
3
4
  /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
4
- export declare function refreshThreadsToken(currentAccessToken: string): Promise<ConnectResult>;
5
+ export declare function refreshThreadsToken(currentAccessToken: string, options?: SocialConfigOptions): Promise<ConnectResult>;
@@ -2,8 +2,11 @@
2
2
  // MCP 도구(threads_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
3
3
  import { saveThreadsConfig } from './config.js';
4
4
  import * as api from './api.js';
5
+ import { resolveSocialConfigTarget, socialTargetLabel } from '../social/profile-store.js';
5
6
  const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
6
- export async function connectThreads(accessToken, userId, assumeIssuedNow = true) {
7
+ export async function connectThreads(accessToken, userId, assumeIssuedNow = true, options = {}) {
8
+ // 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
9
+ const target = resolveSocialConfigTarget('threads', options);
7
10
  let resolvedUserId = userId;
8
11
  if (!resolvedUserId) {
9
12
  try {
@@ -31,11 +34,12 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
31
34
  userId: resolvedUserId,
32
35
  expiresAt,
33
36
  username: account.username,
34
- });
37
+ }, options);
35
38
  return {
36
39
  ok: true,
37
40
  text: [
38
41
  '✅ Threads 연결 확인 완료',
42
+ ` 저장 대상: ${socialTargetLabel(target)}`,
39
43
  ` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
40
44
  ` ID: ${account.id}`,
41
45
  expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
@@ -59,7 +63,8 @@ export async function connectThreads(accessToken, userId, assumeIssuedNow = true
59
63
  }
60
64
  }
61
65
  /** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
62
- export async function refreshThreadsToken(currentAccessToken) {
66
+ export async function refreshThreadsToken(currentAccessToken, options = {}) {
67
+ const target = resolveSocialConfigTarget('threads', options);
63
68
  try {
64
69
  const refreshed = await api.refreshAccessToken(currentAccessToken);
65
70
  const userId = await api.fetchUserId(refreshed.accessToken);
@@ -70,11 +75,12 @@ export async function refreshThreadsToken(currentAccessToken) {
70
75
  userId,
71
76
  username: account.username,
72
77
  expiresAt,
73
- });
78
+ }, options);
74
79
  return {
75
80
  ok: true,
76
81
  text: [
77
82
  '✅ Threads 토큰 갱신 완료',
83
+ ` 저장 대상: ${socialTargetLabel(target)}`,
78
84
  ` 계정: @${account.username}`,
79
85
  ` 새 만료일: ${expiresAt.slice(0, 10)}`,
80
86
  ].join('\n'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoonion/mimi-seed-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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": {