/**
* 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" ;
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: false ,
requiresConsent: false ,
supportsDryRun: true ,
affectedScope: [ "network" , "system" ],
auditRequired: true ,
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 ;
}
// -- 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 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) {
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
try {
await execAsync ( `scutil --nc start "${ safeName }" 2>/dev/null` , {
maxBuffer: 1 * 1024 * 1024 ,
timeout: 30_000 ,
});
reconnected = true ;
} catch (err) {
throw new Error (
`[reconnect_vpn] Failed to start profile "${ profileName }": ${ ( err as Error ). message }` ,
);
}
// Check new status
let newStatus : string | null = null ;
try {
const { stdout } = await execAsync ( "scutil --nc list 2>/dev/null" , {
maxBuffer: 5 * 1024 * 1024 ,
timeout: 5_000 ,
});
const lines = stdout. split ( " \n " );
for ( const line of lines) {
if (line. includes ( `"${ profileName }"` )) {
const match = line. match ( / \( ( \w + ) \) / );
if (match) newStatus = match[ 1 ];
break ;
}
}
} catch { /* ignore */ }
return { profileName, disconnected, reconnected, dryRun: false , newStatus };
}
// -- win32 implementation -----------------------------------------------------
async function reconnectVpnWin32 (
profileName : string ,
dryRun : boolean ,
) : Promise < ReconnectVpnResult > {
const safeName = profileName. replace ( / ' / g , "''" );
// Verify profile exists
const checkPs = `
$ErrorActionPreference = 'SilentlyContinue'
$c = Get-VpnConnection -Name '${ safeName }' -ErrorAction SilentlyContinue
if (-not $c) { $c = Get-VpnConnection -AllUserConnection -Name '${ safeName }' -ErrorAction SilentlyContinue }
if ($c) { 'found' } else { 'notfound' }` . trim ();
const checkResult = await runPS (checkPs);
if (checkResult !== "found" ) {
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 = $false
try {
rasdial '${ safeName }' | Out-Null
$connected = $true
} catch {}
$status = $null
$c = Get-VpnConnection -Name '${ safeName }' -ErrorAction SilentlyContinue
if ($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 ); });
}