Code

/**
 * mcp/skills/getNetworkInterfaces.ts — get_network_interfaces skill
 *
 * Lists all network interfaces with their status, IP addresses, MAC addresses,
 * and connection type (Wi-Fi, Ethernet, VPN, loopback).
 *
 * Platform strategy
 * -----------------
 * darwin  `ifconfig -a` — parse each interface block for inet, ether, status, flags
 * win32   PowerShell Get-NetIPAddress | Select-Object ... | ConvertTo-Json
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/getNetworkInterfaces.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: "get_network_interfaces",
  description:
    "Lists all network interfaces with their status, IP addresses, MAC addresses, " +
    "and connection type (Wi-Fi, Ethernet, VPN, loopback). " +
    "Use at the start of any network troubleshooting workflow.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    includeInactive: z
      .boolean()
      .optional()
      .describe("Include inactive/disconnected interfaces. Default: false"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface NetworkInterface {
  name:   string;
  type:   "Wi-Fi" | "Ethernet" | "VPN" | "Loopback" | "Other";
  status: "active" | "inactive";
  ipv4:   string | null;
  ipv6:   string | null;
  mac:    string | null;
  mtu:    number | null;
}
 
interface RunResult {
  platform:    string;
  interfaces:  NetworkInterface[];
  activeCount: number;
  total:       number;
}
 
// -- 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 },
  );
  return stdout.trim();
}
 
// -- darwin implementation ----------------------------------------------------
 
/**
 * Resolves device → hardware-port-type by parsing
 * `networksetup -listallhardwareports`.  This is the only reliable way on
 * darwin: en0 is *usually* Wi-Fi but Macs with discrete Wi-Fi cards or
 * non-default configurations break that assumption.  Returns an empty Map
 * when networksetup fails — the caller falls back to name-prefix heuristics.
 */
async function getDeviceTypeMapDarwin(): Promise<Map<string, NetworkInterface["type"]>> {
  const map = new Map<string, NetworkInterface["type"]>();
  try {
    const { stdout } = await execAsync("networksetup -listallhardwareports");
    const blocks = stdout.split(/\n\s*\n/);
    for (const block of blocks) {
      const portMatch   = block.match(/Hardware Port:\s*(.+)/);
      const deviceMatch = block.match(/Device:\s*(\S+)/);
      if (!portMatch || !deviceMatch) continue;
      const port   = portMatch[1].trim();
      const device = deviceMatch[1].trim();
      if (port === "Wi-Fi")                          map.set(device, "Wi-Fi");
      else if (port.toLowerCase().includes("ethernet")) map.set(device, "Ethernet");
      else if (port.toLowerCase().includes("thunderbolt")) map.set(device, "Ethernet");
    }
  } catch {
    /* fall through — caller heuristic handles missing entries */
  }
  return map;
}
 
function classifyInterface(
  name:          string,
  deviceTypeMap: Map<string, NetworkInterface["type"]>,
): NetworkInterface["type"] {
  if (name === "lo0") return "Loopback";
  if (name.startsWith("utun") || name.startsWith("ipsec") || name.startsWith("tun")) return "VPN";
  // Authoritative source: networksetup hardware-port mapping.
  const fromMap = deviceTypeMap.get(name);
  if (fromMap) return fromMap;
  // Fallback heuristic for interfaces networksetup doesn't enumerate
  // (transient bridges, VM adapters): treat anything starting with "en"
  // generically as Ethernet rather than guessing Wi-Fi from the device name.
  if (name.startsWith("en")) return "Ethernet";
  return "Other";
}
 
async function getInterfacesDarwin(includeInactive: boolean): Promise<NetworkInterface[]> {
  const [ifconfigResult, deviceTypeMap] = await Promise.all([
    execAsync("ifconfig -a", { maxBuffer: 10 * 1024 * 1024 }),
    getDeviceTypeMapDarwin(),
  ]);
  const { stdout } = ifconfigResult;
 
  const blocks = stdout.split(/^(?=\S)/m).filter(Boolean);
  const result: NetworkInterface[] = [];
 
  for (const block of blocks) {
    const nameMatch = block.match(/^(\S+?):/);
    if (!nameMatch) continue;
    const name = nameMatch[1];
 
    const flags   = block.match(/flags=\S+\s*<([^>]*)>/)?.[1] ?? "";
    const isUp    = flags.split(",").includes("UP");
    const status: NetworkInterface["status"] = isUp ? "active" : "inactive";
 
    if (!includeInactive && status === "inactive") continue;
 
    const ipv4Match = block.match(/inet (\d+\.\d+\.\d+\.\d+)/);
    const ipv6Match = block.match(/inet6 ([a-f0-9:]+)/);
    const macMatch  = block.match(/ether ([0-9a-f:]{17})/i);
    const mtuMatch  = block.match(/mtu (\d+)/);
 
    result.push({
      name,
      type:   classifyInterface(name, deviceTypeMap),
      status,
      ipv4:   ipv4Match?.[1] ?? null,
      ipv6:   ipv6Match?.[1] ?? null,
      mac:    macMatch?.[1]  ?? null,
      mtu:    mtuMatch ? parseInt(mtuMatch[1], 10) : null,
    });
  }
 
  return result;
}
 
// -- win32 implementation -----------------------------------------------------
 
async function getInterfacesWin32(includeInactive: boolean): Promise<NetworkInterface[]> {
  const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$addrs = Get-NetIPAddress | Select-Object InterfaceAlias,IPAddress,AddressFamily,PrefixLength
$addrs | ConvertTo-Json -Depth 2 -Compress`.trim();
 
  const raw = await runPS(ps);
  if (!raw) return [];
 
  interface WinAddr {
    InterfaceAlias:  string;
    IPAddress:       string;
    AddressFamily:   number; // 2=IPv4, 23=IPv6
    PrefixLength:    number;
  }
 
  const parsed = JSON.parse(raw) as WinAddr | WinAddr[];
  const addrs  = Array.isArray(parsed) ? parsed : [parsed];
 
  // Group by interface alias
  const map = new Map<string, NetworkInterface>();
  for (const a of addrs) {
    if (!map.has(a.InterfaceAlias)) {
      const name = a.InterfaceAlias;
      let type: NetworkInterface["type"] = "Other";
      if (name.toLowerCase().includes("loopback")) type = "Loopback";
      else if (name.toLowerCase().includes("wi-fi") || name.toLowerCase().includes("wireless")) type = "Wi-Fi";
      else if (name.toLowerCase().includes("ethernet")) type = "Ethernet";
      else if (name.toLowerCase().includes("vpn") || name.toLowerCase().includes("tunnel")) type = "VPN";
 
      map.set(name, { name, type, status: "active", ipv4: null, ipv6: null, mac: null, mtu: null });
    }
    const entry = map.get(a.InterfaceAlias)!;
    if (a.AddressFamily === 2)  entry.ipv4 = a.IPAddress;
    if (a.AddressFamily === 23) entry.ipv6 = a.IPAddress;
  }
 
  const all = Array.from(map.values());
  return includeInactive ? all : all.filter(i => i.status === "active");
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  includeInactive = false,
}: {
  includeInactive?: boolean;
} = {}): Promise<RunResult> {
  const platform   = os.platform();
  const interfaces = platform === "win32"
    ? await getInterfacesWin32(includeInactive)
    : await getInterfacesDarwin(includeInactive);
 
  return {
    platform,
    interfaces,
    activeCount: interfaces.filter(i => i.status === "active").length,
    total:       interfaces.length,
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}