|
|
|
@@ -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!),
|
|
|
|
|