Disconnects and reconnects a VPN profile by name. Use when a VPN connection is stale, showing connected but not routing traffic, or after network changes.
/** * mcp/skills/reconnectVpn.ts — reconnect_vpn skill * * Disconnects and reconnects a VPN profile by name. Use when a VPN connection * is stale, showing connected but not routing traffic, or after network changes. * * Platform strategy * ----------------- * darwin `scutil --nc stop` then `scutil --nc start` for the named profile * win32 PowerShell Disconnect-VpnConnection then Connect-VpnConnection * * Smoke test * npx tsx -r dotenv/config mcp/skills/reconnectVpn.ts */import * as os from "os";import { exec } from "child_process";import { promisify } from "util";import { z } from "zod";import { detectVendorForProfile, WIN32_VPN_VENDOR_PROCS, type VpnVendor } from "./_shared/vpnProfiles";const execAsync = promisify(exec);// -- Meta ---------------------------------------------------------------------export const meta = { name: "reconnect_vpn", description: "Disconnects and reconnects a VPN profile by name. " + "Use when a VPN connection is stale, showing connected but not routing traffic, " + "or after network changes.", riskLevel: "medium", // destructive: dropping the tunnel interrupts active sessions and, on a // full-tunnel VPN, briefly cuts the device off the network. Marked true so // G4 auto-triggers the dry-run preview (supportsDryRun + destructive) — a // medium/non-destructive tool would skip the preview and only show consent. destructive: true, requiresConsent: true, supportsDryRun: true, affectedScope: ["network"], auditRequired: true, // `scutil --nc start` is fire-and-forget; the darwin path polls for the // tunnel to actually settle to Connected (up to ~25 s) instead of reporting // the transient "Connecting" as success. Raise the ceiling above the 60 s // default headroom so the disconnect + pause + start + poll chain never races // the G4 deadline. timeoutMs: 90_000, schema: { profileName: z .string() .describe("VPN profile name to reconnect (from get_vpn_profiles)"), dryRun: z .boolean() .optional() .describe("If true, show what would happen without reconnecting. Default: true"), },} as const;// -- Types --------------------------------------------------------------------interface ReconnectVpnResult { profileName: string; disconnected: boolean; reconnected: boolean; dryRun: boolean; newStatus: string | null; /** Set when the profile is a vendor-managed VPN (AnyConnect / GlobalProtect) * that scutil cannot drive — the corrective can't reconnect it and the user * must use the vendor client. Distinct from a genuinely missing profile. */ vendorManaged?: VpnVendor; message?: string;}// -- PowerShell helper --------------------------------------------------------async function runPS(script: string): Promise<string> { const encoded = Buffer.from(script, "utf16le").toString("base64"); const { stdout } = await execAsync( `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`, { maxBuffer: 20 * 1024 * 1024, timeout: 30_000 }, ); return stdout.trim();}// -- darwin status helper -----------------------------------------------------/** * Read the current connection state of a native (scutil) profile from * `scutil --nc list`. Lines look like: * `* (Connected) <UUID> ... "ProfileName" [type]` * ` (Disconnected) <UUID> ... "ProfileName" [type]` * Returns the state word ("Connected" / "Connecting" / "Disconnected" / * "Disconnecting" / "Invalid") or null if the profile/line isn't found. */async function readNativeStatus(profileName: string): Promise<string | null> { try { const { stdout } = await execAsync("scutil --nc list 2>/dev/null", { maxBuffer: 5 * 1024 * 1024, timeout: 5_000, }); for (const line of stdout.split("\n")) { if (line.includes(`"${profileName}"`)) { return line.match(/\((\w+)\)/)?.[1] ?? null; } } } catch { /* ignore */ } return null;}// -- darwin implementation ----------------------------------------------------async function reconnectVpnDarwin( profileName: string, dryRun: boolean,): Promise<ReconnectVpnResult> { // Verify profile exists let profileExists = false; try { const { stdout } = await execAsync("scutil --nc list 2>/dev/null", { maxBuffer: 5 * 1024 * 1024, timeout: 5_000, }); profileExists = stdout.includes(`"${profileName}"`); } catch { /* ignore */ } if (!profileExists) { // Not a native (scutil) profile. Before declaring it missing, check whether // it's a vendor-managed profile that get_vpn_profiles legitimately surfaced — // scutil can't list OR drive AnyConnect / GlobalProtect, so reconnecting must // happen via the vendor client. Return accurate guidance instead of a // misleading "Profile not found". const vendor = await detectVendorForProfile(profileName); if (vendor) { return { profileName, disconnected: false, reconnected: false, dryRun, newStatus: "vendor-managed — not reconnected", vendorManaged: vendor, message: `"${profileName}" is a ${vendor} VPN managed by its own client; ` + `macOS scutil cannot reconnect it. Quit and relaunch the ${vendor} ` + `app (or use its menu-bar Connect) to re-establish the tunnel.`, }; } throw new Error( `[reconnect_vpn] Profile not found: "${profileName}". ` + "Use get_vpn_profiles to list available profiles.", ); } if (dryRun) { return { profileName, disconnected: false, reconnected: false, dryRun: true, newStatus: "DryRun — no changes made", }; } const safeName = profileName.replace(/"/g, '\\"'); let disconnected = false; let reconnected = false; // Disconnect try { await execAsync(`scutil --nc stop "${safeName}" 2>/dev/null`, { maxBuffer: 1 * 1024 * 1024, timeout: 15_000, }); disconnected = true; } catch { /* may not be connected */ } // Brief pause to allow teardown await new Promise((res) => setTimeout(res, 2000)); // Reconnect. NOTE: `scutil --nc start` is fire-and-forget — it accepts the // request and returns immediately, BEFORE the tunnel is up. A status read here // almost always catches the transient "Connecting" state, so we must NOT treat // a successful start as a successful reconnect. try { await execAsync(`scutil --nc start "${safeName}" 2>/dev/null`, { maxBuffer: 1 * 1024 * 1024, timeout: 30_000, }); } catch (err) { throw new Error( `[reconnect_vpn] Failed to start profile "${profileName}": ${(err as Error).message}`, ); } // Poll until the connection settles to Connected (real success), a terminal // failure state, or the deadline elapses (still Connecting → stuck). This is // what makes "reconnected" mean the tunnel is actually up rather than merely // "start was accepted" — and stops the skill from advancing to DNS-flush / // routing-verification on a half-established tunnel. const DEADLINE_MS = 25_000; const POLL_MS = 1_500; const startedAt = Date.now(); let newStatus = await readNativeStatus(profileName); while (Date.now() - startedAt < DEADLINE_MS) { if (newStatus === "Connected") break; // success if (newStatus === "Disconnected" || newStatus === "Invalid") break; // terminal failure await new Promise((res) => setTimeout(res, POLL_MS)); newStatus = await readNativeStatus(profileName); } reconnected = newStatus === "Connected"; const waited = Math.round((Date.now() - startedAt) / 1000); const message = reconnected ? `VPN profile "${profileName}" reconnected — status: Connected.` : newStatus === "Connecting" || newStatus === null ? `VPN profile "${profileName}" is still establishing the tunnel (status: ${newStatus ?? "unknown"}) after ${waited}s. ` + "It may be waiting on credentials/MFA, a vendor app or system extension, or an unresponsive server. " + "Check your VPN client's menu-bar icon and complete any sign-in, or try again." : `VPN profile "${profileName}" did not connect — status: ${newStatus}. ` + "Toggle Disconnect → Connect from the VPN menu-bar icon, or escalate to IT if it persists."; return { profileName, disconnected, reconnected, dryRun: false, newStatus, message };}// -- win32 vendor detection ---------------------------------------------------/** * Checks whether a running process matches any known Windows VPN vendor client. * Returns the vendor label for the first match, or null if none is found. * Used by reconnect_vpn to distinguish "vendor-managed" from "genuinely missing" * when Get-VpnConnection doesn't recognise the profile name. */async function detectVendorForProfileWin32(profileName: string): Promise<VpnVendor | null> { // First try a name-based match: if the profile name contains the vendor name // (e.g. "ProtonVPN Free" → ProtonVPN), trust that before spawning PS. const lower = profileName.toLowerCase(); for (const { label } of WIN32_VPN_VENDOR_PROCS) { if (lower.includes(label.toLowerCase().split(" ")[0].toLowerCase())) { // Confirm the client is actually installed/running before declaring it vendor-managed. const procEntry = WIN32_VPN_VENDOR_PROCS.find((e) => e.label === label); if (!procEntry) continue; const safeName = procEntry.proc.replace(/'/g, "''"); try { const out = await runPS( `if (Get-Process -Name '${safeName}' -ErrorAction SilentlyContinue) { 'running' } else { 'notfound' }`, ); if (out.trim() === "running") return label; } catch { /* ignore */ } } } // Fallback: scan all known vendor processes; return the first running one. // Covers cases where the profile name doesn't contain the vendor name. const procListPs = WIN32_VPN_VENDOR_PROCS .map((e) => `[PSCustomObject]@{proc='${e.proc}';label='${e.label}'}`) .join(",\n "); const ps = `$ErrorActionPreference = 'SilentlyContinue'$map = @(${procListPs})foreach ($e in $map) { if (Get-Process -Name $e.proc -ErrorAction SilentlyContinue) { $e.label; break }}`.trim(); try { const out = await runPS(ps); const found = out.trim(); if (found) return found as VpnVendor; } catch { /* ignore */ } return null;}// -- win32 implementation -----------------------------------------------------async function reconnectVpnWin32( profileName: string, dryRun: boolean,): Promise<ReconnectVpnResult> { const safeName = profileName.replace(/'/g, "''"); // Verify profile exists in Windows RAS const checkPs = `$ErrorActionPreference = 'SilentlyContinue'$c = Get-VpnConnection -Name '${safeName}' -ErrorAction SilentlyContinueif (-not $c) { $c = Get-VpnConnection -AllUserConnection -Name '${safeName}' -ErrorAction SilentlyContinue }if ($c) { 'found' } else { 'notfound' }`.trim(); const checkResult = await runPS(checkPs); if (checkResult !== "found") { // Not a native RAS profile. Check whether it's a vendor-managed VPN // (ProtonVPN, NordVPN, etc.) — return accurate guidance instead of a // misleading "Profile not found" (mirrors the darwin detectVendorForProfile path). const vendor = await detectVendorForProfileWin32(profileName); if (vendor) { return { profileName, disconnected: false, reconnected: false, dryRun, newStatus: "vendor-managed — not reconnected", vendorManaged: vendor, message: `"${profileName}" is managed by ${vendor}, which uses its own tunnel driver ` + `(WireGuard/OpenVPN) that Windows cannot reconnect via the built-in VPN stack. ` + `Open the ${vendor} app in the system tray, disconnect, wait 5 seconds, ` + `then click Connect.`, }; } throw new Error( `[reconnect_vpn] Profile not found: "${profileName}". ` + "Use get_vpn_profiles to list available profiles.", ); } if (dryRun) { return { profileName, disconnected: false, reconnected: false, dryRun: true, newStatus: "DryRun — no changes made", }; } const ps = `$ErrorActionPreference = 'SilentlyContinue'try { Disconnect-VpnConnection -Name '${safeName}' -Force -ErrorAction SilentlyContinue } catch {}Start-Sleep -Seconds 2$connected = $falsetry { rasdial '${safeName}' | Out-Null $connected = $true} catch {}$status = $null$c = Get-VpnConnection -Name '${safeName}' -ErrorAction SilentlyContinueif ($c) { $status = $c.ConnectionStatus }[PSCustomObject]@{ reconnected = $connected; status = $status } | ConvertTo-Json -Compress`.trim(); const raw = await runPS(ps); let parsed: { reconnected: boolean; status: string | null } = { reconnected: false, status: null, }; try { parsed = JSON.parse(raw); } catch { /* ignore */ } return { profileName, disconnected: true, reconnected: parsed.reconnected, dryRun: false, newStatus: parsed.status, };}// -- Exported run function ----------------------------------------------------export async function run({ profileName, dryRun = true,}: { profileName: string; dryRun?: boolean;}): Promise<ReconnectVpnResult> { if (!profileName || profileName.trim() === "") { throw new Error("[reconnect_vpn] profileName is required."); } const platform = os.platform(); return platform === "win32" ? reconnectVpnWin32(profileName, dryRun) : reconnectVpnDarwin(profileName, dryRun);}// -- Smoke test ---------------------------------------------------------------if (false) { run({} as { profileName: string }) .then(r => console.log(JSON.stringify(r, null, 2))) .catch((err: Error) => { console.error(err.message); process.exit(1); });}