triflux 8.9.2 → 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 +1 -1
- package/scripts/mcp-cleanup.ps1 +17 -0
- package/scripts/mcp-gateway-config.mjs +110 -0
- package/scripts/mcp-gateway-ensure.mjs +87 -0
- package/scripts/mcp-gateway-start.mjs +193 -0
- package/scripts/mcp-gateway-start.ps1 +138 -0
- package/scripts/mcp-gateway-verify.mjs +63 -0
- package/scripts/setup.mjs +43 -0
- package/skills/tfx-remote-setup/SKILL.md +43 -1
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# mcp-cleanup.ps1 — Claude Code Stop hook: MCP 고아 프로세스 정리
|
|
2
|
+
# Windows에서 Claude Code 세션 종료 시 남는 MCP 서버 고아 프로세스를 정리한다.
|
|
3
|
+
# 원인: Claude Code가 stdio MCP 자식 프로세스 트리를 Windows에서 제대로 kill하지 못함
|
|
4
|
+
# (GitHub Issues #1935, #15211, #28126)
|
|
5
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
6
|
+
|
|
7
|
+
# npx MCP servers (brave, notion, context7, exa, tavily, jira, playwright, etc.)
|
|
8
|
+
# + oh-my-codex MCP servers (team/code-intel/memory/trace/state)
|
|
9
|
+
# + omc bridge
|
|
10
|
+
Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe'" |
|
|
11
|
+
Where-Object { $_.CommandLine -match 'npx-cli|oh-my-codex[\\/]dist[\\/]mcp|omc.*bridge.*mcp-server' } |
|
|
12
|
+
ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
|
|
13
|
+
|
|
14
|
+
# serena (uvx) + python MCP orphans
|
|
15
|
+
Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='uvx.exe'" |
|
|
16
|
+
Where-Object { $_.CommandLine -match 'serena|uv[\\/](cache|python)' } |
|
|
17
|
+
ForEach-Object { taskkill /F /PID $_.ProcessId 2>$null }
|
|
@@ -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();
|
package/scripts/setup.mjs
CHANGED
|
@@ -144,6 +144,11 @@ const SYNC_MAP = [
|
|
|
144
144
|
dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
|
|
145
145
|
label: "hub/workers/factory.mjs",
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
src: join(PLUGIN_ROOT, "scripts", "mcp-cleanup.ps1"),
|
|
149
|
+
dst: join(CLAUDE_DIR, "scripts", "mcp-cleanup.ps1"),
|
|
150
|
+
label: "mcp-cleanup.ps1",
|
|
151
|
+
},
|
|
147
152
|
{
|
|
148
153
|
src: join(PLUGIN_ROOT, "hud", "hud-qos-status.mjs"),
|
|
149
154
|
dst: join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
|
|
@@ -580,6 +585,44 @@ function applyHooks(s) {
|
|
|
580
585
|
changed = true;
|
|
581
586
|
}
|
|
582
587
|
|
|
588
|
+
// ── Stop 훅: MCP 고아 프로세스 정리 (Windows 전용) ──
|
|
589
|
+
if (process.platform === "win32") {
|
|
590
|
+
if (!Array.isArray(s.hooks.Stop)) s.hooks.Stop = [];
|
|
591
|
+
|
|
592
|
+
const cleanupScriptPath = join(CLAUDE_DIR, "scripts", "mcp-cleanup.ps1").replace(/\\/g, "/");
|
|
593
|
+
const hasCleanupHook = s.hooks.Stop.some((entry) =>
|
|
594
|
+
Array.isArray(entry.hooks) &&
|
|
595
|
+
entry.hooks.some((h) => typeof h.command === "string" && h.command.includes("mcp-cleanup")),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
if (!hasCleanupHook && existsSync(cleanupScriptPath.replace(/\//g, "\\"))) {
|
|
599
|
+
// 기존 Stop 엔트리가 있으면 거기에 추가, 없으면 새 엔트리 생성
|
|
600
|
+
const existingEntry = s.hooks.Stop.find((entry) => entry.matcher === "*" && Array.isArray(entry.hooks));
|
|
601
|
+
const cleanupHook = {
|
|
602
|
+
type: "command",
|
|
603
|
+
command: `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${cleanupScriptPath}"`,
|
|
604
|
+
timeout: 8,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if (existingEntry) {
|
|
608
|
+
existingEntry.hooks.push(cleanupHook);
|
|
609
|
+
} else {
|
|
610
|
+
s.hooks.Stop.push({ matcher: "*", hooks: [cleanupHook] });
|
|
611
|
+
}
|
|
612
|
+
changed = true;
|
|
613
|
+
} else if (hasCleanupHook) {
|
|
614
|
+
for (const entry of s.hooks.Stop) {
|
|
615
|
+
if (!Array.isArray(entry.hooks)) continue;
|
|
616
|
+
for (const h of entry.hooks) {
|
|
617
|
+
if (typeof h.command === "string" && h.command.includes("mcp-cleanup") && !h.command.includes(cleanupScriptPath)) {
|
|
618
|
+
h.command = `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${cleanupScriptPath}"`;
|
|
619
|
+
changed = true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
583
626
|
// ── PreToolUse 훅: headless-guard (auto-route) ──
|
|
584
627
|
if (!Array.isArray(s.hooks.PreToolUse)) s.hooks.PreToolUse = [];
|
|
585
628
|
|
|
@@ -337,7 +337,49 @@ options:
|
|
|
337
337
|
|
|
338
338
|
저장 후 결과 보고.
|
|
339
339
|
|
|
340
|
-
**2-8.
|
|
340
|
+
**2-8. MCP 고아 프로세스 정리 훅 배포 (Windows 원격 호스트)**
|
|
341
|
+
|
|
342
|
+
원격 호스트가 Windows인 경우, MCP 고아 프로세스 정리 훅을 자동 배포한다.
|
|
343
|
+
Claude Code 세션 종료 시 MCP 서버 프로세스가 정리되지 않는 Windows 고유 버그 대응.
|
|
344
|
+
(GitHub Issues #1935, #15211, #28126)
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
# mcp-cleanup.ps1 배포
|
|
348
|
+
scp "$(npm root -g)/triflux/scripts/mcp-cleanup.ps1" {host}:~/.claude/scripts/mcp-cleanup.ps1
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
배포 후 원격 호스트의 `~/.claude/settings.json`에 Stop 훅을 등록한다:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
ssh {host} "node -e \"
|
|
355
|
+
const fs = require('fs');
|
|
356
|
+
const p = require('path').join(require('os').homedir(), '.claude', 'settings.json');
|
|
357
|
+
const s = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
358
|
+
if (!s.hooks) s.hooks = {};
|
|
359
|
+
if (!Array.isArray(s.hooks.Stop)) s.hooks.Stop = [];
|
|
360
|
+
const has = s.hooks.Stop.some(e => e.hooks?.some(h => h.command?.includes('mcp-cleanup')));
|
|
361
|
+
if (!has) {
|
|
362
|
+
const script = require('path').join(require('os').homedir(), '.claude/scripts/mcp-cleanup.ps1').replace(/\\\\\\\\/g, '/');
|
|
363
|
+
const entry = s.hooks.Stop.find(e => e.matcher === '*' && Array.isArray(e.hooks));
|
|
364
|
+
const hook = { type: 'command', command: 'powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\\"' + script + '\\\"', timeout: 8 };
|
|
365
|
+
if (entry) entry.hooks.push(hook); else s.hooks.Stop.push({ matcher: '*', hooks: [hook] });
|
|
366
|
+
fs.writeFileSync(p, JSON.stringify(s, null, 2) + '\\n');
|
|
367
|
+
console.log('mcp-cleanup hook registered');
|
|
368
|
+
} else { console.log('mcp-cleanup hook already exists'); }
|
|
369
|
+
\""
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
macOS/Linux 원격 호스트에서는 이 단계를 건너뛴다 (PGID 기반 kill이 정상 동작).
|
|
373
|
+
|
|
374
|
+
프로브 결과에서 OS를 확인하여 자동 판단:
|
|
375
|
+
- Windows → 배포 실행
|
|
376
|
+
- macOS/Linux → 건너뛰기 (표시만)
|
|
377
|
+
|
|
378
|
+
```
|
|
379
|
+
"{host}는 Windows입니다. MCP 고아 프로세스 정리 훅을 배포합니다."
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**2-9. 후속 작업**
|
|
341
383
|
|
|
342
384
|
```
|
|
343
385
|
question: "호스트가 등록되었습니다. 추가 작업이 있습니까?"
|