Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Release process migrated from local `scripts/publish.sh` to GitHub Actions; `npm run release` now triggers the CI workflow instead of running locally
- Auto-reconnect crashed bridge processes in the background when enumerating sessions (`mcpc` or `mcpc grep`), with a 10-second cooldown between reconnection attempts

## [0.2.0] - 2026-03-24

Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@ mcpc/

**Session States:**
- 🟢 **live** - Bridge process running and server responding (lastSeenAt within 2 minutes)
- 🟡 **connecting** - Initial bridge connection in progress (first `connect`)
- 🟡 **reconnecting** - Bridge crashed and is being automatically reconnected
- 🟡 **disconnected** - Bridge process running but server unreachable (lastSeenAt stale >2min); auto-recovers when server responds
- 🟡 **crashed** - Bridge process crashed or killed; auto-restarts on next use
- 🟡 **crashed** - Bridge process crashed or killed; auto-reconnects in the background
- 🔴 **unauthorized** - Server rejected authentication (401/403) or token refresh failed; requires `login` then `restart`
- 🔴 **expired** - Server rejected session ID (404); requires `restart`

Expand Down
8 changes: 6 additions & 2 deletions src/cli/commands/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Tool, Resource, Prompt, CommandOptions, SessionData } from '../../
import { ClientError } from '../../lib/errors.js';
import { isProcessAlive } from '../../lib/utils.js';
import { consolidateSessions } from '../../lib/sessions.js';
import { reconnectCrashedSessions } from '../../lib/bridge-manager.js';
import { withSessionClient } from '../../lib/session-client.js';
import { withMcpClient } from '../helpers.js';
import {
Expand Down Expand Up @@ -447,8 +448,11 @@ export async function grepAllSessions(pattern: string, options: GrepOptions): Pr
const matcher = buildMatcher(pattern, options);

// Load all sessions
const { sessions } = await consolidateSessions(false);
const allSessionEntries = Object.values(sessions);
const consolidateResult = await consolidateSessions(false);
const allSessionEntries = Object.values(consolidateResult.sessions);

// Auto-reconnect crashed bridges in the background (fire-and-forget)
reconnectCrashedSessions(consolidateResult.sessionsToRestart);

if (allSessionEntries.length === 0) {
if (options.outputMode === 'json') {
Expand Down
33 changes: 29 additions & 4 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ import {
consolidateSessions,
getSession,
} from '../../lib/sessions.js';
import { startBridge, StartBridgeOptions, stopBridge } from '../../lib/bridge-manager.js';
import {
startBridge,
StartBridgeOptions,
stopBridge,
reconnectCrashedSessions,
} from '../../lib/bridge-manager.js';
import {
storeKeychainSessionHeaders,
storeKeychainProxyBearerToken,
Expand Down Expand Up @@ -244,6 +249,8 @@ export async function connectSession(
await saveSession(name, {
server: sessionTransportConfig,
createdAt: new Date().toISOString(),
status: 'connecting',
lastConnectionAttemptAt: new Date().toISOString(),
...sessionUpdate,
});
logger.debug(`Initial session record created for: ${name}`);
Expand Down Expand Up @@ -274,8 +281,8 @@ export async function connectSession(

const { pid } = await startBridge(bridgeOptions);

// Update session with bridge info (socket path is computed from session name)
await updateSession(name, { pid });
// Update session with bridge info and mark as active (clears 'connecting' status)
await updateSession(name, { pid, status: 'active' });
logger.debug(`Session ${name} updated with bridge PID: ${pid}`);
} catch (error) {
// Clean up on bridge start failure
Expand Down Expand Up @@ -318,7 +325,14 @@ export async function connectSession(

// DISCONNECTED_THRESHOLD_MS imported from ../../lib/types.js

export type DisplayStatus = 'live' | 'disconnected' | 'crashed' | 'unauthorized' | 'expired';
export type DisplayStatus =
| 'live'
| 'connecting'
| 'reconnecting'
| 'disconnected'
| 'crashed'
| 'unauthorized'
| 'expired';

/**
* Determine bridge status for a session
Expand All @@ -334,6 +348,10 @@ export function getBridgeStatus(session: {
if (session.status === 'expired') {
return 'expired';
}
// Transient states: connecting (initial) or reconnecting (after crash)
if (session.status === 'connecting' || session.status === 'reconnecting') {
return session.status;
}
if (!session.pid || !isProcessAlive(session.pid)) {
return 'crashed';
}
Expand All @@ -354,6 +372,10 @@ export function formatBridgeStatus(status: DisplayStatus): { dot: string; text:
switch (status) {
case 'live':
return { dot: chalk.green('●'), text: chalk.green('live') };
case 'connecting':
return { dot: chalk.yellow('●'), text: chalk.yellow('connecting') };
case 'reconnecting':
return { dot: chalk.yellow('●'), text: chalk.yellow('reconnecting') };
case 'disconnected':
return { dot: chalk.yellow('●'), text: chalk.yellow('disconnected') };
case 'crashed':
Expand Down Expand Up @@ -399,6 +421,9 @@ export async function listSessionsAndAuthProfiles(options: {
const consolidateResult = await consolidateSessions(false);
const sessions = Object.values(consolidateResult.sessions);

// Auto-restart crashed bridges in the background (fire-and-forget)
reconnectCrashedSessions(consolidateResult.sessionsToRestart);

// Load auth profiles from disk
const profiles = await listAuthProfiles();

Expand Down
33 changes: 33 additions & 0 deletions src/lib/bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,11 +568,13 @@ export async function ensureBridgeReady(sessionName: string): Promise<string> {
}

// Bridge not healthy - restart it
await updateSession(sessionName, { status: 'reconnecting' });
await restartBridge(sessionName);

// Try getServerDetails on restarted bridge (blocks until MCP connected)
const result = await checkBridgeHealth(socketPath);
if (result.healthy) {
await updateSession(sessionName, { status: 'active' });
logger.debug(`Bridge for ${sessionName} passed health check`);
return socketPath;
}
Expand All @@ -588,3 +590,34 @@ export async function ensureBridgeReady(sessionName: string): Promise<string> {
`For details, check logs at ${logPath}`
);
}

/**
* Reconnect crashed bridge sessions in the background.
* Fire-and-forget: does not wait for reconnections to complete.
* Called after consolidateSessions() identifies crashed sessions eligible for reconnection.
*
* Unlike explicit "restart" (which creates a fresh MCP session), this preserves
* the existing MCP session ID for resumption when possible.
*
* @param sessionNames - Names of sessions to reconnect (from consolidateSessions result)
*/
export function reconnectCrashedSessions(sessionNames: string[]): void {
for (const name of sessionNames) {
logger.debug(`Reconnecting crashed bridge for session: ${name}`);
restartBridge(name)
.then(async () => {
// Bridge reconnected — clear 'reconnecting' status
await updateSession(name, { status: 'active' });
logger.debug(`Reconnection succeeded for ${name}, status set to active`);
})
.catch(async (err) => {
logger.debug(`Reconnection failed for ${name}: ${(err as Error).message}`);
// Revert to 'crashed' so the next consolidation can retry
try {
await updateSession(name, { status: 'crashed' });
} catch {
// Ignore - session may have been deleted
}
});
}
}
3 changes: 3 additions & 0 deletions src/lib/session-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
import type { ListResourceTemplatesResult } from '@modelcontextprotocol/sdk/types.js';
import { BridgeClient } from './bridge-client.js';
import { ensureBridgeReady, restartBridge } from './bridge-manager.js';
import { updateSession } from './sessions.js';
import { NetworkError } from './errors.js';
import { getSocketPath, getLogsDir, generateRequestId } from './utils.js';
import { createLogger } from './logger.js';
Expand Down Expand Up @@ -102,13 +103,15 @@ export class SessionClient extends EventEmitter implements IMcpClient {
await this.bridgeClient.close();

// Restart bridge
await updateSession(this.sessionName, { status: 'reconnecting' });
await restartBridge(this.sessionName);

// Reconnect using computed socket path
const socketPath = getSocketPath(this.sessionName);
this.bridgeClient = new BridgeClient(socketPath);
this.setupNotificationForwarding();
await this.bridgeClient.connect();
await updateSession(this.sessionName, { status: 'active' });

logger.debug(`Reconnected to bridge for ${this.sessionName}, retrying ${operationName}`);

Expand Down
63 changes: 59 additions & 4 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ export interface ConsolidateSessionsResult {
expiredSessions: number;
/** Updated sessions map (for use by caller) */
sessions: Record<string, SessionData>;
/** Session names eligible for automatic background restart */
sessionsToRestart: string[];
}

/**
Expand All @@ -258,6 +260,7 @@ export async function consolidateSessions(
crashedBridges: 0,
expiredSessions: 0,
sessions: {},
sessionsToRestart: [],
};

const filePath = getSessionsFilePath();
Expand Down Expand Up @@ -320,11 +323,12 @@ export async function consolidateSessions(
logger.debug(`Clearing crashed bridge PID for session: ${name} (PID: ${session.pid})`);
delete session.pid;
hasChanges = true;
// Don't overwrite 'expired' or 'unauthorized' status - those are server-side states, not bridge state
// Don't overwrite terminal/transient statuses
if (
session.status !== 'crashed' &&
session.status !== 'expired' &&
session.status !== 'unauthorized'
session.status !== 'unauthorized' &&
session.status !== 'reconnecting'
) {
session.status = 'crashed';
result.crashedBridges++;
Expand All @@ -333,15 +337,66 @@ export async function consolidateSessions(
!session.pid &&
session.status !== 'crashed' &&
session.status !== 'expired' &&
session.status !== 'unauthorized'
session.status !== 'unauthorized' &&
session.status !== 'connecting' &&
session.status !== 'reconnecting'
) {
// No pid but not marked crashed yet (and not expired/unauthorized)
// No pid but not marked crashed yet (and not in a transient/terminal state)
session.status = 'crashed';
result.crashedBridges++;
hasChanges = true;
}
}

// Cooldown: socket connect timeout (5s) + 5s buffer = 10s
const AUTO_RESTART_COOLDOWN_MS = 10_000;
const now = Date.now();

// Expire stale 'connecting'/'reconnecting' states — if the connection attempt
// started more than the cooldown period ago and no PID exists, assume it failed
for (const [name, session] of Object.entries(storage.sessions)) {
if (
(session?.status === 'connecting' || session?.status === 'reconnecting') &&
!session.pid
) {
const attemptAt = session.lastConnectionAttemptAt
? new Date(session.lastConnectionAttemptAt).getTime()
: 0;
if (attemptAt > 0 && now - attemptAt > AUTO_RESTART_COOLDOWN_MS) {
logger.debug(`Stale ${session.status} state for ${name}, marking as crashed`);
session.status = 'crashed';
result.crashedBridges++;
hasChanges = true;
}
}
}

// Identify crashed sessions eligible for automatic restart
for (const [name, session] of Object.entries(storage.sessions)) {
if (session?.status === 'crashed' && !session.pid) {
// Skip if a connection was already attempted within the cooldown window
const lastAttempt = session.lastConnectionAttemptAt
? new Date(session.lastConnectionAttemptAt).getTime()
: 0;
if (now - lastAttempt <= AUTO_RESTART_COOLDOWN_MS) {
continue;
}
// Skip if bridge was recently alive — it may have just crashed and needs
// time for cleanup before we restart (also avoids conflicts with parallel
// sessions sharing the same home directory)
const lastSeen = session.lastSeenAt ? new Date(session.lastSeenAt).getTime() : 0;
if (lastSeen > 0 && now - lastSeen <= AUTO_RESTART_COOLDOWN_MS) {
logger.debug(`Skipping auto-restart for ${name}: bridge was recently alive`);
continue;
}
session.lastConnectionAttemptAt = new Date(now).toISOString();
session.status = 'reconnecting';
hasChanges = true;
result.sessionsToRestart.push(name);
logger.debug(`Marking session ${name} for auto-restart`);
}
}

// Save updated sessions
if (hasChanges) {
await saveSessionsInternal(storage);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,19 @@ export interface ProxyConfig {
/**
* Session status
* - active: Session is healthy and can be used
* - connecting: Bridge is starting up for the first time (initial connect in progress)
* - reconnecting: Bridge crashed and is being automatically restarted
* - unauthorized: Server rejected authentication (401/403) or token refresh failed. Recovery: login then restart.
* - expired: Server indicated session is no longer valid (e.g., 404 response). Recovery: restart.
* - crashed: Bridge process crashed, session might or might not be usable. Bridge will be restarted on next command.
*/
export type SessionStatus = 'active' | 'unauthorized' | 'expired' | 'crashed';
export type SessionStatus =
| 'active'
| 'connecting'
| 'reconnecting'
| 'unauthorized'
| 'expired'
| 'crashed';

/**
* Notification timestamps for list change events
Expand Down Expand Up @@ -150,6 +158,7 @@ export interface SessionData {
// Timestamps (ISO 8601 strings)
createdAt: string; // When the session was created
lastSeenAt?: string; // Last successful server response (ping, command, etc.)
lastConnectionAttemptAt?: string; // Last connection/reconnection attempt (ISO 8601, for cooldown)
}

/**
Expand Down
13 changes: 8 additions & 5 deletions test/e2e/suites/sessions/expired.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ cat > "$MCPC_HOME_DIR/sessions.json" << 'EOF'
}
EOF

# Test: session with crashed bridge PID shows as crashed (before using it)
test_case "session with crashed bridge shows as crashed"
# Test: session with crashed bridge PID shows as crashed or reconnecting (before using it)
test_case "session with crashed bridge shows as crashed or reconnecting"
run_mcpc --json
assert_success
# The fake session should show as crashed since PID 99999 doesn't exist
# Note: JSON uses "status" field, not "bridgeStatus"
# The fake session should show as crashed (PID 99999 doesn't exist) or reconnecting
# (auto-reconnection started in background). Both are valid states.
session_status=$(echo "$STDOUT" | jq -r '.sessions[] | select(.name == "@fake-session") | .status')
assert_eq "$session_status" "crashed" "fake session should show as crashed"
if [[ "$session_status" != "crashed" && "$session_status" != "reconnecting" ]]; then
test_fail "fake session should show as crashed or reconnecting, got: $session_status"
exit 1
fi
test_pass

# Test: fake session record - using a session that doesn't exist
Expand Down
10 changes: 7 additions & 3 deletions test/e2e/suites/sessions/failover.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@ if kill -0 "$bridge_pid" 2>/dev/null; then
fi
test_pass

# Test: session shows as crashed
test_case "session shows as crashed after bridge kill"
# Test: session shows as crashed or reconnecting
test_case "session shows as crashed or reconnecting after bridge kill"
run_mcpc --json
session_status=$(json_get ".sessions[] | select(.name == \"$SESSION\") | .status")
assert_eq "$session_status" "crashed" "session should show as crashed"
# Session may show as "crashed" (bridge dead) or "reconnecting" (auto-reconnect in progress)
if [[ "$session_status" != "crashed" && "$session_status" != "reconnecting" ]]; then
test_fail "session should show as crashed or reconnecting, got: $session_status"
exit 1
fi
test_pass

# Test: using crashed session attempts restart but server rejects old session ID
Expand Down
Loading