diff --git a/src/bridge.ts b/src/bridge.ts index 3c1ced3..2964d5c 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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(); + // 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(); // 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): Promise { + const ping = async (): Promise => { + 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, 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 { @@ -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!), diff --git a/src/config.ts b/src/config.ts index fe2f39c..c8817c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,16 @@ export interface ServerConfig { command?: string; args?: string[]; env?: Record; + // 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; + healthUrl?: string; + }; // http (Phase 3) url?: string; auth?: "none" | "oauth" | "token" | "google"; diff --git a/src/index.ts b/src/index.ts index a8ea66b..cd76574 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,14 @@ async function main(): Promise { 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) => {