Skip to content
Open
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,59 @@ jobs:
# specific storage backend.
run: ./test/e2e/run.sh --no-build --parallel 6

e2e-windows-node:
name: E2E tests (Windows, Node.js)
runs-on: windows-latest
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v6

- name: Set up Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: E2E tests
run: ./test/e2e/run.sh --no-build --parallel 1

e2e-windows-bun:
name: E2E tests (Windows, Bun)
runs-on: windows-latest
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v6

- name: Set up Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Install Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: E2E tests
run: ./test/e2e/run.sh --no-build --runtime bun --parallel 1

e2e-bun:
name: E2E tests (Bun)
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Windows E2E tests in GitHub Actions CI pipeline — the e2e test framework (`run.sh`, `framework.sh`) is now cross-platform, using Git Bash on Windows runners
- New `mcpc grep <pattern>` command to search tools, resources, prompts, and server instructions across all active sessions, with support for regex (`-E`), type filters (`--tools`, `--resources`, `--prompts`, `--instructions`), single-session search (`mcpc @session grep`), and capability-aware querying (skips unsupported list operations)
- Recovery hints for crashed and expired sessions in `mcpc` session list output

### Fixed

- Fixed Windows E2E test failures: MSYS path conversion for config file references, reliable process detection using `tasklist`, graceful bridge shutdown via IPC instead of SIGTERM, and proper JSON escaping for named pipe paths
- Fixed `mcpc help <command>` showing truncated usage line (e.g. `Usage: resources-read <uri>`) — now correctly shows `Usage: mcpc <@session> resources-read <uri>`
- Fixed auth loss when reconnecting an unauthorized session via `mcpc connect` — the `unauthorized` status was not cleared, causing all subsequent operations to fail with "Authentication required by server" even after successful reconnection
- HTTP proxy support (`HTTP_PROXY`/`HTTPS_PROXY` env vars) now works for MCP server connections, OAuth token refresh, and x402 payment signing — previously the MCP SDK transport and OAuth calls bypassed the global proxy dispatcher
Expand Down
63 changes: 56 additions & 7 deletions src/lib/bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,19 +265,36 @@ export async function stopBridge(sessionName: string): Promise<void> {
// Kill the bridge process if it's still running
if (session.pid && isProcessAlive(session.pid)) {
try {
logger.debug(`Killing bridge process: ${session.pid}`);
process.kill(session.pid, 'SIGTERM');
// On Windows, SIGTERM is not supported and process.kill() calls
// TerminateProcess which kills immediately without cleanup.
// Instead, send a shutdown IPC message so the bridge can close the
// MCP session gracefully (including sending HTTP DELETE).
const socketPath = getSocketPath(sessionName);
if (process.platform === 'win32') {
const shutdownOk = await sendBridgeShutdown(socketPath);
if (shutdownOk) {
// Wait for graceful shutdown (bridge sends HTTP DELETE, closes connections)
await waitForProcessExit(session.pid, 3000);
}
} else {
logger.debug(`Sending SIGTERM to bridge process: ${session.pid}`);
process.kill(session.pid, 'SIGTERM');

// Wait for graceful shutdown (gives time for HTTP DELETE to be sent)
await new Promise((resolve) => setTimeout(resolve, 1000));
// Wait for graceful shutdown (gives time for HTTP DELETE to be sent)
await new Promise((resolve) => setTimeout(resolve, 1000));
}

// Force kill if still alive
if (isProcessAlive(session.pid)) {
logger.debug('Bridge did not exit gracefully, sending SIGKILL');
process.kill(session.pid, 'SIGKILL');
logger.debug('Bridge did not exit gracefully, force killing');
try {
process.kill(session.pid, 'SIGKILL');
} catch {
// Ignore - process may have exited between check and kill
}
}
} catch (error) {
logger.warn('Error killing bridge process:', error);
logger.warn('Error stopping bridge process:', error);
}

logger.debug(`Bridge stopped for ${sessionName}`);
Expand All @@ -288,6 +305,38 @@ export async function stopBridge(sessionName: string): Promise<void> {
// Full cleanup happens in closeSession().
}

/**
* Send a shutdown command to the bridge via IPC socket.
* Returns true if the message was sent successfully, false otherwise.
*/
async function sendBridgeShutdown(socketPath: string): Promise<boolean> {
try {
const client = new BridgeClient(socketPath);
await client.connect();
client.send({ type: 'shutdown' });
await client.close();
logger.debug('Sent shutdown IPC message to bridge');
return true;
} catch (error) {
logger.debug('Failed to send shutdown IPC message:', error);
return false;
}
}

/**
* Wait for a process to exit, with a timeout.
*/
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<void> {
const start = Date.now();
const interval = 100;
while (Date.now() - start < timeoutMs) {
if (!isProcessAlive(pid)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
}

/**
* Restart a bridge process for a session
* Used for automatic recovery when connection to bridge fails
Expand Down
31 changes: 29 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { createHash } from 'crypto';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
import { join, resolve, isAbsolute } from 'path';
import { mkdir, access, constants } from 'fs/promises';
Expand Down Expand Up @@ -307,18 +308,44 @@ export function truncate(str: string, maxLength: number): string {
}

/**
* Check if a process with the given PID is running
* Check if a process with the given PID is running.
*
* On Unix, `process.kill(pid, 0)` reliably checks process existence.
* On Windows, `process.kill(pid, 0)` may not work correctly (especially
* under Bun), so we fall back to `tasklist` which is always reliable.
*/
export function isProcessAlive(pid: number): boolean {
if (process.platform === 'win32') {
return isProcessAliveWindows(pid);
}
try {
// Sending signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

/**
* Windows-specific process alive check using tasklist.
* This is more reliable than process.kill(pid, 0) on Windows,
* which can return false positives in some runtimes (e.g. Bun).
*/
function isProcessAliveWindows(pid: number): boolean {
try {
const output = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
});
// tasklist output contains the PID number when the process exists,
// otherwise it says "No tasks are running..."
return output.includes(String(pid));
} catch {
return false;
}
}

/**
* Generate a unique request ID
*/
Expand Down
Loading