diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd3660..c28a11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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 | @@ -51,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mcpc @session` now shows available tools list from bridge cache (no extra server call) - Task capability and `execution.taskSupport` displayed in `tools-get` and server info - x402 payments are now also sent via the MCP `_meta["x402/payment"]` field on `tools/call` requests, in addition to the existing HTTP header +- `--auto-restart` option for `connect` command to automatically restart expired sessions (server rejected session ID) instead of requiring manual `mcpc @session restart` ### Fixed diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 0a82af4..ec1a829 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, @@ -88,6 +93,7 @@ export async function connectSession( proxyBearerToken?: string; x402?: boolean; insecure?: boolean; + autoRestart?: boolean; } ): Promise { // Validate session name @@ -233,6 +239,7 @@ export async function connectSession( ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: true }), ...(options.insecure && { insecure: true }), + ...(options.autoRestart && { autoRestart: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), }; @@ -595,86 +602,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/cli/index.ts b/src/cli/index.ts index e0d62f8..9d30cc0 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('--auto-restart', 'Automatically restart session when it expires (loses state)') .addHelpText( 'after', ` @@ -421,6 +422,7 @@ ${chalk.bold('Server formats:')} proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), + ...(opts.autoRestart && { autoRestart: true }), }); } else { await sessions.connectSession(server, sessionName, { @@ -430,6 +432,7 @@ ${chalk.bold('Server formats:')} proxyBearerToken: opts.proxyBearerToken, x402: opts.x402, ...(globalOpts.insecure && { insecure: true }), + ...(opts.autoRestart && { autoRestart: true }), }); } }); diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index fd0c378..791e416 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -44,38 +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. + * 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 }, - errorMessage: string, - originalError?: Error -): Promise { + 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) ); - 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 @@ -288,15 +280,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); @@ -331,24 +347,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) { + await updateSession(sessionName, { status: 'active' }); + logger.debug(`Session ${sessionName} auto-restarted successfully after expiry`); + return socketPath; + } + + // 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, + ...(result.error && { originalError: 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); @@ -529,11 +608,16 @@ export async function ensureBridgeReady(sessionName: string): Promise { } if (session.status === 'expired') { + if (session.autoRestart) { + return autoRestartExpiredBridge(sessionName, session, 'status was 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` + `To remove the expired session, run: mcpc ${sessionName} close\n` + + `To enable automatic restarts on expiry, recreate with: mcpc connect --auto-restart ${sessionName}` ); } @@ -553,11 +637,27 @@ 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 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}` ); @@ -573,15 +673,31 @@ 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; } - // 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, + ...(result.error && { 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}. ` + diff --git a/src/lib/types.ts b/src/lib/types.ts index fbf9adf..135cada 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -136,6 +136,7 @@ export interface SessionData { profileName?: string; // Name of auth profile (for OAuth servers) x402?: boolean; // x402 auto-payment enabled for this session insecure?: boolean; // Skip TLS certificate verification + autoRestart?: boolean; // Auto-restart bridge on crash (default: false) pid?: number; // Bridge process PID protocolVersion?: string; // Negotiated MCP version mcpSessionId?: string; // Server-assigned MCP session ID for resumption (Streamable HTTP only)