triflux 3.3.0-dev.3 → 3.3.0-dev.5

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,637 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/mcp-filter.mjs
3
+ // 역할/컨텍스트 기반 MCP 도구 노출 정책의 단일 소스.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import process from 'node:process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import {
10
+ DOMAIN_TAG_KEYWORDS,
11
+ MCP_SERVER_TOOL_CATALOG,
12
+ SEARCH_SERVER_ORDER,
13
+ SERVER_EXPLICIT_KEYWORDS,
14
+ normalizeServerMetadata,
15
+ uniqueStrings,
16
+ } from './mcp-server-catalog.mjs';
17
+
18
+ export const KNOWN_MCP_SERVERS = Object.freeze(Object.keys(MCP_SERVER_TOOL_CATALOG));
19
+
20
+ const SEARCH_INTENT_PATTERNS = Object.freeze([
21
+ /\b(search|web|browse|look ?up|find|latest|recent|news|current|today|release(?: note)?s?|changelog|announcement|pricing|status|verify|fact[- ]?check)\b/i,
22
+ /(검색|웹|브라우즈|찾아|조회|최신|최근|뉴스|현재|오늘|릴리즈|배포|변경사항|공지|가격|상태|검증)/u,
23
+ ]);
24
+
25
+ const PROFILE_DEFINITIONS = Object.freeze({
26
+ default: Object.freeze({
27
+ description: '보수적 기본 프로필. 문서 조회 + 최소 검색만 허용',
28
+ allowedServers: Object.freeze(['context7', 'brave-search']),
29
+ alwaysOnServers: Object.freeze(['context7']),
30
+ maxSearchServers: 1,
31
+ allowedToolsByServer: Object.freeze({
32
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
33
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
34
+ }),
35
+ }),
36
+ executor: Object.freeze({
37
+ description: '구현 워커용. 문서/검색/브라우징 보조 MCP 허용',
38
+ allowedServers: Object.freeze(['context7', 'playwright', 'brave-search', 'tavily', 'exa']),
39
+ alwaysOnServers: Object.freeze(['context7']),
40
+ maxSearchServers: 2,
41
+ allowedToolsByServer: Object.freeze({
42
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
43
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
44
+ exa: Object.freeze(['web_search_exa', 'get_code_context_exa']),
45
+ tavily: Object.freeze(['tavily_search', 'tavily_extract']),
46
+ playwright: Object.freeze([
47
+ 'browser_navigate',
48
+ 'browser_navigate_back',
49
+ 'browser_snapshot',
50
+ 'browser_take_screenshot',
51
+ 'browser_wait_for',
52
+ ]),
53
+ }),
54
+ }),
55
+ designer: Object.freeze({
56
+ description: '디자인/UI 워커용. 브라우저 관찰 + 문서 조회 중심 MCP 허용',
57
+ allowedServers: Object.freeze(['context7', 'playwright', 'tavily', 'exa', 'brave-search']),
58
+ alwaysOnServers: Object.freeze(['context7']),
59
+ maxSearchServers: 2,
60
+ allowedToolsByServer: Object.freeze({
61
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
62
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
63
+ exa: Object.freeze(['web_search_exa', 'get_code_context_exa']),
64
+ tavily: Object.freeze(['tavily_search', 'tavily_extract']),
65
+ playwright: Object.freeze([
66
+ 'browser_navigate',
67
+ 'browser_navigate_back',
68
+ 'browser_snapshot',
69
+ 'browser_take_screenshot',
70
+ 'browser_wait_for',
71
+ ]),
72
+ }),
73
+ }),
74
+ explore: Object.freeze({
75
+ description: '탐색 워커용. 읽기/검색 중심 MCP만 허용',
76
+ allowedServers: Object.freeze(['context7', 'brave-search', 'tavily', 'exa']),
77
+ alwaysOnServers: Object.freeze(['context7']),
78
+ maxSearchServers: 2,
79
+ allowedToolsByServer: Object.freeze({
80
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
81
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
82
+ exa: Object.freeze(['web_search_exa', 'get_code_context_exa']),
83
+ tavily: Object.freeze(['tavily_search', 'tavily_extract']),
84
+ }),
85
+ }),
86
+ reviewer: Object.freeze({
87
+ description: '리뷰 워커용. 문서 조회 + 분석 전용 MCP만 허용',
88
+ allowedServers: Object.freeze(['context7', 'brave-search', 'sequential-thinking']),
89
+ alwaysOnServers: Object.freeze(['context7', 'sequential-thinking']),
90
+ maxSearchServers: 1,
91
+ allowedToolsByServer: Object.freeze({
92
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
93
+ 'brave-search': Object.freeze(['brave_web_search']),
94
+ 'sequential-thinking': Object.freeze(['sequentialthinking']),
95
+ }),
96
+ }),
97
+ writer: Object.freeze({
98
+ description: '문서/작성 워커용. 공식 문서와 최소 검색 MCP만 허용',
99
+ allowedServers: Object.freeze(['context7', 'brave-search', 'exa']),
100
+ alwaysOnServers: Object.freeze(['context7']),
101
+ maxSearchServers: 2,
102
+ allowedToolsByServer: Object.freeze({
103
+ context7: Object.freeze(['resolve-library-id', 'query-docs']),
104
+ 'brave-search': Object.freeze(['brave_web_search', 'brave_news_search']),
105
+ exa: Object.freeze(['web_search_exa']),
106
+ }),
107
+ }),
108
+ none: Object.freeze({
109
+ description: '모든 선택적 MCP 서버 비활성화',
110
+ allowedServers: Object.freeze([]),
111
+ alwaysOnServers: Object.freeze([]),
112
+ maxSearchServers: 0,
113
+ allowedToolsByServer: Object.freeze({}),
114
+ }),
115
+ });
116
+
117
+ export const LEGACY_PROFILE_ALIASES = Object.freeze({
118
+ implement: 'executor',
119
+ analyze: 'explore',
120
+ review: 'reviewer',
121
+ docs: 'writer',
122
+ minimal: 'default',
123
+ });
124
+
125
+ export const SUPPORTED_MCP_PROFILES = Object.freeze([
126
+ 'auto',
127
+ ...Object.keys(PROFILE_DEFINITIONS),
128
+ ...Object.keys(LEGACY_PROFILE_ALIASES),
129
+ ]);
130
+
131
+ function normalizeTaskText(taskText = '') {
132
+ if (typeof taskText !== 'string') return '';
133
+ return taskText.replace(/\s+/g, ' ').trim();
134
+ }
135
+
136
+ function normalizeProfileName(profile) {
137
+ const raw = typeof profile === 'string' && profile.trim() ? profile.trim() : 'auto';
138
+ if (raw === 'auto') return raw;
139
+ if (PROFILE_DEFINITIONS[raw]) return raw;
140
+ if (LEGACY_PROFILE_ALIASES[raw]) return LEGACY_PROFILE_ALIASES[raw];
141
+ throw new Error(`지원하지 않는 MCP 프로필: ${raw}`);
142
+ }
143
+
144
+ function resolveAutoProfile(agentType = '') {
145
+ switch (agentType) {
146
+ case 'executor':
147
+ case 'build-fixer':
148
+ case 'debugger':
149
+ case 'deep-executor':
150
+ case 'test-engineer':
151
+ case 'qa-tester':
152
+ return 'executor';
153
+ case 'designer':
154
+ return 'designer';
155
+ case 'architect':
156
+ case 'planner':
157
+ case 'critic':
158
+ case 'analyst':
159
+ case 'scientist':
160
+ case 'scientist-deep':
161
+ case 'document-specialist':
162
+ case 'explore':
163
+ return 'explore';
164
+ case 'code-reviewer':
165
+ case 'security-reviewer':
166
+ case 'quality-reviewer':
167
+ case 'verifier':
168
+ return 'reviewer';
169
+ case 'writer':
170
+ return 'writer';
171
+ default:
172
+ return 'default';
173
+ }
174
+ }
175
+
176
+ function escapeRegExp(value) {
177
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
+ }
179
+
180
+ function countKeywordMatches(text, keywords = []) {
181
+ let matches = 0;
182
+ for (const keyword of keywords) {
183
+ const source = String(keyword || '').trim();
184
+ if (!source) continue;
185
+ const pattern = /^[a-z0-9- ]+$/i.test(source)
186
+ ? new RegExp(`\\b${escapeRegExp(source)}\\b`, 'i')
187
+ : new RegExp(escapeRegExp(source), 'iu');
188
+ if (pattern.test(text)) matches += 1;
189
+ }
190
+ return matches;
191
+ }
192
+
193
+ function loadInventory(inventoryFile = '') {
194
+ if (typeof inventoryFile !== 'string' || !inventoryFile.trim()) return null;
195
+ try {
196
+ return JSON.parse(readFileSync(inventoryFile, 'utf8'));
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function buildInventoryIndex(inventory = null) {
203
+ const index = new Map();
204
+ if (!inventory || typeof inventory !== 'object') return index;
205
+
206
+ for (const client of ['codex', 'gemini']) {
207
+ const servers = Array.isArray(inventory[client]?.servers) ? inventory[client].servers : [];
208
+ for (const server of servers) {
209
+ if (!server || typeof server.name !== 'string' || !server.name.trim()) continue;
210
+ const name = server.name.trim();
211
+ const previous = index.get(name) || {};
212
+ index.set(name, {
213
+ ...previous,
214
+ tool_count: Number.isFinite(server.tool_count)
215
+ ? Math.max(previous.tool_count ?? 0, Math.trunc(server.tool_count))
216
+ : previous.tool_count,
217
+ domain_tags: uniqueStrings([
218
+ ...(Array.isArray(previous.domain_tags) ? previous.domain_tags : []),
219
+ ...(Array.isArray(server.domain_tags) ? server.domain_tags : []),
220
+ ]),
221
+ });
222
+ }
223
+ }
224
+
225
+ return index;
226
+ }
227
+
228
+ function getServerMetadata(server, inventoryIndex) {
229
+ return normalizeServerMetadata(server, inventoryIndex.get(server) || {});
230
+ }
231
+
232
+ function scoreServer(server, taskText = '', inventoryIndex = new Map()) {
233
+ const normalized = normalizeTaskText(taskText);
234
+ const metadata = getServerMetadata(server, inventoryIndex);
235
+ if (!normalized) {
236
+ return {
237
+ server,
238
+ score: 0,
239
+ toolCount: metadata.tool_count,
240
+ matchedTags: [],
241
+ explicitMatch: false,
242
+ };
243
+ }
244
+
245
+ let score = 0;
246
+ const matchedTags = [];
247
+ for (const tag of metadata.domain_tags) {
248
+ const matches = countKeywordMatches(normalized, DOMAIN_TAG_KEYWORDS[tag] || []);
249
+ if (matches > 0) {
250
+ matchedTags.push(tag);
251
+ score += matches * 2;
252
+ }
253
+ }
254
+
255
+ const explicitMatches = countKeywordMatches(normalized, SERVER_EXPLICIT_KEYWORDS[server] || []);
256
+ if (explicitMatches > 0) {
257
+ score += explicitMatches * 4;
258
+ }
259
+
260
+ const toolKeywords = (MCP_SERVER_TOOL_CATALOG[server] || [])
261
+ .flatMap((toolName) => String(toolName).split(/[_-]+/))
262
+ .filter((token) => token.length >= 4);
263
+ score += countKeywordMatches(normalized, toolKeywords);
264
+
265
+ return {
266
+ server,
267
+ score,
268
+ toolCount: metadata.tool_count,
269
+ matchedTags,
270
+ explicitMatch: explicitMatches > 0,
271
+ };
272
+ }
273
+
274
+ function compareRankedServers(left, right, workerIndex, availableOrder = []) {
275
+ if (right.explicitMatch !== left.explicitMatch) {
276
+ return Number(right.explicitMatch) - Number(left.explicitMatch);
277
+ }
278
+ if (right.score !== left.score) return right.score - left.score;
279
+ if (left.toolCount !== right.toolCount) return left.toolCount - right.toolCount;
280
+
281
+ if (Number.isInteger(workerIndex) && workerIndex > 0 && availableOrder.length > 1) {
282
+ const offset = (workerIndex - 1) % availableOrder.length;
283
+ const rotated = availableOrder.slice(offset).concat(availableOrder.slice(0, offset));
284
+ return rotated.indexOf(left.server) - rotated.indexOf(right.server);
285
+ }
286
+
287
+ return availableOrder.indexOf(left.server) - availableOrder.indexOf(right.server);
288
+ }
289
+
290
+ function rankServers(servers = [], options = {}) {
291
+ const inventoryIndex = options.inventoryIndex instanceof Map
292
+ ? options.inventoryIndex
293
+ : buildInventoryIndex(options.inventory);
294
+ return servers
295
+ .map((server) => scoreServer(server, options.taskText, inventoryIndex))
296
+ .sort((left, right) => compareRankedServers(left, right, options.workerIndex, servers));
297
+ }
298
+
299
+ function hasContextSignals(servers = [], options = {}) {
300
+ return rankServers(servers, options).some((server) => server.score > 0);
301
+ }
302
+
303
+ function inferPreferredSearchTool(taskText = '', inventoryIndex = new Map(), allowedServers = SEARCH_SERVER_ORDER) {
304
+ const ranked = rankServers(
305
+ SEARCH_SERVER_ORDER.filter((server) => allowedServers.includes(server)),
306
+ { taskText, inventoryIndex },
307
+ );
308
+ return ranked.find((server) => server.score > 0)?.server || '';
309
+ }
310
+
311
+ function selectContextualServers(baseServers, profile, options = {}) {
312
+ const taskText = normalizeTaskText(options.taskText);
313
+ if (!taskText || !baseServers.length) return [...baseServers];
314
+
315
+ const inventoryIndex = options.inventoryIndex instanceof Map
316
+ ? options.inventoryIndex
317
+ : buildInventoryIndex(options.inventory);
318
+ if (!hasContextSignals(baseServers, { ...options, inventoryIndex })) return [...baseServers];
319
+
320
+ const selected = new Set(
321
+ (profile.alwaysOnServers || []).filter((server) => baseServers.includes(server)),
322
+ );
323
+ const requestedSearchTool = typeof options.searchTool === 'string' ? options.searchTool : '';
324
+
325
+ const rankedServers = rankServers(
326
+ baseServers.filter((server) => !SEARCH_SERVER_ORDER.includes(server) && !selected.has(server)),
327
+ { ...options, inventoryIndex },
328
+ );
329
+ for (const ranked of rankedServers) {
330
+ if (ranked.score > 0 || ranked.explicitMatch) {
331
+ selected.add(ranked.server);
332
+ }
333
+ }
334
+
335
+ const wantsSearchFallback = SEARCH_INTENT_PATTERNS.some((pattern) => {
336
+ pattern.lastIndex = 0;
337
+ return pattern.test(taskText);
338
+ });
339
+ const orderedSearchServers = resolveSearchToolOrder(
340
+ requestedSearchTool,
341
+ Number.isInteger(options.workerIndex) ? options.workerIndex : undefined,
342
+ baseServers.filter((server) => SEARCH_SERVER_ORDER.includes(server)),
343
+ taskText,
344
+ { inventoryIndex },
345
+ );
346
+ const rankedSearchSet = new Set(orderedSearchServers.filter((server) => {
347
+ if (requestedSearchTool === server) return true;
348
+ const ranked = scoreServer(server, taskText, inventoryIndex);
349
+ return ranked.score > 0 || ranked.explicitMatch;
350
+ }));
351
+ const maxSearchServers = Number.isInteger(profile.maxSearchServers)
352
+ ? profile.maxSearchServers
353
+ : orderedSearchServers.length;
354
+ const chosenSearchServers = (
355
+ rankedSearchSet.size
356
+ ? orderedSearchServers.filter((server) => rankedSearchSet.has(server))
357
+ : wantsSearchFallback
358
+ ? orderedSearchServers.slice(0, 1)
359
+ : []
360
+ ).slice(0, maxSearchServers);
361
+
362
+ for (const server of chosenSearchServers) {
363
+ selected.add(server);
364
+ }
365
+
366
+ const alwaysOnServers = baseServers.filter((server) => selected.has(server) && (profile.alwaysOnServers || []).includes(server));
367
+ const contextualNonSearch = rankServers(
368
+ baseServers.filter((server) => selected.has(server) && !SEARCH_SERVER_ORDER.includes(server) && !alwaysOnServers.includes(server)),
369
+ { ...options, inventoryIndex },
370
+ ).map((entry) => entry.server);
371
+ const contextualSearch = orderedSearchServers.filter((server) => selected.has(server));
372
+ const contextualServers = uniqueStrings([
373
+ ...alwaysOnServers,
374
+ ...contextualNonSearch,
375
+ ...contextualSearch,
376
+ ]);
377
+ return contextualServers.length ? contextualServers : [...baseServers];
378
+ }
379
+
380
+ export function resolveMcpProfile(agentType = '', requestedProfile = 'auto') {
381
+ const normalized = normalizeProfileName(requestedProfile);
382
+ return normalized === 'auto' ? resolveAutoProfile(agentType) : normalized;
383
+ }
384
+
385
+ export function parseAvailableServers(rawAvailableServers = '') {
386
+ if (Array.isArray(rawAvailableServers)) return uniqueStrings(rawAvailableServers);
387
+ if (typeof rawAvailableServers !== 'string' || !rawAvailableServers.trim()) return [];
388
+ return uniqueStrings(rawAvailableServers.split(/[,\s]+/));
389
+ }
390
+
391
+ export function resolveSearchToolOrder(searchTool = '', workerIndex, allowedServers = SEARCH_SERVER_ORDER, taskText = '', options = {}) {
392
+ const available = SEARCH_SERVER_ORDER.filter((tool) => allowedServers.includes(tool));
393
+ if (!available.length) return [];
394
+
395
+ const inventoryIndex = options.inventoryIndex instanceof Map
396
+ ? options.inventoryIndex
397
+ : buildInventoryIndex(options.inventory);
398
+ const preferredSearchTool = searchTool && available.includes(searchTool)
399
+ ? searchTool
400
+ : inferPreferredSearchTool(taskText, inventoryIndex, available);
401
+
402
+ const ranked = rankServers(available, { taskText, workerIndex, inventoryIndex }).map((entry) => entry.server);
403
+ if (!preferredSearchTool || !available.includes(preferredSearchTool)) {
404
+ return ranked;
405
+ }
406
+
407
+ return [preferredSearchTool, ...ranked.filter((tool) => tool !== preferredSearchTool)];
408
+ }
409
+
410
+ function getProfileDefinition(resolvedProfile) {
411
+ return PROFILE_DEFINITIONS[resolvedProfile] || PROFILE_DEFINITIONS.default;
412
+ }
413
+
414
+ export function resolveAllowedServers(options = {}) {
415
+ const resolvedProfile = resolveMcpProfile(options.agentType, options.requestedProfile);
416
+ const profile = getProfileDefinition(resolvedProfile);
417
+ const availableServers = parseAvailableServers(options.availableServers);
418
+ const inventory = options.inventory || loadInventory(options.inventoryFile);
419
+ const inventoryIndex = buildInventoryIndex(inventory);
420
+ const baseServers = availableServers.length
421
+ ? profile.allowedServers.filter((server) => availableServers.includes(server))
422
+ : [...profile.allowedServers];
423
+ return selectContextualServers(baseServers, profile, { ...options, inventory, inventoryIndex });
424
+ }
425
+
426
+ export function buildPromptHint(options = {}) {
427
+ const resolvedProfile = resolveMcpProfile(options.agentType, options.requestedProfile);
428
+ if (resolvedProfile === 'none') return '';
429
+
430
+ const inventory = options.inventory || loadInventory(options.inventoryFile);
431
+ const inventoryIndex = buildInventoryIndex(inventory);
432
+ const allowedServers = resolveAllowedServers({ ...options, inventory, inventoryIndex });
433
+ const orderedTools = resolveSearchToolOrder(
434
+ options.searchTool,
435
+ Number.isInteger(options.workerIndex) ? options.workerIndex : undefined,
436
+ allowedServers,
437
+ options.taskText,
438
+ { inventory, inventoryIndex },
439
+ );
440
+ const has = (server) => allowedServers.includes(server);
441
+ const orderedSearchHint = orderedTools.length > 1
442
+ ? `웹 검색 우선순위: ${orderedTools.join(', ')}.`
443
+ : orderedTools[0]
444
+ ? `웹 검색은 ${orderedTools[0]}를 사용하세요.`
445
+ : '';
446
+ const searchFallbackHint = orderedTools.length > 1
447
+ ? '검색 도구 실패 시 402, 429, 432, 433, quota 에러에서 재시도하지 말고 다음 도구로 전환하세요.'
448
+ : '';
449
+ return [
450
+ has('context7') ? 'context7으로 관련 문서를 조회하세요.' : '',
451
+ has('playwright')
452
+ ? resolvedProfile === 'designer'
453
+ ? '화면/레이아웃 확인은 playwright를 우선 사용하세요.'
454
+ : '브라우저/UI 검증이 필요하면 playwright를 사용하세요.'
455
+ : '',
456
+ has('sequential-thinking') ? 'sequential-thinking으로 체계적으로 분석하세요.' : '',
457
+ resolvedProfile === 'reviewer' && orderedTools[0] ? `외부 근거가 더 필요하면 ${orderedTools[0]}를 사용하세요.` : '',
458
+ resolvedProfile !== 'reviewer' ? orderedSearchHint : '',
459
+ resolvedProfile !== 'reviewer' ? searchFallbackHint : '',
460
+ resolvedProfile === 'explore' ? '검색 깊이를 제한하고 읽기 전용 조사에 집중하세요.' : '',
461
+ resolvedProfile === 'writer' ? '검색 결과의 출처 URL을 함께 제시하세요.' : '',
462
+ ].filter(Boolean).join(' ');
463
+ }
464
+
465
+ export function getGeminiAllowedServers(options = {}) {
466
+ return resolveAllowedServers(options);
467
+ }
468
+
469
+ export function getCodexMcpConfig(options = {}) {
470
+ const allowedServers = new Set(resolveAllowedServers(options));
471
+ const resolvedProfile = resolveMcpProfile(options.agentType, options.requestedProfile);
472
+ if (resolvedProfile === 'none') {
473
+ return {
474
+ mcp_servers: Object.fromEntries(KNOWN_MCP_SERVERS.map((server) => [server, { enabled: false }])),
475
+ };
476
+ }
477
+
478
+ const config = { mcp_servers: {} };
479
+ const allowedToolsByServer = getProfileDefinition(resolvedProfile).allowedToolsByServer;
480
+ for (const server of KNOWN_MCP_SERVERS) {
481
+ if (!allowedServers.has(server)) {
482
+ config.mcp_servers[server] = { enabled: false };
483
+ continue;
484
+ }
485
+
486
+ config.mcp_servers[server] = {
487
+ enabled: true,
488
+ enabled_tools: [...(allowedToolsByServer[server] || [])],
489
+ };
490
+ }
491
+ return config;
492
+ }
493
+
494
+ function toTomlLiteral(value) {
495
+ if (Array.isArray(value)) {
496
+ return `[${value.map((item) => toTomlLiteral(item)).join(',')}]`;
497
+ }
498
+ if (typeof value === 'string') return JSON.stringify(value);
499
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
500
+ throw new Error(`지원하지 않는 TOML 값 타입: ${typeof value}`);
501
+ }
502
+
503
+ function flattenConfig(prefix, value, output) {
504
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
505
+ for (const [key, nestedValue] of Object.entries(value)) {
506
+ flattenConfig(prefix ? `${prefix}.${key}` : key, nestedValue, output);
507
+ }
508
+ return;
509
+ }
510
+ output.push(`${prefix}=${toTomlLiteral(value)}`);
511
+ }
512
+
513
+ export function getCodexConfigOverrides(options = {}) {
514
+ const config = getCodexMcpConfig(options);
515
+ const overrides = [];
516
+ flattenConfig('', config, overrides);
517
+ return overrides;
518
+ }
519
+
520
+ export function buildMcpPolicy(options = {}) {
521
+ const inventory = options.inventory || loadInventory(options.inventoryFile);
522
+ const inventoryIndex = buildInventoryIndex(inventory);
523
+ const resolvedOptions = { ...options, inventory, inventoryIndex };
524
+ const resolvedProfile = resolveMcpProfile(options.agentType, options.requestedProfile);
525
+ const allowedServers = resolveAllowedServers(resolvedOptions);
526
+ const hint = buildPromptHint(resolvedOptions);
527
+ return {
528
+ requestedProfile: typeof options.requestedProfile === 'string' && options.requestedProfile
529
+ ? options.requestedProfile
530
+ : 'auto',
531
+ resolvedProfile,
532
+ allowedServers,
533
+ hint,
534
+ geminiAllowedServers: getGeminiAllowedServers(resolvedOptions),
535
+ codexConfig: getCodexMcpConfig(resolvedOptions),
536
+ codexConfigOverrides: getCodexConfigOverrides(resolvedOptions),
537
+ };
538
+ }
539
+
540
+ function shellEscape(value) {
541
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
542
+ }
543
+
544
+ function shellArray(name, values) {
545
+ return `${name}=(${values.map((value) => shellEscape(value)).join(' ')})`;
546
+ }
547
+
548
+ export function toShellExports(policy) {
549
+ return [
550
+ `MCP_PROFILE_REQUESTED=${shellEscape(policy.requestedProfile)}`,
551
+ `MCP_RESOLVED_PROFILE=${shellEscape(policy.resolvedProfile)}`,
552
+ `MCP_HINT=${shellEscape(policy.hint)}`,
553
+ shellArray('GEMINI_ALLOWED_SERVERS', policy.geminiAllowedServers),
554
+ shellArray('CODEX_CONFIG_FLAGS', policy.codexConfigOverrides.flatMap((override) => ['-c', override])),
555
+ `CODEX_CONFIG_JSON=${shellEscape(JSON.stringify(policy.codexConfig))}`,
556
+ ].join('\n');
557
+ }
558
+
559
+ function parseCliArgs(argv) {
560
+ const args = {
561
+ command: 'json',
562
+ agentType: '',
563
+ requestedProfile: 'auto',
564
+ availableServers: [],
565
+ inventoryFile: '',
566
+ searchTool: '',
567
+ taskText: '',
568
+ workerIndex: undefined,
569
+ };
570
+
571
+ const [first = 'json'] = argv;
572
+ if (!first.startsWith('--')) {
573
+ args.command = first;
574
+ argv = argv.slice(1);
575
+ }
576
+
577
+ for (let i = 0; i < argv.length; i += 1) {
578
+ const token = argv[i];
579
+ const next = () => {
580
+ const value = argv[i + 1];
581
+ if (value === undefined) throw new Error(`${token} 값이 필요합니다.`);
582
+ i += 1;
583
+ return value;
584
+ };
585
+
586
+ switch (token) {
587
+ case '--agent':
588
+ args.agentType = next();
589
+ break;
590
+ case '--profile':
591
+ args.requestedProfile = next();
592
+ break;
593
+ case '--available':
594
+ args.availableServers = parseAvailableServers(next());
595
+ break;
596
+ case '--inventory-file':
597
+ args.inventoryFile = next();
598
+ break;
599
+ case '--search-tool':
600
+ args.searchTool = next();
601
+ break;
602
+ case '--task-text':
603
+ args.taskText = next();
604
+ break;
605
+ case '--worker-index':
606
+ args.workerIndex = Number.parseInt(next(), 10);
607
+ break;
608
+ default:
609
+ throw new Error(`알 수 없는 옵션: ${token}`);
610
+ }
611
+ }
612
+
613
+ return args;
614
+ }
615
+
616
+ export async function runCli(argv = process.argv.slice(2)) {
617
+ let args;
618
+ try {
619
+ args = parseCliArgs(argv);
620
+ } catch (error) {
621
+ console.error(`[mcp-filter] ${error instanceof Error ? error.message : String(error)}`);
622
+ process.exitCode = 64;
623
+ return;
624
+ }
625
+
626
+ const policy = buildMcpPolicy(args);
627
+ if (args.command === 'shell') {
628
+ process.stdout.write(`${toShellExports(policy)}\n`);
629
+ return;
630
+ }
631
+
632
+ process.stdout.write(`${JSON.stringify(policy, null, 2)}\n`);
633
+ }
634
+
635
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
636
+ await runCli();
637
+ }