From 83669eeedb6e04a35531fdb8294a74167782f77f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:15:31 +0000 Subject: [PATCH 1/7] Add --no-auto-restart option to connect command Allow users to disable automatic bridge restart on crash via `mcpc connect --no-auto-restart @session`. When set, crashed bridges require manual restart with `mcpc @session restart`. The flag is stored in session data and checked in both ensureBridgeReady() (initial health check) and SessionClient.withRetry() (mid-operation crash). https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- CHANGELOG.md | 1 + src/cli/commands/sessions.ts | 2 ++ src/cli/index.ts | 3 +++ src/lib/bridge-manager.ts | 10 ++++++++++ src/lib/session-client.ts | 33 ++++++++++++++++++++++++++++++--- src/lib/types.ts | 1 + 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d360f40..f7363bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `--no-auto-restart` option for `connect` command to disable automatic bridge restart on crash (default: auto-restart enabled) - x402 payments are now also sent via the MCP `_meta["x402/payment"]` field on `tools/call` requests, in addition to the existing HTTP `PAYMENT-SIGNATURE` header — servers can choose which mechanism to consume - `_meta` parameter threaded through the full tool call chain (`IMcpClient`, `SessionClient`, bridge IPC, `McpClient`) for forward compatibility - `mcpc login` now falls back to accepting a pasted callback URL when the browser cannot be opened (e.g. headless servers, containers) diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 8a1f71c..e116035 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -88,6 +88,7 @@ export async function connectSession( proxyBearerToken?: string; x402?: boolean; insecure?: boolean; + autoRestart?: boolean; } ): Promise { // Validate session name @@ -233,6 +234,7 @@ export async function connectSession( ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: true }), ...(options.insecure && { insecure: true }), + ...(options.autoRestart === false && { autoRestart: false }), }; if (isReconnect) { diff --git a/src/cli/index.ts b/src/cli/index.ts index f9dd218..1489084 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -375,6 +375,7 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--x402', 'Enable x402 auto-payment using the configured wallet') + .option('--no-auto-restart', 'Disable automatic bridge restart on crash') .addHelpText( 'after', ` @@ -421,6 +422,7 @@ Server formats: proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), + ...(!opts.autoRestart && { autoRestart: false }), }); } else { await sessions.connectSession(server, sessionName, { @@ -430,6 +432,7 @@ Server formats: proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), + ...(!opts.autoRestart && { autoRestart: false }), }); } }); diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index fd0c378..9a57c02 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -567,6 +567,16 @@ export async function ensureBridgeReady(sessionName: string): Promise { logger.debug(`Bridge process not alive for ${sessionName}, will try to restart it`); } + // Bridge not healthy - check if auto-restart is disabled + if (session.autoRestart === false) { + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Bridge for ${sessionName} is not running (auto-restart is disabled).\n` + + `To restart manually, run: mcpc ${sessionName} restart\n` + + `For details, check logs at ${logPath}` + ); + } + // Bridge not healthy - restart it await restartBridge(sessionName); diff --git a/src/lib/session-client.ts b/src/lib/session-client.ts index 263792b..9fff3c6 100644 --- a/src/lib/session-client.ts +++ b/src/lib/session-client.ts @@ -35,6 +35,7 @@ import { BridgeClient } from './bridge-client.js'; import { ensureBridgeReady, restartBridge } from './bridge-manager.js'; import { NetworkError } from './errors.js'; import { getSocketPath, getLogsDir, generateRequestId } from './utils.js'; +import { getSession } from './sessions.js'; import { createLogger } from './logger.js'; const logger = createLogger('session-client'); @@ -47,11 +48,13 @@ export class SessionClient extends EventEmitter implements IMcpClient { private bridgeClient: BridgeClient; private sessionName: string; private requestTimeout?: number; // Per-request timeout in seconds + private autoRestart: boolean; // Whether to auto-restart bridge on crash - constructor(sessionName: string, bridgeClient: BridgeClient) { + constructor(sessionName: string, bridgeClient: BridgeClient, autoRestart = true) { super(); this.sessionName = sessionName; this.bridgeClient = bridgeClient; + this.autoRestart = autoRestart; this.setupNotificationForwarding(); } @@ -96,6 +99,16 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } + // If auto-restart is disabled, don't retry + if (!this.autoRestart) { + const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; + throw new NetworkError( + `Bridge for ${this.sessionName} connection failed (auto-restart is disabled).\n` + + `To restart manually, run: mcpc ${this.sessionName} restart\n` + + `For details, check logs at ${logPath}` + ); + } + logger.debug(`Socket error during ${operationName}, will restart bridge...`); // Close the failed client @@ -323,6 +336,16 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } + // If auto-restart is disabled, don't retry + if (!this.autoRestart) { + const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; + throw new NetworkError( + `Bridge for ${this.sessionName} connection failed (auto-restart is disabled).\n` + + `To restart manually, run: mcpc ${this.sessionName} restart\n` + + `For details, check logs at ${logPath}` + ); + } + logger.debug(`Socket error during callToolWithTask, will restart bridge...`); await this.bridgeClient.close(); await restartBridge(this.sessionName); @@ -446,12 +469,16 @@ export async function createSessionClient(sessionName: string): Promise Date: Mon, 16 Mar 2026 23:26:28 +0000 Subject: [PATCH 2/7] Make --auto-restart opt-in instead of default behavior Auto-restart can silently lose session state (MCP session ID, subscriptions, in-flight requests), which is confusing. Flip the default so crashed bridges stay crashed with a clear error message, and users opt in with `mcpc connect --auto-restart` when they explicitly accept potential state loss. https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- CHANGELOG.md | 2 +- src/cli/commands/sessions.ts | 2 +- src/cli/index.ts | 6 +++--- src/lib/bridge-manager.ts | 7 ++++--- src/lib/session-client.ts | 14 ++++++++------ src/lib/types.ts | 2 +- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7363bc..561e73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `--no-auto-restart` option for `connect` command to disable automatic bridge restart on crash (default: auto-restart enabled) +- `--auto-restart` option for `connect` command to automatically restart the bridge on crash; without this flag, crashed bridges require manual restart via `mcpc @session restart` - x402 payments are now also sent via the MCP `_meta["x402/payment"]` field on `tools/call` requests, in addition to the existing HTTP `PAYMENT-SIGNATURE` header — servers can choose which mechanism to consume - `_meta` parameter threaded through the full tool call chain (`IMcpClient`, `SessionClient`, bridge IPC, `McpClient`) for forward compatibility - `mcpc login` now falls back to accepting a pasted callback URL when the browser cannot be opened (e.g. headless servers, containers) diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index e116035..badc7bb 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -234,7 +234,7 @@ export async function connectSession( ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: true }), ...(options.insecure && { insecure: true }), - ...(options.autoRestart === false && { autoRestart: false }), + ...(options.autoRestart && { autoRestart: true }), }; if (isReconnect) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 1489084..22322c8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -375,7 +375,7 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--x402', 'Enable x402 auto-payment using the configured wallet') - .option('--no-auto-restart', 'Disable automatic bridge restart on crash') + .option('--auto-restart', 'Automatically restart bridge on crash (default: no restart)') .addHelpText( 'after', ` @@ -422,7 +422,7 @@ Server formats: proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), - ...(!opts.autoRestart && { autoRestart: false }), + ...(opts.autoRestart && { autoRestart: true }), }); } else { await sessions.connectSession(server, sessionName, { @@ -432,7 +432,7 @@ Server formats: proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), - ...(!opts.autoRestart && { autoRestart: false }), + ...(opts.autoRestart && { autoRestart: true }), }); } }); diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 9a57c02..199bf00 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -567,12 +567,13 @@ export async function ensureBridgeReady(sessionName: string): Promise { logger.debug(`Bridge process not alive for ${sessionName}, will try to restart it`); } - // Bridge not healthy - check if auto-restart is disabled - if (session.autoRestart === false) { + // Bridge not healthy - only auto-restart if explicitly enabled + if (session.autoRestart !== true) { const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; throw new ClientError( - `Bridge for ${sessionName} is not running (auto-restart is disabled).\n` + + `Bridge for ${sessionName} is not running.\n` + `To restart manually, run: mcpc ${sessionName} restart\n` + + `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${sessionName}\n` + `For details, check logs at ${logPath}` ); } diff --git a/src/lib/session-client.ts b/src/lib/session-client.ts index 9fff3c6..b9cfece 100644 --- a/src/lib/session-client.ts +++ b/src/lib/session-client.ts @@ -50,7 +50,7 @@ export class SessionClient extends EventEmitter implements IMcpClient { private requestTimeout?: number; // Per-request timeout in seconds private autoRestart: boolean; // Whether to auto-restart bridge on crash - constructor(sessionName: string, bridgeClient: BridgeClient, autoRestart = true) { + constructor(sessionName: string, bridgeClient: BridgeClient, autoRestart = false) { super(); this.sessionName = sessionName; this.bridgeClient = bridgeClient; @@ -99,12 +99,13 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } - // If auto-restart is disabled, don't retry + // Only auto-restart if explicitly enabled if (!this.autoRestart) { const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; throw new NetworkError( - `Bridge for ${this.sessionName} connection failed (auto-restart is disabled).\n` + + `Bridge for ${this.sessionName} connection failed.\n` + `To restart manually, run: mcpc ${this.sessionName} restart\n` + + `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${this.sessionName}\n` + `For details, check logs at ${logPath}` ); } @@ -336,12 +337,13 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } - // If auto-restart is disabled, don't retry + // Only auto-restart if explicitly enabled if (!this.autoRestart) { const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; throw new NetworkError( - `Bridge for ${this.sessionName} connection failed (auto-restart is disabled).\n` + + `Bridge for ${this.sessionName} connection failed.\n` + `To restart manually, run: mcpc ${this.sessionName} restart\n` + + `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${this.sessionName}\n` + `For details, check logs at ${logPath}` ); } @@ -471,7 +473,7 @@ export async function createSessionClient(sessionName: string): Promise Date: Mon, 16 Mar 2026 23:36:06 +0000 Subject: [PATCH 3/7] Scope --auto-restart to expired sessions only Auto-restart should only handle expired sessions (server rejected session ID), not crashed bridges. Crashed bridges already have their own recovery logic that always restarts on next command. Move auto-restart logic into ensureBridgeReady's expired-session handling and classifyAndThrowSessionError, revert all changes to SessionClient (crash recovery stays unchanged). https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- CHANGELOG.md | 2 +- src/cli/index.ts | 2 +- src/lib/bridge-manager.ts | 91 ++++++++++++++++++++++++++++++++------- src/lib/session-client.ts | 35 ++------------- 4 files changed, 81 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561e73a..059e478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `--auto-restart` option for `connect` command to automatically restart the bridge on crash; without this flag, crashed bridges require manual restart via `mcpc @session restart` +- `--auto-restart` option for `connect` command to automatically restart expired sessions (server rejected session ID) instead of requiring manual `mcpc @session restart` - x402 payments are now also sent via the MCP `_meta["x402/payment"]` field on `tools/call` requests, in addition to the existing HTTP `PAYMENT-SIGNATURE` header — servers can choose which mechanism to consume - `_meta` parameter threaded through the full tool call chain (`IMcpClient`, `SessionClient`, bridge IPC, `McpClient`) for forward compatibility - `mcpc login` now falls back to accepting a pasted callback URL when the browser cannot be opened (e.g. headless servers, containers) diff --git a/src/cli/index.ts b/src/cli/index.ts index 22322c8..18e82a2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -375,7 +375,7 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--x402', 'Enable x402 auto-payment using the configured wallet') - .option('--auto-restart', 'Automatically restart bridge on crash (default: no restart)') + .option('--auto-restart', 'Automatically restart session when it expires') .addHelpText( 'after', ` diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 199bf00..b3b0faf 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -48,17 +48,26 @@ const logger = createLogger('bridge-manager'); * Classify a bridge health check error as session expiry or auth failure and throw. * Session expiry (404/session-not-found) is checked first since it's more specific * than auth errors (401/403/unauthorized). Does nothing if neither pattern matches. + * + * When autoRestart is true, session expiry is marked but not thrown — the caller + * is expected to handle the restart. Returns 'expired' in this case. */ async function classifyAndThrowSessionError( sessionName: string, - session: { server: ServerConfig }, + session: { server: ServerConfig; autoRestart?: boolean }, errorMessage: string, originalError?: Error -): Promise { +): Promise<'expired' | void> { if (isSessionExpiredError(errorMessage)) { await updateSession(sessionName, { status: 'expired' }).catch((e) => logger.warn(`Failed to mark session ${sessionName} as expired:`, e) ); + + if (session.autoRestart) { + logger.debug(`Session ${sessionName} expired, auto-restart enabled — deferring to caller`); + return 'expired'; + } + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; throw new ClientError( `Session ${sessionName} expired (server rejected session ID). ` + @@ -529,11 +538,41 @@ export async function ensureBridgeReady(sessionName: string): Promise { } if (session.status === 'expired') { + if (session.autoRestart) { + logger.debug(`Session ${sessionName} expired, auto-restarting...`); + // Clear expired status so restartBridge can proceed + await updateSession(sessionName, { status: 'active' }); + await restartBridge(sessionName); + + const socketPath = getSocketPath(sessionName); + const result = await checkBridgeHealth(socketPath); + if (result.healthy) { + logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); + return socketPath; + } + + // Auto-restart failed - classify the error (omit autoRestart to prevent retry loop) + const errorMsg = result.error?.message || 'unknown error'; + await classifyAndThrowSessionError( + sessionName, + { server: session.server }, + errorMsg, + result.error + ); + + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Session ${sessionName} failed to auto-restart after expiry: ${errorMsg}. ` + + `For details, check logs at ${logPath}` + ); + } + 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` + `To remove the expired session, run: mcpc ${sessionName} close\n` + + `To enable automatic restarts on expiry, recreate with: mcpc connect --auto-restart ${sessionName}` ); } @@ -553,7 +592,40 @@ export async function ensureBridgeReady(sessionName: string): Promise { // Not healthy - check error type if (result.error) { const errorMessage = result.error.message || ''; - await classifyAndThrowSessionError(sessionName, session, errorMessage, result.error); + const classification = await classifyAndThrowSessionError( + sessionName, + session, + errorMessage, + result.error + ); + if (classification === 'expired') { + // Auto-restart handles expiry below via the shared restart path + logger.debug(`Session ${sessionName} expired during health check, auto-restarting...`); + await stopBridge(sessionName).catch(() => {}); + await updateSession(sessionName, { status: 'active' }); + await restartBridge(sessionName); + + const retryResult = await checkBridgeHealth(socketPath); + if (retryResult.healthy) { + logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); + return socketPath; + } + + const retryMsg = retryResult.error?.message || 'unknown error'; + // On retry failure, don't loop — pass autoRestart=false equivalent by using a plain session + await classifyAndThrowSessionError( + sessionName, + { server: session.server }, + retryMsg, + retryResult.error + ); + + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Session ${sessionName} failed to auto-restart after expiry: ${retryMsg}. ` + + `For details, check logs at ${logPath}` + ); + } if (result.error instanceof NetworkError) { logger.debug(`Bridge process alive but socket not responding for ${sessionName}`); } else { @@ -567,17 +639,6 @@ export async function ensureBridgeReady(sessionName: string): Promise { logger.debug(`Bridge process not alive for ${sessionName}, will try to restart it`); } - // Bridge not healthy - only auto-restart if explicitly enabled - if (session.autoRestart !== true) { - const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; - throw new ClientError( - `Bridge for ${sessionName} is not running.\n` + - `To restart manually, run: mcpc ${sessionName} restart\n` + - `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${sessionName}\n` + - `For details, check logs at ${logPath}` - ); - } - // Bridge not healthy - restart it await restartBridge(sessionName); diff --git a/src/lib/session-client.ts b/src/lib/session-client.ts index b9cfece..263792b 100644 --- a/src/lib/session-client.ts +++ b/src/lib/session-client.ts @@ -35,7 +35,6 @@ import { BridgeClient } from './bridge-client.js'; import { ensureBridgeReady, restartBridge } from './bridge-manager.js'; import { NetworkError } from './errors.js'; import { getSocketPath, getLogsDir, generateRequestId } from './utils.js'; -import { getSession } from './sessions.js'; import { createLogger } from './logger.js'; const logger = createLogger('session-client'); @@ -48,13 +47,11 @@ export class SessionClient extends EventEmitter implements IMcpClient { private bridgeClient: BridgeClient; private sessionName: string; private requestTimeout?: number; // Per-request timeout in seconds - private autoRestart: boolean; // Whether to auto-restart bridge on crash - constructor(sessionName: string, bridgeClient: BridgeClient, autoRestart = false) { + constructor(sessionName: string, bridgeClient: BridgeClient) { super(); this.sessionName = sessionName; this.bridgeClient = bridgeClient; - this.autoRestart = autoRestart; this.setupNotificationForwarding(); } @@ -99,17 +96,6 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } - // Only auto-restart if explicitly enabled - if (!this.autoRestart) { - const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; - throw new NetworkError( - `Bridge for ${this.sessionName} connection failed.\n` + - `To restart manually, run: mcpc ${this.sessionName} restart\n` + - `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${this.sessionName}\n` + - `For details, check logs at ${logPath}` - ); - } - logger.debug(`Socket error during ${operationName}, will restart bridge...`); // Close the failed client @@ -337,17 +323,6 @@ export class SessionClient extends EventEmitter implements IMcpClient { throw error; } - // Only auto-restart if explicitly enabled - if (!this.autoRestart) { - const logPath = `${getLogsDir()}/bridge-${this.sessionName}.log`; - throw new NetworkError( - `Bridge for ${this.sessionName} connection failed.\n` + - `To restart manually, run: mcpc ${this.sessionName} restart\n` + - `To enable automatic restarts, recreate with: mcpc connect --auto-restart ${this.sessionName}\n` + - `For details, check logs at ${logPath}` - ); - } - logger.debug(`Socket error during callToolWithTask, will restart bridge...`); await this.bridgeClient.close(); await restartBridge(this.sessionName); @@ -471,16 +446,12 @@ export async function createSessionClient(sessionName: string): Promise Date: Mon, 16 Mar 2026 23:40:08 +0000 Subject: [PATCH 4/7] Clarify --auto-restart help text about state loss https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 18e82a2..3ef4466 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -375,7 +375,7 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--x402', 'Enable x402 auto-payment using the configured wallet') - .option('--auto-restart', 'Automatically restart session when it expires') + .option('--auto-restart', 'Automatically restart session when it expires (loses state)') .addHelpText( 'after', ` From 717458838b1e2966b8e5a2637bdc2ebe34ea9c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 10:25:43 +0000 Subject: [PATCH 5/7] Deduplicate restart logic: restartSession delegates to restartBridge - Add `RestartBridgeOptions` with `freshSession` flag and `resolveProfile` callback so `restartBridge()` can handle both automatic recovery and explicit user-initiated restarts - `restartSession()` now delegates to `restartBridge(freshSession: true)` instead of duplicating bridge option assembly, header loading, and profile resolution - Extract `autoRestartExpiredBridge()` helper to eliminate the two near-identical auto-restart blocks in `ensureBridgeReady()` https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- CHANGELOG.md | 1 + src/cli/commands/sessions.ts | 90 +++++---------------- src/lib/bridge-manager.ts | 151 +++++++++++++++++++++-------------- 3 files changed, 112 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 702ce2b..96c32de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `--auto-restart` help text now clarifies that restarting loses session state - **Breaking:** CLI syntax redesigned to command-first style. All commands now start with a verb; MCP operations require a named session. | Before | After | diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 75a94c2..944ea96 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -30,7 +30,12 @@ import { consolidateSessions, getSession, } from '../../lib/sessions.js'; -import { startBridge, StartBridgeOptions, stopBridge } from '../../lib/bridge-manager.js'; +import { + startBridge, + StartBridgeOptions, + stopBridge, + restartBridge, +} from '../../lib/bridge-manager.js'; import { storeKeychainSessionHeaders, storeKeychainProxyBearerToken, @@ -595,86 +600,31 @@ export async function restartSession( options: { outputMode: OutputMode; verbose?: boolean } ): Promise { try { - // Get existing session + // Verify session exists const session = await getSession(name); - if (!session) { throw new ClientError(`Session not found: ${name}`); } - if (options.outputMode === 'human') { - console.log(chalk.yellow(`Restarting session ${name}...`)); - } - - // Stop the bridge (even if it's alive) - try { - await stopBridge(name); - } catch { - // Bridge may already be stopped - } - - // Get server config from session - const serverConfig = session.server; - if (!serverConfig) { + if (!session.server) { throw new ClientError(`Session ${name} has no server configuration`); } - // Load headers from keychain if present - const { readKeychainSessionHeaders } = await import('../../lib/auth/keychain.js'); - const headers = await readKeychainSessionHeaders(name); - - // Start bridge process - const bridgeOptions: StartBridgeOptions = { - sessionName: name, - serverConfig: { ...serverConfig, ...(headers && { headers }) }, - verbose: options.verbose || false, - }; - - if (headers) { - bridgeOptions.headers = headers; - } - - // Resolve auth profile: use stored profile, or auto-detect a "default" profile. - // This handles the case where user creates a session without auth, then later runs - // `mcpc login ` to create a default profile, and restarts the session. - const hasExplicitAuthHeader = headers?.Authorization !== undefined; - let profileName = session.profileName; - if (!profileName && serverConfig.url && !hasExplicitAuthHeader) { - profileName = await resolveAuthProfile(serverConfig.url, serverConfig.url, undefined, { - sessionName: name, - }); - if (profileName) { - logger.debug(`Discovered auth profile "${profileName}" for session ${name}`); - await updateSession(name, { profileName }); - } - } - - if (profileName) { - bridgeOptions.profileName = profileName; - } - - if (session.proxy) { - bridgeOptions.proxyConfig = session.proxy; - } - - if (session.x402) { - bridgeOptions.x402 = session.x402; - } - - if (session.insecure) { - bridgeOptions.insecure = session.insecure; + if (options.outputMode === 'human') { + console.log(chalk.yellow(`Restarting session ${name}...`)); } - // NOTE: Do NOT pass mcpSessionId on explicit restart. - // Explicit restart should create a fresh session, not try to resume the old one. - // Session resumption is only attempted on automatic bridge restart (when bridge crashes - // and CLI detects it). If server rejects the session ID, session is marked as expired. - - const { pid } = await startBridge(bridgeOptions); + // Delegate to restartBridge with freshSession=true to create a clean MCP session + // and re-discover auth profiles (handles the case where user ran `mcpc login` after connect) + await restartBridge(name, { + freshSession: true, + verbose: options.verbose || false, + resolveProfile: async (serverUrl, sessionName) => { + return resolveAuthProfile(serverUrl, serverUrl, undefined, { sessionName }); + }, + }); - // Update session with new bridge PID and clear any expired/crashed status - await updateSession(name, { pid, status: 'active' }); - logger.debug(`Session ${name} restarted with bridge PID: ${pid}`); + logger.debug(`Session ${name} restarted successfully`); // Success message if (options.outputMode === 'human') { diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index b3b0faf..ed719e0 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -297,15 +297,39 @@ export async function stopBridge(sessionName: string): Promise { // Full cleanup happens in closeSession(). } +export interface RestartBridgeOptions { + /** + * When true, creates a fresh MCP session (no session ID resumption) and + * re-discovers auth profiles. Used for explicit user-initiated restarts. + * When false (default), attempts to resume the existing MCP session. + */ + freshSession?: boolean; + /** Verbose logging flag passed to the bridge process */ + verbose?: boolean; + /** + * Optional callback to resolve an auth profile name for the session. + * Only called when freshSession is true and the session has no stored profile. + * This allows the CLI layer to inject its own profile resolution logic. + */ + resolveProfile?: (serverUrl: string, sessionName: string) => Promise; +} + /** * Restart a bridge process for a session - * Used for automatic recovery when connection to bridge fails + * Used for automatic recovery when connection to bridge fails, and also + * for explicit user-initiated restarts (with freshSession: true). * * Headers persist in keychain across bridge restarts, so they are * retrieved here and passed to startBridge() which sends them via IPC. */ -export async function restartBridge(sessionName: string): Promise { - logger.debug(`Trying to restart bridge for ${sessionName}...`); +export async function restartBridge( + sessionName: string, + options: RestartBridgeOptions = {} +): Promise { + const { freshSession = false, verbose, resolveProfile } = options; + logger.debug( + `Trying to restart bridge for ${sessionName}${freshSession ? ' (fresh session)' : ''}...` + ); const session = await getSession(sessionName); @@ -340,24 +364,42 @@ export async function restartBridge(sessionName: string): Promise { + logger.debug(`Session ${sessionName} expired (${context}), auto-restarting...`); + await restartBridge(sessionName); + + const socketPath = getSocketPath(sessionName); + const result = await checkBridgeHealth(socketPath); + if (result.healthy) { + logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); + return socketPath; + } + + // Auto-restart failed - classify the error (omit autoRestart to prevent retry loop) + const errorMsg = result.error?.message || 'unknown error'; + await classifyAndThrowSessionError( + sessionName, + { server: session.server }, + errorMsg, + result.error + ); + + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Session ${sessionName} failed to auto-restart after expiry: ${errorMsg}. ` + + `For details, check logs at ${logPath}` + ); +} + export async function ensureBridgeReady(sessionName: string): Promise { const session = await getSession(sessionName); @@ -539,32 +619,7 @@ export async function ensureBridgeReady(sessionName: string): Promise { if (session.status === 'expired') { if (session.autoRestart) { - logger.debug(`Session ${sessionName} expired, auto-restarting...`); - // Clear expired status so restartBridge can proceed - await updateSession(sessionName, { status: 'active' }); - await restartBridge(sessionName); - - const socketPath = getSocketPath(sessionName); - const result = await checkBridgeHealth(socketPath); - if (result.healthy) { - logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); - return socketPath; - } - - // Auto-restart failed - classify the error (omit autoRestart to prevent retry loop) - const errorMsg = result.error?.message || 'unknown error'; - await classifyAndThrowSessionError( - sessionName, - { server: session.server }, - errorMsg, - result.error - ); - - const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; - throw new ClientError( - `Session ${sessionName} failed to auto-restart after expiry: ${errorMsg}. ` + - `For details, check logs at ${logPath}` - ); + return autoRestartExpiredBridge(sessionName, session, 'status was expired'); } throw new ClientError( @@ -599,32 +654,8 @@ export async function ensureBridgeReady(sessionName: string): Promise { result.error ); if (classification === 'expired') { - // Auto-restart handles expiry below via the shared restart path - logger.debug(`Session ${sessionName} expired during health check, auto-restarting...`); await stopBridge(sessionName).catch(() => {}); - await updateSession(sessionName, { status: 'active' }); - await restartBridge(sessionName); - - const retryResult = await checkBridgeHealth(socketPath); - if (retryResult.healthy) { - logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); - return socketPath; - } - - const retryMsg = retryResult.error?.message || 'unknown error'; - // On retry failure, don't loop — pass autoRestart=false equivalent by using a plain session - await classifyAndThrowSessionError( - sessionName, - { server: session.server }, - retryMsg, - retryResult.error - ); - - const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; - throw new ClientError( - `Session ${sessionName} failed to auto-restart after expiry: ${retryMsg}. ` + - `For details, check logs at ${logPath}` - ); + return autoRestartExpiredBridge(sessionName, session, 'detected during health check'); } if (result.error instanceof NetworkError) { logger.debug(`Bridge process alive but socket not responding for ${sessionName}`); From 6cb46081251ed58b532ad07930ecdb0b702685df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 11:50:07 +0000 Subject: [PATCH 6/7] Replace classifyAndThrowSessionError with pure classifySessionError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old function had confusing dual behavior: sometimes it threw, sometimes it returned 'expired', sometimes it returned void — controlled by a session.autoRestart flag buried in the parameters. The new classifySessionError() purely classifies and updates session status, returning 'expired' | 'unauthorized' | 'unknown'. Each call site handles the classification explicitly with clear if/throw chains. https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- src/lib/bridge-manager.ts | 94 ++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index ed719e0..8398b9c 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -44,47 +44,30 @@ import { getWallet } from './wallets.js'; const logger = createLogger('bridge-manager'); +type SessionErrorKind = 'expired' | 'unauthorized' | 'unknown'; + /** - * Classify a bridge health check error as session expiry or auth failure and throw. - * Session expiry (404/session-not-found) is checked first since it's more specific - * than auth errors (401/403/unauthorized). Does nothing if neither pattern matches. - * - * When autoRestart is true, session expiry is marked but not thrown — the caller - * is expected to handle the restart. Returns 'expired' in this case. + * Classify a bridge health check error and update session status accordingly. + * Never throws — callers decide how to handle each classification. */ -async function classifyAndThrowSessionError( +async function classifySessionError( sessionName: string, - session: { server: ServerConfig; autoRestart?: boolean }, - errorMessage: string, - originalError?: Error -): Promise<'expired' | void> { + errorMessage: string +): Promise { + // Session expiry (404/session-not-found) checked first — more specific than auth errors (401/403) if (isSessionExpiredError(errorMessage)) { await updateSession(sessionName, { status: 'expired' }).catch((e) => logger.warn(`Failed to mark session ${sessionName} as expired:`, e) ); - - if (session.autoRestart) { - logger.debug(`Session ${sessionName} expired, auto-restart enabled — deferring to caller`); - return 'expired'; - } - - const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; - throw new ClientError( - `Session ${sessionName} expired (server rejected session ID). ` + - `Use "mcpc ${sessionName} restart" to start a new session. ` + - `For details, check logs at ${logPath}` - ); + return 'expired'; } if (isAuthenticationError(errorMessage)) { await updateSession(sessionName, { status: 'unauthorized' }).catch((e) => logger.warn(`Failed to mark session ${sessionName} as unauthorized:`, e) ); - const target = session.server.url || session.server.command || sessionName; - throw createServerAuthError(target, { - sessionName, - ...(originalError && { originalError }), - }); + return 'unauthorized'; } + return 'unknown'; } // Get the path to the bridge executable @@ -589,14 +572,14 @@ async function autoRestartExpiredBridge( return socketPath; } - // Auto-restart failed - classify the error (omit autoRestart to prevent retry loop) + // Auto-restart failed const errorMsg = result.error?.message || 'unknown error'; - await classifyAndThrowSessionError( - sessionName, - { server: session.server }, - errorMsg, - result.error - ); + const kind = await classifySessionError(sessionName, errorMsg); + + if (kind === 'unauthorized') { + const target = session.server.url || session.server.command || sessionName; + throw createServerAuthError(target, { sessionName, originalError: result.error }); + } const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; throw new ClientError( @@ -647,20 +630,27 @@ export async function ensureBridgeReady(sessionName: string): Promise { // Not healthy - check error type if (result.error) { const errorMessage = result.error.message || ''; - const classification = await classifyAndThrowSessionError( - sessionName, - session, - errorMessage, - result.error - ); - if (classification === 'expired') { + const kind = await classifySessionError(sessionName, errorMessage); + + if (kind === 'expired' && session.autoRestart) { await stopBridge(sessionName).catch(() => {}); return autoRestartExpiredBridge(sessionName, session, 'detected during health check'); } + if (kind === 'expired') { + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Session ${sessionName} expired (server rejected session ID). ` + + `Use "mcpc ${sessionName} restart" to start a new session. ` + + `For details, check logs at ${logPath}` + ); + } + if (kind === 'unauthorized') { + const target = session.server.url || session.server.command || sessionName; + throw createServerAuthError(target, { sessionName, originalError: result.error }); + } if (result.error instanceof NetworkError) { logger.debug(`Bridge process alive but socket not responding for ${sessionName}`); } else { - // Other MCP errors - propagate throw new ClientError( `Bridge for ${sessionName} failed to connect to MCP server: ${result.error.message}` ); @@ -680,11 +670,23 @@ export async function ensureBridgeReady(sessionName: string): Promise { return socketPath; } - // Not healthy after restart - classify the error + // Not healthy after restart - classify and throw const errorMsg = result.error?.message || 'unknown error'; - await classifyAndThrowSessionError(sessionName, session, errorMsg, result.error); + const kind = await classifySessionError(sessionName, errorMsg); + + if (kind === 'expired') { + const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; + throw new ClientError( + `Session ${sessionName} expired (server rejected session ID). ` + + `Use "mcpc ${sessionName} restart" to start a new session. ` + + `For details, check logs at ${logPath}` + ); + } + if (kind === 'unauthorized') { + const target = session.server.url || session.server.command || sessionName; + throw createServerAuthError(target, { sessionName, originalError: result.error }); + } - // Other errors - provide detailed error with log path const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; throw new ClientError( `Bridge for ${sessionName} failed after restart: ${errorMsg}. ` + From 106af58cb351a084f7fffb6da12b2d0fbebf37f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 22:21:35 +0000 Subject: [PATCH 7/7] Fix exactOptionalPropertyTypes errors and status update timing - Use conditional spread for originalError to avoid passing undefined to optional Error parameter (TS2379 with exactOptionalPropertyTypes) - Only set status:'active' in restartBridge for freshSession (explicit user restarts). Automatic restarts leave status unchanged so ensureBridgeReady can classify errors before updating status. - Set status:'active' after successful health checks in autoRestartExpiredBridge and ensureBridgeReady. https://claude.ai/code/session_01JguVLhdg3mmrejPXf9RehX --- src/lib/bridge-manager.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 8398b9c..791e416 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -394,8 +394,11 @@ export async function restartBridge( const { pid } = await startBridge(bridgeOptions); - // Update session with new PID and clear any expired/crashed status - await updateSession(sessionName, { pid, status: 'active' }); + // Update session with new PID. + // For fresh restarts (explicit user action), set status to active immediately. + // For automatic restarts (crash recovery), leave status unchanged — the caller + // (ensureBridgeReady) verifies health and classifies errors before updating status. + await updateSession(sessionName, { pid, ...(freshSession && { status: 'active' }) }); logger.debug(`Bridge restarted for ${sessionName} with PID: ${pid}`); @@ -568,17 +571,21 @@ async function autoRestartExpiredBridge( const socketPath = getSocketPath(sessionName); const result = await checkBridgeHealth(socketPath); if (result.healthy) { + await updateSession(sessionName, { status: 'active' }); logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); return socketPath; } - // Auto-restart failed + // Auto-restart failed — classify the new error const errorMsg = result.error?.message || 'unknown error'; const kind = await classifySessionError(sessionName, errorMsg); if (kind === 'unauthorized') { const target = session.server.url || session.server.command || sessionName; - throw createServerAuthError(target, { sessionName, originalError: result.error }); + throw createServerAuthError(target, { + sessionName, + ...(result.error && { originalError: result.error }), + }); } const logPath = `${getLogsDir()}/bridge-${sessionName}.log`; @@ -666,6 +673,7 @@ export async function ensureBridgeReady(sessionName: string): Promise { // 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; } @@ -684,7 +692,10 @@ export async function ensureBridgeReady(sessionName: string): Promise { } if (kind === 'unauthorized') { const target = session.server.url || session.server.command || sessionName; - throw createServerAuthError(target, { sessionName, originalError: result.error }); + throw createServerAuthError(target, { + sessionName, + ...(result.error && { originalError: result.error }), + }); } const logPath = `${getLogsDir()}/bridge-${sessionName}.log`;