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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- New `--auto-restart` option for the `connect` command: sessions expired by the server are restarted automatically with a fresh MCP session (previous session state is lost), instead of failing until an explicit `restart`. Recovery happens on next use, or in the background whenever sessions are probed (e.g. `mcpc` or `mcpc grep`), same as crashed-session reconnection.

### Fixed

- A server-side network failure (e.g. an expired session or unreachable server) no longer makes the CLI needlessly restart a healthy bridge, which could overwrite the session's `expired`/`unauthorized` status with `active` and hide the real state.

## [0.5.0] - 2026-07-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Run `mcpc --help` and `mcpc help <command>` for the authoritative, always-curren
- 🟡 **disconnected** - Bridge process running but server unreachable (lastSeenAt stale >~65s); auto-recovers when server responds
- 🟡 **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`
- 🔴 **expired** - Server rejected session ID (404); requires `restart` (sessions created with `connect --auto-restart` are restarted automatically with a fresh session on next use, or in the background whenever sessions are probed, e.g. by `mcpc` or `mcpc grep`)

### Transport Implementation

Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
proxyBearerToken?: string;
x402?: X402SchemePreference;
insecure?: boolean;
autoRestart?: boolean;
skipDetails?: boolean;
quiet?: boolean;
};
Expand Down Expand Up @@ -367,6 +368,8 @@
...(proxyConfig && { proxy: proxyConfig }),
...(options.x402 && { x402: options.x402 }),
...(options.insecure && { insecure: true }),
// Persist auto-restart preference (absence leaves an existing session unchanged)
...(options.autoRestart && { autoRestart: true }),
// Clear any previous error status (unauthorized, expired) when reconnecting
...(isReconnect && { status: 'active' }),
};
Expand Down Expand Up @@ -604,6 +607,7 @@
stdio?: boolean;
x402?: X402SchemePreference;
insecure?: boolean;
autoRestart?: boolean;
};

/**
Expand Down Expand Up @@ -729,7 +733,7 @@
);

let results: BulkConnectResult[] = settled.map((outcome, i) => {
const base = entries[i]!;

Check warning on line 736 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 22

Forbidden non-null assertion

Check warning on line 736 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 26

Forbidden non-null assertion

Check warning on line 736 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 24

Forbidden non-null assertion
if (outcome.status === 'fulfilled') {
return { ...base, status: liveSet.has(base.sessionName) ? 'active' : 'created' };
}
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ export async function listSessionsAndAuthProfiles(options: {
} else if (status === 'crashed') {
console.log(chalk.dim(` ↳ run: mcpc ${session.name}`));
} else if (status === 'expired') {
console.log(chalk.dim(` ↳ run: mcpc ${session.name} restart`));
// No hint for auto-restart sessions — a background restart is already underway
if (!session.autoRestart) {
console.log(chalk.dim(` ↳ run: mcpc ${session.name} restart`));
}
} else if (status === 'disconnected') {
// Bridge is alive and auto-recovers when the server responds again;
// a restart forces a fresh connection if it stays stuck.
Expand Down
14 changes: 14 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ Full docs: ${docsUrl}`
.option('--proxy <[host:]port>', 'Start proxy MCP server for session')
.option('--proxy-bearer-token <token>', 'Require authentication for access to proxy server')
.option('--stdio', 'Launch all local stdio servers from selected config files')
.option(
'--auto-restart',
'Restart expired sessions automatically with a fresh session (previous session state is lost)'
)
.option(
'--x402 [scheme]',
'Enable x402 auto-payment using the configured wallet; optional scheme: auto (default, prefer upto), upto, or exact.'
Expand Down Expand Up @@ -500,6 +504,12 @@ ${chalk.bold('Stdio servers (command-based, run locally):')}
Config entries spawn the command on connect, even if the handshake
later fails — only connect to configs you trust. Bulk connects skip
stdio by default; pass --stdio to include them.

${chalk.bold('Auto-restart (--auto-restart):')}
Expired sessions are restarted automatically with a fresh MCP session
— on next use, or in the background whenever sessions are probed
(e.g. "mcpc", "mcpc grep") — instead of requiring an explicit
"mcpc @session restart". Server-side session state is lost on restart.
${jsonHelp(
'Array of `InitializeResult` objects (one per session), extended with `toolNames` and `_mcpc` metadata',
'`[{ protocolVersion?, capabilities?, serverInfo?, instructions?, toolNames?, _mcpc: { sessionName, server?, ... }]`',
Expand Down Expand Up @@ -530,6 +540,7 @@ ${jsonHelp(
...(opts.proxy && { proxy: opts.proxy as string }),
...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }),
...(opts.stdio && { stdio: true }),
...(opts.autoRestart && { autoRestart: true }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.insecure && { insecure: true }),
});
Expand Down Expand Up @@ -561,6 +572,7 @@ ${jsonHelp(
...(opts.proxy && { proxy: opts.proxy as string }),
...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }),
...(opts.stdio && { stdio: true }),
...(opts.autoRestart && { autoRestart: true }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.insecure && { insecure: true }),
});
Expand All @@ -585,6 +597,7 @@ ${jsonHelp(
config: parsed.file,
proxy: opts.proxy,
proxyBearerToken: opts.proxyBearerToken,
...(opts.autoRestart && { autoRestart: true }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.insecure && { insecure: true }),
});
Expand All @@ -594,6 +607,7 @@ ${jsonHelp(
...(headers && { headers }),
proxy: opts.proxy,
proxyBearerToken: opts.proxyBearerToken,
...(opts.autoRestart && { autoRestart: true }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.insecure && { insecure: true }),
});
Expand Down
13 changes: 11 additions & 2 deletions src/lib/bridge-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import { connect, type Socket } from 'net';
import { EventEmitter } from 'events';
import type { IpcMessage, TaskUpdate, X402WalletCredentials } from './types.js';
import { createLogger } from './logger.js';
import { NetworkError, ClientError, ServerError, AuthError, IpcTimeoutError } from './errors.js';
import {
NetworkError,
ClientError,
ServerError,
AuthError,
IpcTimeoutError,
markBridgeReported,
} from './errors.js';
import { generateRequestId, sleep } from './utils.js';

const logger = createLogger('bridge-client');
Expand Down Expand Up @@ -244,7 +251,9 @@ export class BridgeClient extends EventEmitter {
default:
error = new Error(message.error.message);
}
pending.reject(error);
// Mark as bridge-reported so callers can tell a server-side failure
// (bridge alive) apart from a local socket failure (bridge gone)
pending.reject(markBridgeReported(error));
} else {
pending.resolve(message.result);
}
Expand Down
97 changes: 65 additions & 32 deletions src/lib/bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
isSessionExpiredError,
enrichErrorMessage,
} from './utils.js';
import { updateSession, getSession } from './sessions.js';
import { updateSession, getSession, clearSessionMcpSessionId } from './sessions.js';
import { createLogger } from './logger.js';
import {
ClientError,
Expand Down Expand Up @@ -743,13 +743,24 @@ export async function ensureBridgeReady(
throw createServerAuthError(target, { sessionName });
}

// Auto-restart recovery: recover an expired session by starting a fresh MCP
// session — drop the rejected session id so the restart below does not attempt
// resumption. There is no bridge worth probing: a session is only marked
// expired right before its bridge shuts down. Previous session state is lost.
let startFresh = false;
if (session.status === 'expired') {
throw new ClientError(
`Session ${sessionName} has expired. ` +
`The MCP server indicated the session is no longer valid.\n` +
`To restart the session, run: mcpc ${sessionName} restart\n` +
`To remove the expired session, run: mcpc ${sessionName} close`
);
if (!session.autoRestart) {
throw new ClientError(
`Session ${sessionName} has expired. ` +
`The MCP server indicated the session is no longer valid.\n` +
`To restart the session, run: mcpc ${sessionName} restart\n` +
`To remove the expired session, run: mcpc ${sessionName} close\n` +
`Tip: sessions created with "mcpc connect --auto-restart" recover from this automatically.`
);
}
logger.debug(`Session ${sessionName} expired; auto-restart enabled, starting a fresh session`);
await clearSessionMcpSessionId(sessionName);
startFresh = true;
}

// Socket path is PID-based: each bridge instance gets its own unique path
Expand All @@ -758,7 +769,7 @@ export async function ensureBridgeReady(
// Quick check: is the process alive?
const processAlive = session.pid ? isProcessAlive(session.pid) : false;

if (processAlive && socketPath) {
if (!startFresh && processAlive && socketPath) {
// Process alive, try getServerDetails (blocks until MCP connected)
const result = await checkBridgeHealth(socketPath, timeoutSecs);
if (result.healthy) {
Expand All @@ -777,41 +788,61 @@ export async function ensureBridgeReady(
throw new ClientError(enrichErrorMessage(result.error.message, serverUrl));
}
}
} else {
} else if (!startFresh) {
logger.debug(`Bridge process not alive for ${sessionName}, will try to restart it`);
}

// Bridge not healthy - restart it
// Bridge not healthy - restart it (the loop runs at most twice: with
// auto-restart, a restart whose session resumption is rejected by the server
// is retried once with a fresh session).
// Use 'connecting' if the session has never successfully connected (no lastSeenAt),
// 'reconnecting' if it was previously active.
// Set lastConnectionAttemptAt to prevent parallel CLI processes from
// also triggering a restart via consolidateSessions/reconnectCrashedSessions.
const restartStatus = session.lastSeenAt ? 'reconnecting' : 'connecting';
await updateSession(sessionName, {
status: restartStatus,
lastConnectionAttemptAt: new Date().toISOString(),
});
const { pid: newPid } = await restartBridge(sessionName);
for (;;) {
await updateSession(sessionName, {
status: restartStatus,
lastConnectionAttemptAt: new Date().toISOString(),
});
const { pid: newPid } = await restartBridge(sessionName);

const newSocketPath = getSocketPath(sessionName, newPid);
const newSocketPath = getSocketPath(sessionName, newPid);

// Try getServerDetails on restarted bridge (blocks until MCP connected)
const result = await checkBridgeHealth(newSocketPath, timeoutSecs);
if (result.healthy) {
await updateSession(sessionName, { status: 'active' });
logger.debug(`Bridge for ${sessionName} passed health check`);
return newSocketPath;
}
// Try getServerDetails on restarted bridge (blocks until MCP connected)
const result = await checkBridgeHealth(newSocketPath, timeoutSecs);
if (result.healthy) {
await updateSession(sessionName, { status: 'active' });
logger.debug(`Bridge for ${sessionName} passed health check`);
return newSocketPath;
}

// Not healthy after restart - classify the error
const errorMsg = result.error?.message || 'unknown error';
await classifyAndThrowSessionError(sessionName, session, errorMsg, result.error);
const errorMsg = result.error?.message || 'unknown error';

// Auto-restart: if resuming the old MCP session failed because the server
// rejected the session id, retry once with a fresh session (no resumption)
if (
!startFresh &&
session.autoRestart &&
isSessionExpiredError(errorMsg, { hadActiveSession: !!session.mcpSessionId })
) {
logger.debug(
`Session ${sessionName} expired during restart; auto-restart enabled, retrying with a fresh session`
);
await clearSessionMcpSessionId(sessionName);
startFresh = true;
continue;
}

// Other errors - provide enriched error with hint to view logs
const serverUrl = session.server.url;
throw new ClientError(
`${enrichErrorMessage(errorMsg, serverUrl)}\n` + `For details, run: mcpc ${sessionName} logs`
);
// Not healthy after restart - classify the error
await classifyAndThrowSessionError(sessionName, session, errorMsg, result.error);

// Other errors - provide enriched error with hint to view logs
const serverUrl = session.server.url;
throw new ClientError(
`${enrichErrorMessage(errorMsg, serverUrl)}\n` + `For details, run: mcpc ${sessionName} logs`
);
}
}

/**
Expand All @@ -820,7 +851,9 @@ export async function ensureBridgeReady(
* 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.
* the existing MCP session ID for resumption when possible. Expired sessions with
* auto-restart enabled are also included — consolidateSessions() has already
* dropped their rejected session id, so they reconnect with a fresh session.
*
* @param sessionNames - Names of sessions to reconnect (from consolidateSessions result)
*/
Expand Down
32 changes: 32 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ export class AuthError extends McpError {
*/
export class IpcTimeoutError extends NetworkError {}

/**
* Marker for errors that the bridge reported over IPC, as opposed to errors
* produced locally in the CLI (e.g. a failed bridge socket).
*
* The distinction matters for NetworkError: a bridge-reported one means the MCP
* *server* was unreachable or rejected the request while the bridge process is
* alive and maintains its own session status (e.g. marking the session expired
* before shutting down). A local one means the bridge itself is gone. Only the
* latter warrants a bridge restart — restarting on the former is pointless and
* races the bridge's own status bookkeeping in sessions.json.
*/
const BRIDGE_REPORTED = Symbol('mcpc.bridgeReported');

/**
* Mark an error as reported by the bridge over IPC. Returns the same error.
*/
export function markBridgeReported<T extends Error>(error: T): T {
(error as unknown as Record<symbol, unknown>)[BRIDGE_REPORTED] = true;
return error;
}

/**
* Check whether an error was reported by the bridge over IPC (see markBridgeReported).
*/
export function isBridgeReported(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as Record<symbol, unknown>)[BRIDGE_REPORTED] === true
);
}

/**
* Check if an error message indicates an authentication error from the server.
* Uses word boundaries for numeric codes (401, 403) to avoid false positives
Expand Down
23 changes: 21 additions & 2 deletions src/lib/session-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import type { ListResourceTemplatesResult } from '@modelcontextprotocol/sdk/type
import { BridgeClient } from './bridge-client.js';
import { ensureBridgeReady, restartBridge } from './bridge-manager.js';
import { updateSession } from './sessions.js';
import { NetworkError, IpcTimeoutError } from './errors.js';
import { NetworkError, IpcTimeoutError, isBridgeReported } from './errors.js';
import { getSocketPath, generateRequestId } from './utils.js';
import { createLogger } from './logger.js';

Expand Down Expand Up @@ -68,9 +68,12 @@ export class SessionClient implements IMcpClient {
* 2. Reconnect
* 3. Retry the operation once — but only for idempotent operations
*
* Two cases are deliberately NOT retried:
* Three cases are deliberately NOT retried:
* - IPC timeouts: the bridge is likely healthy and still processing the request;
* restarting would kill the in-flight request and retrying could execute it twice.
* - Bridge-reported network errors: the MCP *server* failed, not the bridge — the
* bridge is alive and manages its own session status (e.g. marking the session
* expired), so restarting it would be pointless and would race that bookkeeping.
* - Non-idempotent operations (tool calls) after a socket failure: the bridge died
* with the request in flight, so the server may already have executed it. We
* restart the bridge to recover the session, but surface the uncertainty to the
Expand Down Expand Up @@ -104,6 +107,15 @@ export class SessionClient implements IMcpClient {
throw error;
}

// A NetworkError reported by the bridge itself means the MCP *server* was
// unreachable or rejected the request — the bridge process is alive and
// maintains its own session status (e.g. marking the session expired).
// Restarting it here would be pointless and would race that bookkeeping.
if (isBridgeReported(error)) {
error.message = `${error.message}. For details, run: mcpc ${this.sessionName} logs`;
throw error;
}

logger.debug(`Socket error during ${operationName}, will restart bridge...`);

// Close the failed client
Expand Down Expand Up @@ -384,6 +396,13 @@ export class SessionClient implements IMcpClient {
throw error;
}

// Bridge-reported network error: the server failed, not the bridge —
// don't restart (see withRetry for the full rationale)
if (isBridgeReported(error)) {
error.message = `${error.message}. For details, run: mcpc ${this.sessionName} logs`;
throw error;
}

logger.debug(`Socket error during callToolWithTask, will restart bridge...`);
await this.bridgeClient.close();
const { pid: newPid } = await restartBridge(this.sessionName);
Expand Down
Loading
Loading