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:
@@ -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!),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user