pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
967 lines (885 loc) • 35.3 kB
text/typescript
/**
* Server lifecycle manager for pi-mcp.
*
* Manages MCP server connections using the official SDK.
* Deliberately thin: the SDK handles protocol state, transport, and process lifecycle,
* `src/mcp-client.ts` handles the stateless client/ping adapter, and this module owns only
* what needs a state machine:
* - 3-state lifecycle per server (stopped / starting / ready)
* - Retry with a fixed delay schedule
* - Background recovery on transport close, stream error, or a failed health-check ping
* - Stderr capture (circular buffer)
* - PID tracking for safety-net SIGKILL on shutdown failure
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { AuthCancelledError, AuthRequiredError, McpError } from "./errors.js";
import type { McpConfig, ServerConfig, Settings } from "./config.js";
import { getAuthStatus, invalidateStoredTokens, resetAuth } from "./oauth-status.js";
import type { ToolRefreshOptions } from "./tool-bridge.js";
import { redactSecrets } from "./secret-redaction.js";
import { sanitizeTerminalText } from "./terminal-text.js";
import { describeError, sanitizeLogText } from "./debug-log-format.js";
import { reportDiagnostic } from "./debug-log.js";
import {
ensurePidTerminated,
instrumentStdioTransport,
resolveTransportPid,
terminateStdioTree,
} from "./stdio-process.js";
import { createTransport } from "./transport-factory.js";
import {
classifyClientError,
classifyConnectFailure,
type ClientErrorAction,
type ConnectFailureAction,
} from "./connect-policy.js";
import {
connectClientWithTimeout,
createMcpClient,
startHealthCheck,
type FormattedServerLog,
} from "./mcp-client.js";
/**
* Strip carriage returns, ANSI CSI/OSC/DCS/PM/APC escape sequences, and C0/C1
* control characters from a single server stderr line before storing or logging.
*/
export function sanitizeStderrLine(line: string): string {
return sanitizeTerminalText(line);
}
/** Sanitized message text of a client/transport error, for diagnostics. */
function clientErrorMessage(err: unknown): string {
return sanitizeTerminalText(err instanceof Error ? err.message : String(err));
}
// ─── Types ────────────────────────────────────────────────────────────────────
export type ServerState = "stopped" | "starting" | "ready";
/** Maximum stderr lines stored per server (circular). */
const STDERR_BUFFER_SIZE = 100;
export interface ManagedServer {
name: string;
config: ServerConfig;
state: ServerState;
client: Client | null;
transport?: Transport | undefined;
cwd?: string | undefined;
/** PID of the child process (stdio transport only). Used for safety-net cleanup. */
childPid: number | null;
retryCount: number;
lastError: Error | null;
/** Recent stderr lines from the server subprocess. */
stderrLog: string[];
healthCheckTimer: ReturnType<typeof setInterval> | null;
/** Pending retry timeout — cleared on shutdown to prevent ghost reconnects. */
retryTimer: ReturnType<typeof setTimeout> | null;
attempt?: Promise<void> | undefined;
controller?: AbortController | undefined;
generation?: number;
/** Timestamp of the last state transition — used for the debug log's `ms` field. */
stateSince?: number | undefined;
}
/** Called after tool list is refreshed for a server (e.g. on list_changed notification). */
export type ToolRefreshCallback = (
serverName: string,
client: Client,
options?: ToolRefreshOptions,
) => Promise<void>;
/** Called when background auto-recovery after a disconnect or health check fails. */
export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
/**
* Called every time a server reaches `ready`, whether the transition came from an
* explicit connect, an eager startup, or background `_recover()`.
*
* WHY: Readiness is the only state change that makes a previously declined
* `run /mcp:auth <name>` footer hint obsolete, and eager starts plus background
* recovery bypass `ConnectionCoordinator.connect()`, which is where the hint is
* otherwise repainted.
*/
export type ServerReadyCallback = (serverName: string) => void;
interface HandshakeParams {
server: ManagedServer;
client: Client;
transport: Transport;
timeoutMs: number;
signal: AbortSignal;
currentCwd: string;
}
interface ConnectFailureContext {
server: ManagedServer;
client: Client;
transport: Transport;
cwd: string;
signal: AbortSignal;
corruptStateRecovered: boolean;
err: unknown;
}
// ─── ServerManager ────────────────────────────────────────────────────────────
export class ServerManager {
private readonly servers = new Map<string, ManagedServer>();
private settings: Settings;
private onToolRefresh: ToolRefreshCallback | null = null;
private onResourceListChanged: ((name: string) => void) | null = null;
private onServerStopped: ((name: string) => void) | null = null;
private onRecoveryFailed: RecoveryFailedCallback | null = null;
private onServerReady: ServerReadyCallback | null = null;
constructor(config: McpConfig, private readonly storageDir?: string) {
this.settings = config.settings;
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
this.setServer(name, serverConfig);
}
}
/** Register callback invoked after tool list changes for a server. */
setToolRefreshCallback(cb: ToolRefreshCallback): void {
this.onToolRefresh = cb;
}
/** Register callback invoked when a server reports `notifications/resources/list_changed`. */
setResourceListChangedCallback(cb: (name: string) => void): void {
this.onResourceListChanged = cb;
}
setServerStoppedCallback(cb: (name: string) => void): void {
this.onServerStopped = cb;
}
setRecoveryFailedCallback(cb: RecoveryFailedCallback): void {
this.onRecoveryFailed = cb;
}
/** Register callback invoked whenever a server reaches the `ready` state. */
setServerReadyCallback(cb: ServerReadyCallback): void {
this.onServerReady = cb;
}
/** Effective request timeout for a server: its own override, else the session default. */
resolveRequestTimeoutMs(server: ManagedServer): number {
return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
}
/**
* Move a server to a new lifecycle state and record the transition.
*
* The transition is reported to the debug log (never to stderr while a UI is
* active) so maintainers can see when a server started, became ready, or
* was torn down.
*/
private _setState(server: ManagedServer, next: ServerState, reason?: string): void {
const from = server.state;
if (from === next) return;
const now = Date.now();
const ms = server.stateSince !== undefined ? now - server.stateSince : undefined;
server.state = next;
server.stateSince = now;
reportDiagnostic({
level: "info",
event: "server.state",
server: server.name,
message: `Server ${server.name} state ${from} -> ${next}`,
fields: { from, to: next, ms, reason },
});
}
createTransport(name: string, config: ServerConfig, cwd?: string): Transport {
return createTransport(name, config, cwd, this.storageDir, cwd);
}
getServer(name: string): ManagedServer | undefined {
return this.servers.get(name);
}
getServerStatus(name: string): ManagedServer | undefined {
return this.servers.get(name);
}
getAllServers(): ManagedServer[] {
return Array.from(this.servers.values());
}
getReadyServers(): ManagedServer[] {
return this.getAllServers().filter((s) => s.state === "ready");
}
private _updateServerCwd(server: ManagedServer, cwd: string): void {
const prevCwd = server.cwd;
server.cwd = cwd;
if (prevCwd !== undefined && prevCwd !== cwd && server.state === "ready" && server.client) {
void server.client.sendRootsListChanged().catch(() => {
// WHY: Best-effort notification; some MCP servers ignore or do not support roots/list_changed.
});
}
}
/** Update workspace cwd for all managed servers and notify ready servers if cwd changed. */
updateCwd(cwd: string): void {
for (const server of this.servers.values()) {
this._updateServerCwd(server, cwd);
}
}
/** Status summary for /mcp command. */
getStatusSummary(): string {
const all = this.getAllServers();
if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
const lines = all.map((s) => {
const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
const err = s.lastError ? ` — ${sanitizeLogText(s.lastError.message, 500)}` : "";
return ` ${icon} ${s.name} (${s.state})${err}`;
});
const ready = all.filter((s) => s.state === "ready").length;
return [`MCP: ${ready}/${all.length} servers ready`, ...lines].join("\n");
}
/** Reset OAuth credentials for a server, forcing re-authorization on next connect. */
async resetServerAuth(name: string): Promise<void> {
await resetAuth(name, this.storageDir);
}
/** Get auth status for a server. */
async getServerAuthStatus(name: string): Promise<{
hasTokens: boolean;
hasClientInfo: boolean;
savedAt: string | undefined;
scope: string | undefined;
} | null> {
const serverUrl = this.servers.get(name)?.config.url;
return getAuthStatus(name, this.storageDir, serverUrl);
}
/**
* Mark an OAuth server as requiring re-authorization when a mid-session
* request (tools/list or tools/call) fails with 401/403 insufficient_scope.
*
* Returns the {@link AuthRequiredError} when a server that was already `ready`
* lost its credentials mid-session, and `undefined` otherwise (unknown server,
* non-OAuth server, or a server that is not `ready` — see below).
*
* Only a `ready` server is handled here. For any other state this is a no-op and
* must NOT touch the server: a 401/403 during a connect attempt (`initialize` ok,
* `tools/list` 401 while `starting`) arrives while that attempt owns the
* lifecycle, and shutting the server down here would abort the attempt's own
* signal. `classifyConnectFailure` then sees `aborted` first and reports
* `AuthCancelledError` instead of `auth-required`, which suppresses the startup
* auth prompt, the interactive authorization, and the recovery failure alike.
* Left alone, the attempt's `_raiseAuthRequired` invalidates the tokens, records
* the `AuthRequiredError`, and reports it exactly once.
*/
async handleUnauthorizedServerError(name: string): Promise<AuthRequiredError | undefined> {
const server = this.servers.get(name);
if (!server || !server.config.auth || server.config.transport === "stdio") {
return undefined;
}
if (server.state !== "ready") {
// WHY: See the JSDoc above — an in-flight connect attempt reports its own 401.
return undefined;
}
const authError = new AuthRequiredError(name);
// WHY: Shut down immediately before async disk I/O so server.generation increments
// synchronously and cancels any concurrent _recover() microtask queued by client.onerror.
await this._shutdown(server);
server.lastError = authError;
await invalidateStoredTokens(name, this.storageDir, server.config.url);
server.lastError = authError;
return authError;
}
/** Get recent stderr output for a server. */
getServerLogs(name: string): string {
const server = this.servers.get(name);
if (!server) return `No server named "${name}"`;
if (server.stderrLog.length === 0) return `(no stderr output from ${name})`;
return server.stderrLog.join("\n");
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/**
* Start a server and connect to it.
* cwd is passed to roots/list — the workspace root exposed to the MCP server.
*/
async startServer(name: string, cwd: string): Promise<void> {
const server = this.servers.get(name);
if (!server) {
throw new McpError(`Unknown server "${name}"`, name, "config");
}
this._updateServerCwd(server, cwd);
if (server.attempt && !server.controller?.signal.aborted) return server.attempt;
if (server.state === "ready") return;
server.retryCount = 0;
const controller = new AbortController();
server.controller = controller;
this._setState(server, "starting");
const attempt = this._connect(server, cwd, controller.signal);
server.attempt = attempt;
try {
await attempt;
} finally {
if (server.attempt === attempt) {
server.attempt = undefined;
}
if (server.controller === controller && (server.state as ServerState) !== "ready") {
server.controller = undefined;
}
}
}
async stopServer(name: string): Promise<void> {
const server = this.servers.get(name);
if (!server) return;
await this._shutdown(server);
}
async shutdownAll(): Promise<void> {
await Promise.allSettled(
Array.from(this.servers.values()).map((s) => this._shutdown(s)),
);
}
/** Add or replace a stopped server entry in the manager. */
setServer(name: string, serverConfig: ServerConfig): void {
this.servers.set(name, {
name,
config: serverConfig,
state: "stopped",
client: null,
childPid: null,
retryCount: 0,
lastError: null,
stderrLog: [],
healthCheckTimer: null,
retryTimer: null,
stateSince: Date.now(),
});
}
/** Shut down and remove a server entry from the manager. */
async removeServer(name: string): Promise<void> {
const server = this.servers.get(name);
if (!server) return;
await this._shutdown(server);
// WHY: _shutdown awaits child-process teardown, and the registration reconcile installs a
// replacement right after removing the changed entry — it can land in that window. Deleting
// by name alone would drop that live entry and orphan its stdio child, which nothing can
// then terminate.
if (this.servers.get(name) === server) {
this.servers.delete(name);
}
}
/**
* Rebuild the server map from a new config.
* Must only be called after shutdownAll() — old servers are discarded.
* New servers that didn't exist before are added; servers removed from
* config are dropped (their tools should already be deactivated).
*/
rebuildServers(config: McpConfig): void {
this.settings = config.settings;
this.servers.clear();
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
this.setServer(name, serverConfig);
}
}
/** Alias for rebuildServers when replacing configuration on session_start. */
replaceConfig(config: McpConfig): void {
this.rebuildServers(config);
}
// ─── Internal ───────────────────────────────────────────────────────────────
private _appendStderr(server: ManagedServer, line: string): void {
// WHY: this buffer is what `/mcp <name>` prints as "Recent output" and what
// `getServerLogs` returns — text users paste into bug reports. A child server that echoes
// its own key on stderr must not surface it there, so mask credentials exactly as the
// debug log does. Redacting twice is a no-op.
server.stderrLog.push(redactSecrets(line));
if (server.stderrLog.length > STDERR_BUFFER_SIZE) {
server.stderrLog.shift();
}
}
private _createAndInstrumentTransport(server: ManagedServer, cwd: string): Transport {
let transport: Transport;
try {
transport = this.createTransport(server.name, server.config, cwd);
} catch (err) {
this._setState(server, "stopped", "transport");
server.lastError = err instanceof Error ? err : new Error(String(err));
throw new McpError(
`Failed to create transport: ${server.lastError.message}`,
server.name,
"connection",
err,
);
}
if (transport instanceof StdioClientTransport) {
instrumentStdioTransport(transport, {
onPid: (pid) => {
server.childPid = pid;
},
onStderrLine: (rawLine) => {
const clean = sanitizeStderrLine(rawLine);
if (!clean) return;
this._appendStderr(server, clean);
reportDiagnostic({
level: "info",
event: "server.stderr",
server: server.name,
message: clean,
// WHY: child-server stderr historically went to stderr; keep it
// visible in headless runs, where there is no /mcp ring buffer UI.
stderr: true,
});
},
});
}
return transport;
}
private _createClient(
server: ManagedServer,
cwd: string,
signal: AbortSignal,
): Client {
return createMcpClient({
serverName: server.name,
// WHY: read `server.cwd` lazily: `updateCwd()` can move the workspace root while the
// handshake is in flight, and roots/list must answer with the current one.
getCwd: () => server.cwd ?? cwd,
onToolsChanged: (client) => this._handleToolListChanged(server, client, signal),
onResourcesChanged: (client) => this._handleResourceListChanged(server, client),
onLogLine: (entry) => this._recordServerLog(server, entry),
});
}
/**
* Record one `notifications/message` entry from a server.
*
* WHY: server log messages historically went to stderr; keep them visible in headless runs,
* where there is no /mcp ring buffer UI, and store the sanitized line for `/mcp <name>`.
*/
private _recordServerLog(server: ManagedServer, entry: FormattedServerLog): void {
reportDiagnostic({
level: "info",
event: "server.log",
server: server.name,
message: entry.formatted,
fields: { logLevel: entry.safeLevel, logger: entry.safeLogger },
stderr: true,
});
this._appendStderr(server, entry.formatted);
}
/**
* Re-discover tools after a `notifications/tools/list_changed` from a ready server.
*
* Extracted from the notification handler so the handler registration stays a single
* guarded call.
*/
private async _handleToolListChanged(
server: ManagedServer,
client: Client,
signal: AbortSignal,
): Promise<void> {
const onToolRefresh = this.onToolRefresh;
if (signal.aborted || !onToolRefresh || server.state !== "ready" || server.client !== client) {
return;
}
try {
const refreshTimeoutMs = this.resolveRequestTimeoutMs(server);
await onToolRefresh(server.name, server.client, {
requestTimeoutMs: refreshTimeoutMs,
signal,
});
} catch (err) {
const { kind, message } = describeError(err);
// WHY: Two failures here are already accounted for and must not toast: a cancelled
// refresh (/mcp:stop, session teardown) is expected, and a 401/403 was just reported
// by handleUnauthorizedServerError as a re-authorization warning plus the footer hint
// — a second "Failed to refresh tools" error would read as an unrelated problem.
const alreadyReported =
err instanceof AuthCancelledError ||
signal.aborted ||
server.lastError instanceof AuthRequiredError;
reportDiagnostic({
level: alreadyReported ? "info" : "error",
event: "server.tools_refresh_failed",
server: server.name,
message: `Failed to refresh tools for ${server.name}: ${message}`,
fields: { kind },
notify: !alreadyReported,
});
}
}
/**
* Invalidate a server's cached resource catalog after a
* `notifications/resources/list_changed`.
*
* Only the current client of a ready server may invalidate: a notification from a
* superseded client (reconnect race) or from a server that is being torn down must not
* touch the cache the live connection owns.
*/
private _handleResourceListChanged(server: ManagedServer, client: Client): void {
if (server.state !== "ready" || server.client !== client) return;
this.onResourceListChanged?.(server.name);
}
private async _handshakeAndDiscover(params: HandshakeParams): Promise<void> {
const { server, client, transport, timeoutMs, signal, currentCwd } = params;
await connectClientWithTimeout({
client,
transport,
timeoutMs,
signal,
serverName: server.name,
});
if (signal.aborted) throw new AuthCancelledError(server.name);
if (transport instanceof StdioClientTransport && !server.childPid) {
server.childPid = transport.pid;
}
// Readiness includes tool discovery, not just the MCP handshake.
if (this.onToolRefresh) {
await this.onToolRefresh(server.name, client, {
requestTimeoutMs: timeoutMs,
signal,
});
}
if (signal.aborted) throw new AuthCancelledError(server.name);
this._setState(server, "ready");
server.retryCount = 0;
server.lastError = null;
// WHY: Fire only after the server is observably ready with a cleared lastError, so a
// listener that repaints session state never sees a half-transitioned server.
this.onServerReady?.(server.name);
if (server.cwd && server.cwd !== currentCwd) {
void client.sendRootsListChanged().catch(() => {
// WHY: Best-effort notification if cwd was updated while handshake was in flight.
});
}
}
private _startHealthCheck(
server: ManagedServer,
timeoutMs: number,
signal: AbortSignal,
cwd: string,
): void {
server.healthCheckTimer = startHealthCheck({
serverName: server.name,
intervalMs: server.config.healthCheckIntervalMs ?? 0,
timeoutMs,
signal,
getClient: () => server.client,
isReady: () => server.state === "ready",
// WHY: Ping failure triggers background recovery if the server is still ready.
// The ping error is intentionally not kept on lastError because _recover() →
// _shutdown() synchronously resets lastError during teardown before any reader
// observes it; if reconnection fails, _recover() records the failure there.
onPingFailure: () => {
void this._recover(server, server.cwd ?? cwd);
},
});
}
// WHY: SSEClientTransport and StreamableHTTPClientTransport report unexpected
// stream drops via transport.onerror (forwarded to client.onerror) rather than
// transport.onclose. Crucially, StreamableHTTPClientTransport.send() invokes
// this.onerror(error) synchronously BEFORE `throw error` rejects the send()
// promise; deferring _recover() via setImmediate lets Protocol.request() and
// ToolBridge.execute() observe the original transport/401 error (and run
// handleUnauthorizedServerError if applicable) before client.close() rejects
// pending requests with "Connection closed".
private _scheduleErrorRecovery(
server: ManagedServer,
client: Client,
signal: AbortSignal,
cwd: string,
): void {
const errorGeneration = server.generation ?? 0;
setImmediate(() => {
if (
(server.generation ?? 0) !== errorGeneration ||
server.state !== "ready" ||
signal.aborted ||
server.client !== client
) {
return;
}
void this._recover(server, server.cwd ?? cwd);
});
}
private _handleClientError(
server: ManagedServer,
client: Client,
signal: AbortSignal,
cwd: string,
err: unknown,
): void {
if (server.state !== "ready" || signal.aborted || server.client !== client) {
return;
}
const action = classifyClientError(server.config.transport, err);
if (action.kind === "ignore") {
this._reportIgnoredClientError(server, action, err);
return;
}
// WHY: deferred recovery — see the note on _scheduleErrorRecovery.
reportDiagnostic({
level: "warn",
event: action.event,
server: server.name,
message: `Server ${server.name} ${action.reason}: ${clientErrorMessage(err)}`,
fields: { transport: action.transport },
});
this._scheduleErrorRecovery(server, client, signal, cwd);
}
/**
* Log the one ignorable client error that is worth a line: a server notification whose
* handler threw (`notifications/progress` with malformed parameters, typically).
*/
private _reportIgnoredClientError(
server: ManagedServer,
action: Extract<ClientErrorAction, { kind: "ignore" }>,
err: unknown,
): void {
if (action.diagnostic !== "server.notification_error") return;
reportDiagnostic({
level: "info",
event: "server.notification_error",
server: server.name,
message: clientErrorMessage(err),
fields: { transport: server.config.transport },
});
}
private _wireDisconnectRecovery(
server: ManagedServer,
client: Client,
signal: AbortSignal,
cwd: string,
): void {
// Unexpected transport close (e.g. the server subprocess crashes
// mid-session). Without this the state would stay "ready" and tools
// would remain active against a dead server. Deliberate closes abort
// the signal first, so they are ignored here.
client.onclose = () => {
if (signal.aborted || server.client !== client) return;
reportDiagnostic({
level: "warn",
event: "server.disconnected",
server: server.name,
message: `Server ${server.name} closed unexpectedly, reconnecting`,
fields: { transport: server.config.transport },
});
void this._recover(server, server.cwd ?? cwd);
};
client.onerror = (err) => {
this._handleClientError(server, client, signal, cwd, err);
};
}
private async _cleanupFailedConnect(
server: ManagedServer,
client: Client,
transport: Transport,
): Promise<void> {
const pid = resolveTransportPid(server.childPid, transport);
server.childPid = null;
await Promise.allSettled([
client.close(),
terminateStdioTree(pid, transport),
]);
// WHY: Secondary safety check to guarantee PID termination even if transport.close()
// or child process exit raced during the initial Promise.allSettled.
await ensurePidTerminated(pid);
if (server.client === client) {
server.client = null;
server.transport = undefined;
}
}
private async _waitRetry(
server: ManagedServer,
signal: AbortSignal,
delayMs: number,
): Promise<void> {
await new Promise<void>((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const onAbort = () => {
if (server.retryTimer) {
clearTimeout(server.retryTimer);
server.retryTimer = null;
}
resolve();
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
server.retryTimer = null;
resolve();
}, delayMs);
server.retryTimer = timer;
signal.addEventListener("abort", onAbort, { once: true });
});
}
private async _connect(
server: ManagedServer,
cwd: string,
signal: AbortSignal,
corruptStateRecovered = false,
): Promise<void> {
server.lastError = null;
const currentCwd = server.cwd ?? cwd;
server.cwd = currentCwd;
const timeoutMs = this.resolveRequestTimeoutMs(server);
// Note: retryCount is NOT reset here — it's only reset after successful connect.
// This ensures the retry limit is respected across reconnection attempts.
// It IS reset when startServer() is called explicitly (e.g. /mcp:start)
// via the caller having already set retryCount = 0 before calling _connect.
const transport = this._createAndInstrumentTransport(server, currentCwd);
server.transport = transport;
const client = this._createClient(server, cwd, signal);
server.client = client;
try {
await this._handshakeAndDiscover({ server, client, transport, timeoutMs, signal, currentCwd });
this._startHealthCheck(server, timeoutMs, signal, cwd);
this._wireDisconnectRecovery(server, client, signal, cwd);
} catch (err) {
await this._handleConnectFailure({
server,
client,
transport,
cwd,
signal,
corruptStateRecovered,
err,
});
}
}
private async _handleConnectFailure(context: ConnectFailureContext): Promise<void> {
const { server, client, transport, signal, corruptStateRecovered, err } = context;
await this._cleanupFailedConnect(server, client, transport);
const action = classifyConnectFailure({
error: err,
aborted: signal.aborted,
corruptStateRecovered,
hasAuth: Boolean(server.config.auth),
transport: server.config.transport,
retryCount: server.retryCount,
maxRetries: this.settings.maxRetries,
serverName: server.name,
});
this._reportConnectFailure(server, action.kind, err);
return this._applyConnectFailureAction(context, action);
}
private _reportConnectFailure(
server: ManagedServer,
outcome: ConnectFailureAction["kind"],
err: unknown,
): void {
const { kind: errorKind, message: errorMessage } = describeError(err);
reportDiagnostic({
level: outcome === "cancelled" ? "info" : "warn",
event: "server.connect_failure",
server: server.name,
message: `Connection attempt for ${server.name} failed: ${errorMessage}`,
fields: { outcome, kind: errorKind, attempt: server.retryCount },
});
}
private async _applyConnectFailureAction(
context: ConnectFailureContext,
action: ConnectFailureAction,
): Promise<void> {
const { server, cwd, signal, corruptStateRecovered, err } = context;
const error = err instanceof Error ? err : new Error(String(err));
switch (action.kind) {
case "cancelled":
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(server.name);
case "retry-corrupt-state":
return this._retryCorruptState(server, cwd, signal, err);
case "auth-required":
return this._raiseAuthRequired(server, err, action.invalidateTokens);
case "fail":
server.lastError = action.error;
this._setState(server, "stopped", "failed");
throw action.error;
case "give-up":
server.lastError = error;
this._setState(server, "stopped", "give-up");
throw action.error;
case "retry":
server.lastError = error;
return this._scheduleReconnect(server, cwd, signal, corruptStateRecovered, action.delayMs);
}
}
private _retryCorruptState(
server: ManagedServer,
cwd: string,
signal: AbortSignal,
err: unknown,
): Promise<void> {
const { kind: errorKind, message: errorMessage } = describeError(err);
reportDiagnostic({
level: "warn",
event: "oauth.state_corrupt",
server: server.name,
message: errorMessage,
fields: { kind: errorKind },
});
if (signal.aborted) throw new AuthCancelledError(server.name);
return this._connect(server, server.cwd ?? cwd, signal, true);
}
private async _raiseAuthRequired(
server: ManagedServer,
err: unknown,
invalidateTokens: boolean,
): Promise<never> {
server.lastError = err instanceof Error ? err : new Error(String(err));
if (invalidateTokens) {
await invalidateStoredTokens(server.name, this.storageDir, server.config.url);
}
const authError = err instanceof AuthRequiredError ? err : new AuthRequiredError(server.name);
server.lastError = authError;
this._setState(server, "stopped", "auth-required");
throw authError;
}
private async _scheduleReconnect(
server: ManagedServer,
cwd: string,
signal: AbortSignal,
corruptStateRecovered: boolean,
delayMs: number,
): Promise<void> {
server.retryCount++;
reportDiagnostic({
level: "warn",
event: "server.retry",
server: server.name,
message: `Retrying ${server.name} in ${delayMs}ms (attempt ${server.retryCount})`,
fields: { attempt: server.retryCount, delayMs },
});
await this._waitRetry(server, signal, delayMs);
if (signal.aborted) throw new AuthCancelledError(server.name);
return this._connect(server, server.cwd ?? cwd, signal, corruptStateRecovered);
}
/** Tear down a server that died or stopped responding, then reconnect. */
private async _recover(server: ManagedServer, cwd: string): Promise<void> {
const recoveryGeneration = (server.generation ?? 0) + 1;
await this._shutdown(server);
if (server.generation === recoveryGeneration) {
// An external stop during cleanup increments the generation again.
queueMicrotask(() => {
if (server.generation !== recoveryGeneration) return;
void this.startServer(server.name, cwd).catch((err) => {
if (err instanceof AuthCancelledError) return;
const error = err instanceof Error ? err : new Error(String(err));
server.lastError = error;
const { kind, message } = describeError(err);
reportDiagnostic({
level: "error",
event: "server.recovery_failed",
server: server.name,
message: `Background recovery failed for ${server.name}: ${message}`,
fields: { kind },
});
this.onRecoveryFailed?.(server.name, error);
});
});
}
}
private async _shutdown(server: ManagedServer): Promise<void> {
server.controller?.abort();
server.controller = undefined;
server.attempt = undefined;
server.generation = (server.generation ?? 0) + 1;
// Cancel pending retry
if (server.retryTimer) {
clearTimeout(server.retryTimer);
server.retryTimer = null;
}
// Stop health check
if (server.healthCheckTimer) {
clearInterval(server.healthCheckTimer);
server.healthCheckTimer = null;
}
this._setState(server, "stopped", "shutdown");
server.lastError = null;
this.onServerStopped?.(server.name);
const client = server.client;
// WHY: shared with _cleanupFailedConnect so a PID only visible through the transport
// (childPid already cleared, `_process` gone after close) is still terminated.
const pid = resolveTransportPid(server.childPid, server.transport);
const transport = server.transport;
server.client = null;
server.transport = undefined;
server.childPid = null;
await Promise.allSettled([
client?.close(),
terminateStdioTree(pid, transport),
]);
await ensurePidTerminated(pid);
}
}