triflux 8.10.0 → 8.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-gateway-config.mjs — Claude Code MCP stdio↔SSE 전환
|
|
3
|
+
// Usage: node mcp-gateway-config.mjs --enable # stdio → SSE
|
|
4
|
+
// node mcp-gateway-config.mjs --disable # SSE → stdio (복원)
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const GATEWAY_SERVERS = [
|
|
9
|
+
{ name: 'context7', port: 8100, stdioCmd: 'cmd /c npx -y @upstash/context7-mcp@latest' },
|
|
10
|
+
{ name: 'brave-search', port: 8101, stdioCmd: 'cmd /c npx -y @brave/brave-search-mcp-server' },
|
|
11
|
+
{ name: 'exa', port: 8102, stdioCmd: 'cmd /c npx -y exa-mcp-server' },
|
|
12
|
+
{ name: 'tavily', port: 8103, stdioCmd: 'cmd /c npx -y tavily-mcp@latest' },
|
|
13
|
+
{ name: 'jira', port: 8104, stdioCmd: 'cmd /c npx -y mcp-jira-cloud@latest' },
|
|
14
|
+
{ name: 'serena', port: 8105, stdioCmd: 'uvx --from git+https://github.com/oraios/serena serena start-mcp-server' },
|
|
15
|
+
{ name: 'notion', port: 8106, stdioCmd: 'cmd /c npx -y @notionhq/notion-mcp-server' },
|
|
16
|
+
{ name: 'notion-guest', port: 8107, stdioCmd: 'cmd /c npx -y @notionhq/notion-mcp-server' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const SKIP_SERVERS = new Set([
|
|
20
|
+
'playwright',
|
|
21
|
+
'claude.ai Notion',
|
|
22
|
+
'plugin:oh-my-claudecode:t',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Git Bash에서 /c → C:/ 경로 변환 방지
|
|
26
|
+
const EXEC_ENV = { ...process.env, MSYS_NO_PATHCONV: '1' };
|
|
27
|
+
|
|
28
|
+
function run(cmd) {
|
|
29
|
+
try {
|
|
30
|
+
execSync(cmd, { stdio: 'pipe', encoding: 'utf8', timeout: 15000, env: EXEC_ENV });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function removeMcp(name) {
|
|
38
|
+
// 여러 scope에서 제거 시도 (user → local)
|
|
39
|
+
run(`claude mcp remove "${name}" -s user`);
|
|
40
|
+
run(`claude mcp remove "${name}" -s local`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function enableSse() {
|
|
44
|
+
console.log('Switching MCP servers to SSE mode...\n');
|
|
45
|
+
let ok = 0;
|
|
46
|
+
let fail = 0;
|
|
47
|
+
|
|
48
|
+
for (const { name, port } of GATEWAY_SERVERS) {
|
|
49
|
+
if (SKIP_SERVERS.has(name)) continue;
|
|
50
|
+
|
|
51
|
+
removeMcp(name);
|
|
52
|
+
const url = `http://localhost:${port}/sse`;
|
|
53
|
+
const success = run(`claude mcp add --transport sse -s user "${name}" ${url}`);
|
|
54
|
+
|
|
55
|
+
if (success) {
|
|
56
|
+
console.log(` [SSE] ${name} → ${url}`);
|
|
57
|
+
ok++;
|
|
58
|
+
} else {
|
|
59
|
+
console.error(` [FAIL] ${name}`);
|
|
60
|
+
fail++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`\nDone: ${ok} switched, ${fail} failed`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function disableSse() {
|
|
68
|
+
console.log('Restoring MCP servers to stdio mode...\n');
|
|
69
|
+
let ok = 0;
|
|
70
|
+
let fail = 0;
|
|
71
|
+
|
|
72
|
+
for (const { name, stdioCmd } of GATEWAY_SERVERS) {
|
|
73
|
+
if (SKIP_SERVERS.has(name)) continue;
|
|
74
|
+
|
|
75
|
+
removeMcp(name);
|
|
76
|
+
const success = run(`claude mcp add "${name}" -s user -- ${stdioCmd}`);
|
|
77
|
+
|
|
78
|
+
if (success) {
|
|
79
|
+
console.log(` [stdio] ${name} → ${stdioCmd}`);
|
|
80
|
+
ok++;
|
|
81
|
+
} else {
|
|
82
|
+
console.error(` [FAIL] ${name}`);
|
|
83
|
+
fail++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`\nDone: ${ok} restored, ${fail} failed`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printUsage() {
|
|
91
|
+
console.log(`Usage: node mcp-gateway-config.mjs [--enable|--disable]
|
|
92
|
+
|
|
93
|
+
--enable Switch Claude Code MCP servers from stdio to SSE (supergateway)
|
|
94
|
+
--disable Restore Claude Code MCP servers to original stdio mode
|
|
95
|
+
|
|
96
|
+
Servers managed: ${GATEWAY_SERVERS.map((s) => s.name).join(', ')}
|
|
97
|
+
Servers skipped: ${[...SKIP_SERVERS].join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── main ──
|
|
101
|
+
const flag = process.argv[2];
|
|
102
|
+
|
|
103
|
+
if (flag === '--enable') {
|
|
104
|
+
enableSse();
|
|
105
|
+
} else if (flag === '--disable') {
|
|
106
|
+
disableSse();
|
|
107
|
+
} else {
|
|
108
|
+
printUsage();
|
|
109
|
+
process.exit(flag ? 1 : 0);
|
|
110
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-gateway-ensure.mjs — SessionStart 훅에서 supergateway MCP 서비스 보장
|
|
3
|
+
// hub-ensure.mjs 패턴을 따름. 가볍게 헬스체크만 수행하고 필요시 기동.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
12
|
+
const PID_FILE = join(tmpdir(), 'tfx-gateway-pids.json');
|
|
13
|
+
const PROBE_PORT = 8100; // context7 — 첫 번째 게이트웨이 포트로 alive 프로브
|
|
14
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
15
|
+
const STARTUP_WAIT_MS = 4000;
|
|
16
|
+
const POLL_INTERVAL_MS = 500;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 단일 포트 /healthz 프로브로 게이트웨이 클러스터 alive 판정.
|
|
20
|
+
* 모든 포트를 체크하면 hook timeout(8s)에 걸리므로 대표 포트 1개만 확인.
|
|
21
|
+
*/
|
|
22
|
+
async function isGatewayAlive() {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`http://127.0.0.1:${PROBE_PORT}/healthz`, {
|
|
25
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
26
|
+
});
|
|
27
|
+
return res.ok;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 매니페스트 파일 존재 여부로 gateway 설치 판정 (빠른 경로) */
|
|
34
|
+
function hasManifest() {
|
|
35
|
+
return existsSync(PID_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** mcp-gateway-start.mjs를 detached로 기동 */
|
|
39
|
+
function startGateway() {
|
|
40
|
+
const scriptPath = join(PLUGIN_ROOT, 'scripts', 'mcp-gateway-start.mjs');
|
|
41
|
+
if (!existsSync(scriptPath)) return false;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// hub-ensure.mjs 패턴: cmd.exe /c start /b → hook timeout에서 생존
|
|
45
|
+
const child = spawn('cmd.exe', ['/c', 'start', '/b', '', process.execPath, scriptPath], {
|
|
46
|
+
stdio: 'ignore',
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
child.unref();
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 게이트웨이 기동 후 프로브 포트 ready 대기 */
|
|
57
|
+
async function waitForGatewayReady(maxWaitMs = STARTUP_WAIT_MS) {
|
|
58
|
+
const deadline = Date.now() + maxWaitMs;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
if (await isGatewayAlive()) return true;
|
|
61
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── main ──
|
|
67
|
+
|
|
68
|
+
// 빠른 경로: 매니페스트 존재 + 프로브 포트 살아있으면 즉시 OK
|
|
69
|
+
if (hasManifest() && await isGatewayAlive()) {
|
|
70
|
+
process.stdout.write('gateway: ok');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 매니페스트 없으면 gateway가 설정되지 않은 상태 — 조용히 스킵
|
|
75
|
+
if (!hasManifest()) {
|
|
76
|
+
process.stdout.write('gateway: not configured');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 느린 경로: 게이트웨이 기동 시도
|
|
81
|
+
const started = startGateway();
|
|
82
|
+
if (started) {
|
|
83
|
+
const ready = await waitForGatewayReady();
|
|
84
|
+
process.stdout.write(ready ? 'gateway: ok' : 'gateway: starting');
|
|
85
|
+
} else {
|
|
86
|
+
process.stderr.write('[gateway-ensure] start failed');
|
|
87
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-gateway-start.mjs — supergateway MCP SSE 영속 서비스 관리
|
|
3
|
+
// Usage: node mcp-gateway-start.mjs # 시작
|
|
4
|
+
// node mcp-gateway-start.mjs --stop # 중지
|
|
5
|
+
// node mcp-gateway-start.mjs --status # 상태 확인
|
|
6
|
+
|
|
7
|
+
import { spawn, execSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { createConnection } from 'node:net';
|
|
12
|
+
|
|
13
|
+
const PID_FILE = join(tmpdir(), 'tfx-gateway-pids.json');
|
|
14
|
+
const STARTUP_WAIT_MS = 8000;
|
|
15
|
+
const POLL_INTERVAL_MS = 500;
|
|
16
|
+
const HEALTH_TIMEOUT_MS = 3000;
|
|
17
|
+
|
|
18
|
+
const SERVERS = [
|
|
19
|
+
{ name: 'context7', port: 8100, cmd: 'npx -y @upstash/context7-mcp@latest', envVars: [] },
|
|
20
|
+
{ name: 'brave-search', port: 8101, cmd: 'npx -y @brave/brave-search-mcp-server', envVars: ['BRAVE_API_KEY'] },
|
|
21
|
+
{ name: 'exa', port: 8102, cmd: 'npx -y exa-mcp-server', envVars: ['EXA_API_KEY'] },
|
|
22
|
+
{ name: 'tavily', port: 8103, cmd: 'npx -y tavily-mcp@latest', envVars: ['TAVILY_API_KEY'] },
|
|
23
|
+
{ name: 'jira', port: 8104, cmd: 'npx -y mcp-jira-cloud@latest', envVars: ['JIRA_API_TOKEN', 'JIRA_EMAIL', 'JIRA_INSTANCE_URL'] },
|
|
24
|
+
{ name: 'serena', port: 8105, cmd: 'uvx --from git+https://github.com/oraios/serena serena start-mcp-server', envVars: [] },
|
|
25
|
+
{ name: 'notion', port: 8106, cmd: 'npx -y @notionhq/notion-mcp-server', envVars: ['NOTION_TOKEN'] },
|
|
26
|
+
{ name: 'notion-guest', port: 8107, cmd: 'npx -y @notionhq/notion-mcp-server', envVars: ['NOTION_TOKEN'] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export { SERVERS };
|
|
30
|
+
|
|
31
|
+
// ── 유틸리티 ──
|
|
32
|
+
|
|
33
|
+
function isPortInUse(port) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const sock = createConnection({ host: '127.0.0.1', port });
|
|
36
|
+
sock.once('connect', () => { sock.destroy(); resolve(true); });
|
|
37
|
+
sock.once('error', () => resolve(false));
|
|
38
|
+
sock.setTimeout(1000, () => { sock.destroy(); resolve(false); });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function checkHealth(port) {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`, {
|
|
45
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
46
|
+
});
|
|
47
|
+
return res.ok;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sleep(ms) {
|
|
54
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 시작 ──
|
|
58
|
+
|
|
59
|
+
function spawnGateway(srv) {
|
|
60
|
+
// 단일 명령 문자열 — shell: true에서 공백 인자를 올바르게 쿼팅
|
|
61
|
+
const cmdStr = `npx -y supergateway --stdio "${srv.cmd}" --port ${srv.port} --outputTransport sse --healthEndpoint /healthz`;
|
|
62
|
+
|
|
63
|
+
// detached: true → 독립 프로세스 그룹 (부모 종료 후 생존)
|
|
64
|
+
// shell: true → Windows에서 npx.cmd 해석
|
|
65
|
+
const child = spawn(cmdStr, [], {
|
|
66
|
+
detached: true,
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
shell: true,
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
env: process.env,
|
|
71
|
+
});
|
|
72
|
+
child.unref();
|
|
73
|
+
return child.pid;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function startAll() {
|
|
77
|
+
const launched = [];
|
|
78
|
+
|
|
79
|
+
for (const srv of SERVERS) {
|
|
80
|
+
// 포트 사용 중이면 스킵
|
|
81
|
+
if (await isPortInUse(srv.port)) {
|
|
82
|
+
console.log(`[SKIP] ${srv.name} already running on :${srv.port}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 필수 환경변수 체크
|
|
87
|
+
const missing = srv.envVars.filter((k) => !process.env[k]);
|
|
88
|
+
if (missing.length > 0) {
|
|
89
|
+
console.log(`[WARN] ${srv.name} skipped — missing env: ${missing.join(', ')}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
spawnGateway(srv);
|
|
94
|
+
launched.push(srv);
|
|
95
|
+
console.log(`[START] ${srv.name} on :${srv.port}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (launched.length === 0) {
|
|
99
|
+
console.log('\n[gateway] No servers started (all running or skipped)');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 헬스체크 대기
|
|
104
|
+
console.log(`\n[gateway] Waiting for ${launched.length} servers...`);
|
|
105
|
+
const deadline = Date.now() + STARTUP_WAIT_MS;
|
|
106
|
+
const pending = new Set(launched.map((s) => s.port));
|
|
107
|
+
|
|
108
|
+
while (pending.size > 0 && Date.now() < deadline) {
|
|
109
|
+
await sleep(POLL_INTERVAL_MS);
|
|
110
|
+
for (const port of [...pending]) {
|
|
111
|
+
if (await checkHealth(port)) pending.delete(port);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 결과 출력
|
|
116
|
+
console.log('\nHealth Check');
|
|
117
|
+
console.log('='.repeat(50));
|
|
118
|
+
const pidEntries = [];
|
|
119
|
+
for (const srv of launched) {
|
|
120
|
+
const healthy = !pending.has(srv.port);
|
|
121
|
+
const mark = healthy ? '\u2713' : '\u2717';
|
|
122
|
+
const status = healthy ? 'ok' : 'down';
|
|
123
|
+
console.log(` ${srv.name.padEnd(16)} :${srv.port} ${mark} ${status}`);
|
|
124
|
+
if (healthy) pidEntries.push({ name: srv.name, port: srv.port });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// PID 파일 대신 포트 매니페스트 저장 (프로세스 찾기는 포트 기반)
|
|
128
|
+
const existing = loadManifest();
|
|
129
|
+
const merged = [...existing.filter((e) => !pidEntries.some((p) => p.port === e.port)), ...pidEntries];
|
|
130
|
+
writeFileSync(PID_FILE, JSON.stringify(merged, null, 2));
|
|
131
|
+
console.log(`\n[gateway] ${launched.length - pending.size}/${launched.length} healthy. Manifest: ${PID_FILE}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 중지 ──
|
|
135
|
+
|
|
136
|
+
function stopAll() {
|
|
137
|
+
// supergateway + 하위 MCP 프로세스를 포트 기반으로 찾아 종료
|
|
138
|
+
try {
|
|
139
|
+
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe'" | Where-Object { $_.CommandLine -match 'supergateway' } | ForEach-Object { taskkill /F /T /PID $_.ProcessId 2>$null; Write-Output "[STOP] PID $($_.ProcessId)" }`;
|
|
140
|
+
const output = execSync(`powershell -NoProfile -Command "${ps}"`, {
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
timeout: 10000,
|
|
143
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
144
|
+
});
|
|
145
|
+
if (output.trim()) console.log(output.trim());
|
|
146
|
+
else console.log('[gateway] No supergateway processes found');
|
|
147
|
+
} catch {
|
|
148
|
+
console.log('[gateway] No supergateway processes found');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (existsSync(PID_FILE)) {
|
|
152
|
+
unlinkSync(PID_FILE);
|
|
153
|
+
console.log('[gateway] Manifest removed');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 상태 ──
|
|
158
|
+
|
|
159
|
+
async function showStatus() {
|
|
160
|
+
const manifest = loadManifest();
|
|
161
|
+
if (manifest.length === 0) {
|
|
162
|
+
console.log('[gateway] No manifest — checking all ports...');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('\nMCP Gateway Status');
|
|
166
|
+
console.log('='.repeat(50));
|
|
167
|
+
for (const srv of SERVERS) {
|
|
168
|
+
const healthy = await checkHealth(srv.port);
|
|
169
|
+
const mark = healthy ? '\u2713' : '\u2717';
|
|
170
|
+
const status = healthy ? 'ok' : 'down';
|
|
171
|
+
console.log(` ${srv.name.padEnd(16)} :${srv.port} ${mark} ${status}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function loadManifest() {
|
|
176
|
+
if (!existsSync(PID_FILE)) return [];
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(readFileSync(PID_FILE, 'utf8'));
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── main ──
|
|
185
|
+
|
|
186
|
+
const flag = process.argv[2];
|
|
187
|
+
if (flag === '--stop') {
|
|
188
|
+
stopAll();
|
|
189
|
+
} else if (flag === '--status') {
|
|
190
|
+
await showStatus();
|
|
191
|
+
} else {
|
|
192
|
+
await startAll();
|
|
193
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# mcp-gateway-start.ps1 — supergateway MCP SSE 영속 서비스 관리
|
|
2
|
+
# 각 MCP 서버를 supergateway로 래핑하여 SSE 엔드포인트로 노출한다.
|
|
3
|
+
# Usage: .\mcp-gateway-start.ps1 # 시작
|
|
4
|
+
# .\mcp-gateway-start.ps1 -Stop # 중지
|
|
5
|
+
|
|
6
|
+
param(
|
|
7
|
+
[switch]$Stop
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
$PidFile = Join-Path $env:TEMP 'tfx-gateway-pids.json'
|
|
11
|
+
|
|
12
|
+
$Servers = @(
|
|
13
|
+
@{ Name = 'context7'; Port = 8100; Cmd = 'npx -y @upstash/context7-mcp@latest'; EnvVars = @() }
|
|
14
|
+
@{ Name = 'brave-search'; Port = 8101; Cmd = 'npx -y @brave/brave-search-mcp-server'; EnvVars = @('BRAVE_API_KEY') }
|
|
15
|
+
@{ Name = 'exa'; Port = 8102; Cmd = 'npx -y exa-mcp-server'; EnvVars = @('EXA_API_KEY') }
|
|
16
|
+
@{ Name = 'tavily'; Port = 8103; Cmd = 'npx -y tavily-mcp@latest'; EnvVars = @('TAVILY_API_KEY') }
|
|
17
|
+
@{ Name = 'jira'; Port = 8104; Cmd = 'npx -y mcp-jira-cloud@latest'; EnvVars = @('JIRA_API_TOKEN', 'JIRA_EMAIL', 'JIRA_INSTANCE_URL') }
|
|
18
|
+
@{ Name = 'serena'; Port = 8105; Cmd = 'uvx --from git+https://github.com/oraios/serena serena start-mcp-server'; EnvVars = @() }
|
|
19
|
+
@{ Name = 'notion'; Port = 8106; Cmd = 'npx -y @notionhq/notion-mcp-server'; EnvVars = @('NOTION_TOKEN') }
|
|
20
|
+
@{ Name = 'notion-guest'; Port = 8107; Cmd = 'npx -y @notionhq/notion-mcp-server'; EnvVars = @('NOTION_TOKEN') }
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
function Test-PortInUse {
|
|
24
|
+
param([int]$Port)
|
|
25
|
+
try {
|
|
26
|
+
$tcp = New-Object System.Net.Sockets.TcpClient
|
|
27
|
+
$tcp.Connect('127.0.0.1', $Port)
|
|
28
|
+
$tcp.Close()
|
|
29
|
+
return $true
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return $false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Stop-AllGateways {
|
|
37
|
+
if (-not (Test-Path $PidFile)) {
|
|
38
|
+
Write-Host '[gateway] PID file not found — nothing to stop'
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
$entries = Get-Content $PidFile -Raw | ConvertFrom-Json
|
|
43
|
+
foreach ($entry in $entries) {
|
|
44
|
+
try {
|
|
45
|
+
Stop-Process -Id $entry.pid -Force -ErrorAction SilentlyContinue
|
|
46
|
+
Write-Host "[STOP] $($entry.name) (PID $($entry.pid))"
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
Write-Host "[SKIP] $($entry.name) (PID $($entry.pid)) — already gone"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
|
53
|
+
Write-Host '[gateway] All gateways stopped'
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
Write-Error "[gateway] Failed to parse PID file: $_"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Start-AllGateways {
|
|
61
|
+
$pidEntries = @()
|
|
62
|
+
$started = 0
|
|
63
|
+
|
|
64
|
+
foreach ($srv in $Servers) {
|
|
65
|
+
$name = $srv.Name
|
|
66
|
+
$port = $srv.Port
|
|
67
|
+
|
|
68
|
+
# 포트 사용 중이면 스킵
|
|
69
|
+
if (Test-PortInUse -Port $port) {
|
|
70
|
+
Write-Host "[SKIP] $name already running on :$port"
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# 필수 환경변수 체크
|
|
75
|
+
$missing = @()
|
|
76
|
+
foreach ($envKey in $srv.EnvVars) {
|
|
77
|
+
if (-not [Environment]::GetEnvironmentVariable($envKey)) {
|
|
78
|
+
$missing += $envKey
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if ($missing.Count -gt 0) {
|
|
82
|
+
Write-Host "[WARN] $name skipped — missing env: $($missing -join ', ')"
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# supergateway 기동 — npx는 .cmd 파일이므로 cmd.exe /c 로 래핑
|
|
87
|
+
$stdioCmdEscaped = $srv.Cmd -replace '"', '\"'
|
|
88
|
+
$sgCmd = "npx -y supergateway --stdio `"$stdioCmdEscaped`" --port $port --outputTransport sse --healthEndpoint /healthz"
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
$proc = Start-Process -FilePath 'cmd.exe' -ArgumentList "/c $sgCmd" `
|
|
92
|
+
-WindowStyle Hidden -PassThru -ErrorAction Stop
|
|
93
|
+
$pidEntries += @{ name = $name; port = $port; pid = $proc.Id }
|
|
94
|
+
$started++
|
|
95
|
+
Write-Host "[START] $name on :$port (PID $($proc.Id))"
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
Write-Host "[ERROR] $name failed to start: $_"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# PID 파일 저장
|
|
103
|
+
if ($pidEntries.Count -gt 0) {
|
|
104
|
+
$pidEntries | ConvertTo-Json -Depth 3 | Set-Content -Path $PidFile -Encoding UTF8
|
|
105
|
+
Write-Host "`n[gateway] $started servers started. PID file: $PidFile"
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
Write-Host "`n[gateway] No servers started (all running or skipped)"
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# 헬스체크 (3초 대기 후)
|
|
113
|
+
Write-Host "`n[gateway] Waiting 3s for startup..."
|
|
114
|
+
Start-Sleep -Seconds 3
|
|
115
|
+
|
|
116
|
+
Write-Host "`nHealth Check"
|
|
117
|
+
Write-Host ('=' * 50)
|
|
118
|
+
foreach ($entry in $pidEntries) {
|
|
119
|
+
$url = "http://127.0.0.1:$($entry.port)/healthz"
|
|
120
|
+
try {
|
|
121
|
+
$resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
|
|
122
|
+
$status = if ($resp.StatusCode -eq 200) { 'ok' } else { 'error' }
|
|
123
|
+
$mark = if ($status -eq 'ok') { [char]0x2713 } else { [char]0x2717 }
|
|
124
|
+
Write-Host (" {0,-16} :{1} {2} {3}" -f $entry.name, $entry.port, $mark, $status)
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
Write-Host (" {0,-16} :{1} {2} down" -f $entry.name, $entry.port, [char]0x2717)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ── main ──
|
|
133
|
+
if ($Stop) {
|
|
134
|
+
Stop-AllGateways
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
Start-AllGateways
|
|
138
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-gateway-verify.mjs — supergateway SSE 엔드포인트 헬스체크
|
|
3
|
+
|
|
4
|
+
const ENDPOINTS = [
|
|
5
|
+
{ name: 'context7', port: 8100 },
|
|
6
|
+
{ name: 'brave-search', port: 8101 },
|
|
7
|
+
{ name: 'exa', port: 8102 },
|
|
8
|
+
{ name: 'tavily', port: 8103 },
|
|
9
|
+
{ name: 'jira', port: 8104 },
|
|
10
|
+
{ name: 'serena', port: 8105 },
|
|
11
|
+
{ name: 'notion', port: 8106 },
|
|
12
|
+
{ name: 'notion-guest', port: 8107 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
async function checkHealth(name, port) {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`http://localhost:${port}/healthz`, {
|
|
19
|
+
signal: AbortSignal.timeout(3000),
|
|
20
|
+
});
|
|
21
|
+
const latencyMs = Date.now() - start;
|
|
22
|
+
return res.ok
|
|
23
|
+
? { name, port, status: 'ok', latencyMs, error: null }
|
|
24
|
+
: { name, port, status: 'down', latencyMs, error: `HTTP ${res.status}` };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const latencyMs = Date.now() - start;
|
|
27
|
+
const message = err?.cause?.code || err?.message || 'unknown';
|
|
28
|
+
return { name, port, status: 'down', latencyMs, error: message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const results = await Promise.allSettled(
|
|
34
|
+
ENDPOINTS.map(({ name, port }) => checkHealth(name, port)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const entries = results.map((r) =>
|
|
38
|
+
r.status === 'fulfilled' ? r.value : { name: '?', port: 0, status: 'down', error: r.reason },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
console.log('\nMCP Gateway Health Check');
|
|
42
|
+
console.log('='.repeat(56));
|
|
43
|
+
|
|
44
|
+
let downCount = 0;
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const mark = e.status === 'ok' ? '\u2713' : '\u2717';
|
|
47
|
+
const detail = e.status === 'ok' ? `(${e.latencyMs}ms)` : `(${e.error})`;
|
|
48
|
+
const line = ` ${e.name.padEnd(16)} :${String(e.port).padEnd(6)} ${mark} ${e.status.padEnd(6)} ${detail}`;
|
|
49
|
+
console.log(line);
|
|
50
|
+
if (e.status !== 'ok') downCount++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('='.repeat(56));
|
|
54
|
+
console.log(
|
|
55
|
+
downCount === 0
|
|
56
|
+
? `All ${entries.length} gateways healthy`
|
|
57
|
+
: `${downCount}/${entries.length} gateways down`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
process.exit(downCount > 0 ? 1 : 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main();
|