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 { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import type { ConnectorsConfig, ServerConfig } from "./config.js";
|
import type { ConnectorsConfig, ServerConfig } from "./config.js";
|
||||||
import { saveConfig } from "./config.js";
|
import { saveConfig } from "./config.js";
|
||||||
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
||||||
@@ -91,6 +92,9 @@ interface ServerEntry {
|
|||||||
|
|
||||||
export class ConnectorBridge {
|
export class ConnectorBridge {
|
||||||
private servers = new Map<string, ServerEntry>();
|
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.
|
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
|
||||||
private config: ConnectorsConfig = { servers: {} };
|
private config: ConnectorsConfig = { servers: {} };
|
||||||
|
|
||||||
@@ -166,6 +170,49 @@ export class ConnectorBridge {
|
|||||||
if (entry?.client) {
|
if (entry?.client) {
|
||||||
try { await entry.client.close(); } catch { /* best effort */ }
|
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> {
|
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.
|
// 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 };
|
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 {
|
try {
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: resolveRuntimeCommand(cfg.command!),
|
command: resolveRuntimeCommand(cfg.command!),
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export interface ServerConfig {
|
|||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, 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)
|
// http (Phase 3)
|
||||||
url?: string;
|
url?: string;
|
||||||
auth?: "none" | "oauth" | "token" | "google";
|
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)`);
|
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
|
||||||
|
|
||||||
startServer(bridge);
|
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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user