feat(connectd): companion sidecar lifecycle (connectd-child, v1)

Adds optional ServerConfig.companion {command,args,env,healthUrl}. On stdio connect, ensureCompanion
spawns the sidecar (e.g. the WhatsApp bridge) if healthUrl isn't already serving, polls health (15s),
then spawns the MCP server that talks to it. Killed on closeServer + SIGINT/SIGTERM (disposeCompanions)
so it never orphans. Reuses an already-running bridge (idempotent). Typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tim Lingo
2026-06-27 14:27:28 -05:00
parent fed166202e
commit edf276c51e
3 changed files with 79 additions and 0 deletions
+61
View File
@@ -4,6 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { createHash } from "node:crypto";
import { join, dirname } from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import type { ConnectorsConfig, ServerConfig } from "./config.js";
import { saveConfig } from "./config.js";
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
@@ -91,6 +92,9 @@ interface ServerEntry {
export class ConnectorBridge {
private servers = new Map<string, ServerEntry>();
// Companion sidecar processes (e.g. the WhatsApp bridge), keyed by connector id. Started on
// connect, killed on disconnect/shutdown. v1 lifecycle = child-of-connectd (dies with the app).
private companions = new Map<string, ChildProcess>();
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
private config: ConnectorsConfig = { servers: {} };
@@ -166,6 +170,49 @@ export class ConnectorBridge {
if (entry?.client) {
try { await entry.client.close(); } catch { /* best effort */ }
}
const comp = this.companions.get(id);
if (comp) {
try { comp.kill(); } catch { /* best effort */ }
this.companions.delete(id);
}
}
// Ensure a connector's companion sidecar is running before we spawn its MCP server.
// If healthUrl already responds, something is serving (reuse it). Otherwise spawn the companion
// as a child and poll healthUrl until it's up (or time out). Throws if it never becomes healthy.
private async ensureCompanion(id: string, comp: NonNullable<ServerConfig["companion"]>): Promise<void> {
const ping = async (): Promise<boolean> => {
if (!comp.healthUrl) return false;
try {
const r = await fetch(comp.healthUrl, { signal: AbortSignal.timeout(1500) });
return r.ok || r.status === 401; // 401 = up but unauthorized (still "running")
} catch { return false; }
};
if (await ping()) return; // already running (this session or a prior one)
if (this.companions.has(id)) return; // we already started it
const child = spawn(
resolveRuntimeCommand(comp.command),
(comp.args ?? []).map(resolveBundledArg),
{ env: { ...process.env, ...(comp.env ?? {}) } as Record<string, string>, stdio: "ignore" },
);
this.companions.set(id, child);
child.on("exit", () => this.companions.delete(id));
if (!comp.healthUrl) return; // no health probe configured — best-effort start
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 400));
if (await ping()) return;
}
throw new Error(`companion for "${id}" did not become healthy within 15s`);
}
// Kill every companion sidecar — called on connectd shutdown so we never orphan a child
// process (e.g. the WhatsApp bridge) when the app/daemon exits.
disposeCompanions(): void {
for (const [id, child] of this.companions) {
try { child.kill(); } catch { /* best effort */ }
this.companions.delete(id);
}
}
private async connectServer(id: string, cfg: ServerConfig): Promise<void> {
@@ -204,6 +251,20 @@ export class ConnectorBridge {
// not a code change — we set the common names so swapping servers stays config-only.
extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token };
}
// Start the companion sidecar (e.g. the WhatsApp bridge) before the MCP server that talks to it.
if (cfg.companion) {
try {
await this.ensureCompanion(id, cfg.companion);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.servers.set(id, {
id, cfg, status: "needs_setup", tools: [],
error: "Couldn't start the connector's helper service — try toggling it off and on.",
});
console.error(`[connectd] ${id}: companion failed (${msg}) — needs_setup`);
return;
}
}
try {
const transport = new StdioClientTransport({
command: resolveRuntimeCommand(cfg.command!),
+10
View File
@@ -11,6 +11,16 @@ export interface ServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
// Companion sidecar: a long-running process this connector's MCP server talks to (e.g. the
// WhatsApp bridge on :8080 that holds the paired session). connectd starts it as a CHILD when the
// connector connects, waits for healthUrl to respond, then spawns the MCP server; it is killed on
// disconnect/shutdown. `command` supports @bin//@node//@bundled resolution like the server itself.
companion?: {
command: string;
args?: string[];
env?: Record<string, string>;
healthUrl?: string;
};
// http (Phase 3)
url?: string;
auth?: "none" | "oauth" | "token" | "google";
+8
View File
@@ -16,6 +16,14 @@ async function main(): Promise<void> {
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
startServer(bridge);
// Clean shutdown: kill companion sidecars (e.g. the WhatsApp bridge) so they don't orphan.
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => {
bridge.disposeCompanions();
process.exit(0);
});
}
}
main().catch((err) => {