From bff0abf325593e51ab73017ba495887c97a03ecd Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Thu, 9 Jul 2026 18:29:29 +0300 Subject: [PATCH 1/2] feat: add stdio bridge and npm publish workflow - Add stdio-bridge.cjs: a stdio MCP proxy that manages oc port-forward internally, enabling local Claude Code sessions to connect to an in-cluster che-mcp-server without manual port-forwarding - Add publish-npm.yml: GitHub Actions workflow that publishes to npm on GitHub Release with provenance - Add che-mcp-bridge bin entry to package.json - Update README with local connection instructions via npx - Set version to 0.0.1 Usage after publish: claude mcp add --transport stdio che-mcp -- npx che-mcp-server che-mcp-bridge Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Oleksii Kurinnyi --- .github/workflows/publish-npm.yml | 33 +++++ README.md | 21 +++ package.json | 6 +- stdio-bridge.cjs | 208 ++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-npm.yml create mode 100755 stdio-bridge.cjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..f5d270c --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,33 @@ +name: Publish to npm + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - run: npm ci + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + + - name: Publish + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index d0e60f6..e051177 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,27 @@ transport = "http" url = "http://che-mcp-server:8080/mcp" ``` +### Local (connecting to a remote cluster) + +Connect to the MCP server from your local machine using the stdio bridge. It manages `oc port-forward` internally — auto-starts, monitors, and restarts on failure. Requires `oc login` to the target cluster. + +```bash +# Via npx (no install needed) +claude mcp add --transport stdio che-mcp -- npx che-mcp-server che-mcp-bridge + +# Or install globally +npm install -g che-mcp-server +claude mcp add --transport stdio che-mcp -- che-mcp-bridge +``` + +Configuration via environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `MCP_NAMESPACE` | Kubernetes namespace | Current `oc project` | +| `MCP_SERVICE` | Service name | `che-mcp-server` | +| `MCP_PORT` | Service port | `8080` | + ### Local (from git repo) ```bash diff --git a/package.json b/package.json index 3fd3c3d..73cec66 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "che-mcp-server", - "version": "0.1.0", + "version": "0.0.1", "description": "MCP server for Eclipse Che workspace management and coding agent orchestration", "type": "module", "main": "dist/index.js", "bin": { - "che-mcp-server": "dist/index.js" + "che-mcp-server": "dist/index.js", + "che-mcp-bridge": "stdio-bridge.cjs" }, "scripts": { "build": "tsc && cp src/tools/registry.json dist/tools/registry.json", @@ -19,6 +20,7 @@ }, "files": [ "dist", + "stdio-bridge.cjs", "README.md" ], "engines": { diff --git a/stdio-bridge.cjs b/stdio-bridge.cjs new file mode 100755 index 0000000..a19f5a0 --- /dev/null +++ b/stdio-bridge.cjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +// Stdio MCP bridge for che-mcp-server. +// Launches oc port-forward, translates stdio JSON-RPC to HTTP, auto-restarts on failure. +// +// Usage: +// claude mcp add --transport stdio che-mcp-server -- node /path/to/stdio-bridge.js +// +// Environment: +// MCP_NAMESPACE - Kubernetes namespace (default: from oc project) +// MCP_SERVICE - Service name (default: che-mcp-server) +// MCP_PORT - Service port (default: 8080) + +const { spawn, execFileSync } = require('child_process'); +const http = require('http'); +const net = require('net'); +const readline = require('readline'); + +const NAMESPACE = process.env.MCP_NAMESPACE || detectNamespace(); +const SERVICE = process.env.MCP_SERVICE || 'che-mcp-server'; +const SERVICE_PORT = parseInt(process.env.MCP_PORT || '8080', 10); +const MAX_RESTARTS = 5; +const RESTART_DELAY_MS = 1000; + +let localPort = 0; +let pfProcess = null; +let sessionId = null; +let restartCount = 0; +let shuttingDown = false; + +function detectNamespace() { + try { + return execFileSync('oc', ['project', '-q'], { encoding: 'utf8', timeout: 5000 }).trim(); + } catch { + return 'akurinnoy-che'; + } +} + +function findFreePort() { + return new Promise(function (resolve, reject) { + var srv = net.createServer(); + srv.listen(0, function () { + var port = srv.address().port; + srv.close(function () { resolve(port); }); + }); + srv.on('error', reject); + }); +} + +function waitForPort(port, timeoutMs) { + var deadline = Date.now() + timeoutMs; + return new Promise(function (resolve, reject) { + function attempt() { + if (Date.now() > deadline) return reject(new Error('port-forward not ready within ' + timeoutMs + 'ms')); + var sock = net.createConnection({ host: '127.0.0.1', port: port }, function () { + sock.destroy(); + resolve(); + }); + sock.on('error', function () { + setTimeout(attempt, 200); + }); + } + attempt(); + }); +} + +async function startPortForward() { + localPort = await findFreePort(); + + pfProcess = spawn('oc', [ + 'port-forward', 'svc/' + SERVICE, + localPort + ':' + SERVICE_PORT, + '-n', NAMESPACE + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + pfProcess.stdout.on('data', function () {}); + pfProcess.stderr.on('data', function (d) { + process.stderr.write('[pf] ' + d); + }); + + pfProcess.on('exit', function (code) { + if (shuttingDown) return; + process.stderr.write('[bridge] port-forward exited (code ' + code + '), restarting...\n'); + restartCount++; + if (restartCount > MAX_RESTARTS) { + process.stderr.write('[bridge] too many restarts, giving up\n'); + process.exit(1); + } + setTimeout(function () { startPortForward().catch(function (e) { process.stderr.write('[bridge] restart failed: ' + e.message + '\n'); process.exit(1); }); }, RESTART_DELAY_MS); + }); + + await waitForPort(localPort, 10000); + restartCount = 0; + process.stderr.write('[bridge] connected via port-forward on localhost:' + localPort + ' -> ' + SERVICE + ':' + SERVICE_PORT + ' (ns: ' + NAMESPACE + ')\n'); +} + +function forwardRequest(message) { + return new Promise(function (resolve, reject) { + var body = JSON.stringify(message); + var headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + }; + if (sessionId) headers['mcp-session-id'] = sessionId; + + var req = http.request({ + hostname: '127.0.0.1', + port: localPort, + path: '/mcp', + method: 'POST', + headers: headers, + }, function (res) { + if (res.headers['mcp-session-id']) sessionId = res.headers['mcp-session-id']; + var ct = res.headers['content-type'] || ''; + + if (ct.indexOf('text/event-stream') !== -1) { + var buf = ''; + var resolved = false; + res.on('data', function (chunk) { + buf += chunk.toString(); + var parts = buf.split('\n\n'); + buf = parts.pop(); + for (var i = 0; i < parts.length; i++) { + var lines = parts[i].split('\n'); + for (var j = 0; j < lines.length; j++) { + if (lines[j].indexOf('data: ') === 0) { + var payload = lines[j].slice(6); + if (!resolved) { + resolved = true; + res.destroy(); + resolve(payload); + } + return; + } + } + } + }); + res.on('end', function () { + if (!resolved) resolve(null); + }); + } else { + var chunks = []; + res.on('data', function (c) { chunks.push(c); }); + res.on('end', function () { + resolve(Buffer.concat(chunks).toString() || null); + }); + } + }); + + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +async function main() { + process.stderr.write('[bridge] starting stdio bridge to ' + SERVICE + ' in namespace ' + NAMESPACE + '\n'); + await startPortForward(); + + var rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + var queue = []; + var processing = false; + + function enqueue(line) { + queue.push(line); + if (!processing) drain(); + } + + function drain() { + if (queue.length === 0) { processing = false; return; } + processing = true; + var line = queue.shift(); + processLine(line).then(drain).catch(function (e) { + process.stderr.write('[bridge] error: ' + e.message + '\n'); + drain(); + }); + } + + async function processLine(line) { + if (!line.trim()) return; + var message = JSON.parse(line); + var response = await forwardRequest(message); + if (response && response.trim()) { + process.stdout.write(response + '\n'); + } + } + + rl.on('line', enqueue); + rl.on('close', function () { + // stdin closed — drain remaining queue then exit + if (!processing && queue.length === 0) cleanup(); + // otherwise drain() will finish and we stay alive for port-forward restart + }); +} + +function cleanup() { + shuttingDown = true; + if (pfProcess) pfProcess.kill(); + process.exit(0); +} + +process.on('SIGTERM', cleanup); +process.on('SIGINT', cleanup); + +main().catch(function (e) { + process.stderr.write('[bridge] fatal: ' + e.message + '\n'); + process.exit(1); +}); From 92b257d8bb496a158fb9beca28b1b19ef10f6730 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Thu, 9 Jul 2026 19:00:37 +0300 Subject: [PATCH 2/2] fix: address CodeRabbit review findings - Remove hardcoded personal namespace fallback; exit with clear error message when oc context is unavailable - Add 30s timeout on HTTP forwarding requests - Fix SSE parser to collect all events and resolve with the last response instead of the first (handles notifications before final) - Fix process hang after stdin close with pending queue items - Add JSON.parse error handling for malformed stdin input - Fix npx command in README: use -p flag to specify package - Add persist-credentials: false to checkout step (hardening) - Remove redundant build step (prepublishOnly handles it) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Oleksii Kurinnyi --- .github/workflows/publish-npm.yml | 5 ++-- README.md | 2 +- stdio-bridge.cjs | 43 +++++++++++++++++++------------ 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index f5d270c..d88791b 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -12,6 +12,8 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: @@ -21,9 +23,6 @@ jobs: - run: npm ci - - name: Build - run: npm run build - - name: Run tests run: npm test diff --git a/README.md b/README.md index e051177..29bd20a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Connect to the MCP server from your local machine using the stdio bridge. It man ```bash # Via npx (no install needed) -claude mcp add --transport stdio che-mcp -- npx che-mcp-server che-mcp-bridge +claude mcp add --transport stdio che-mcp -- npx -p che-mcp-server che-mcp-bridge # Or install globally npm install -g che-mcp-server diff --git a/stdio-bridge.cjs b/stdio-bridge.cjs index a19f5a0..8906171 100755 --- a/stdio-bridge.cjs +++ b/stdio-bridge.cjs @@ -4,7 +4,7 @@ // Launches oc port-forward, translates stdio JSON-RPC to HTTP, auto-restarts on failure. // // Usage: -// claude mcp add --transport stdio che-mcp-server -- node /path/to/stdio-bridge.js +// claude mcp add --transport stdio che-mcp -- che-mcp-bridge // // Environment: // MCP_NAMESPACE - Kubernetes namespace (default: from oc project) @@ -21,18 +21,22 @@ const SERVICE = process.env.MCP_SERVICE || 'che-mcp-server'; const SERVICE_PORT = parseInt(process.env.MCP_PORT || '8080', 10); const MAX_RESTARTS = 5; const RESTART_DELAY_MS = 1000; +const REQUEST_TIMEOUT_MS = 30000; let localPort = 0; let pfProcess = null; let sessionId = null; let restartCount = 0; let shuttingDown = false; +let stdinClosed = false; function detectNamespace() { try { return execFileSync('oc', ['project', '-q'], { encoding: 'utf8', timeout: 5000 }).trim(); - } catch { - return 'akurinnoy-che'; + } catch (e) { + process.stderr.write('[bridge] failed to detect namespace: ' + e.message + '\n'); + process.stderr.write('[bridge] set MCP_NAMESPACE environment variable or run oc login first\n'); + process.exit(1); } } @@ -109,13 +113,14 @@ function forwardRequest(message) { path: '/mcp', method: 'POST', headers: headers, + timeout: REQUEST_TIMEOUT_MS, }, function (res) { if (res.headers['mcp-session-id']) sessionId = res.headers['mcp-session-id']; var ct = res.headers['content-type'] || ''; if (ct.indexOf('text/event-stream') !== -1) { var buf = ''; - var resolved = false; + var lastPayload = null; res.on('data', function (chunk) { buf += chunk.toString(); var parts = buf.split('\n\n'); @@ -124,19 +129,13 @@ function forwardRequest(message) { var lines = parts[i].split('\n'); for (var j = 0; j < lines.length; j++) { if (lines[j].indexOf('data: ') === 0) { - var payload = lines[j].slice(6); - if (!resolved) { - resolved = true; - res.destroy(); - resolve(payload); - } - return; + lastPayload = lines[j].slice(6); } } } }); res.on('end', function () { - if (!resolved) resolve(null); + resolve(lastPayload); }); } else { var chunks = []; @@ -147,6 +146,9 @@ function forwardRequest(message) { } }); + req.on('timeout', function () { + req.destroy(new Error('request timed out after ' + REQUEST_TIMEOUT_MS + 'ms')); + }); req.on('error', reject); req.write(body); req.end(); @@ -167,7 +169,11 @@ async function main() { } function drain() { - if (queue.length === 0) { processing = false; return; } + if (queue.length === 0) { + processing = false; + if (stdinClosed) cleanup(); + return; + } processing = true; var line = queue.shift(); processLine(line).then(drain).catch(function (e) { @@ -178,7 +184,13 @@ async function main() { async function processLine(line) { if (!line.trim()) return; - var message = JSON.parse(line); + var message; + try { + message = JSON.parse(line); + } catch (e) { + process.stderr.write('[bridge] invalid JSON: ' + e.message + '\n'); + return; + } var response = await forwardRequest(message); if (response && response.trim()) { process.stdout.write(response + '\n'); @@ -187,9 +199,8 @@ async function main() { rl.on('line', enqueue); rl.on('close', function () { - // stdin closed — drain remaining queue then exit + stdinClosed = true; if (!processing && queue.length === 0) cleanup(); - // otherwise drain() will finish and we stay alive for port-forward restart }); }