Code

/**
 * mcp/skills/checkCertificateExpiry.ts — check_certificate_expiry skill
 *
 * Checks the TLS certificate expiry date for a given hostname using Node.js
 * tls.connect() — no child_process needed.
 *
 * Platform strategy
 * -----------------
 * darwin & win32  Pure Node.js tls module — cross-platform
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/checkCertificateExpiry.ts google.com
 */
 
import * as os  from "os";
import * as tls from "tls";
import { z }    from "zod";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_certificate_expiry",
  description:
    "Checks the TLS certificate expiry date for a given hostname. " +
    "Use when diagnosing HTTPS connection failures, email server issues, or " +
    "VPN authentication problems caused by expired certificates.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.  Wave 2 Track B Trigger 2
  // (`certificate-expiring`) references `daysUntilExpiry` and `isExpired`.
  outputKeys: [
    "platform",
    "host",
    "port",
    "subject",
    "issuer",
    "validFrom",
    "validTo",
    "daysUntilExpiry",
    "isExpired",
    "isExpiringSoon",
    "error",
  ],
  schema: {
    host: z
      .string()
      .describe("Hostname to check (e.g. 'mail.company.com', 'vpn.example.com')"),
    port: z
      .number()
      .optional()
      .describe("Port number. Default: 443"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface CertResult {
  host:            string;
  port:            number;
  subject:         string;
  issuer:          string;
  validFrom:       string;
  validTo:         string;
  daysUntilExpiry: number;
  isExpired:       boolean;
  isExpiringSoon:  boolean;
  error?:          string;
}
 
// -- Shared implementation (darwin + win32) -----------------------------------
 
function tlsConnect(host: string, port: number): Promise<tls.TLSSocket> {
  return new Promise((resolve, reject) => {
    const socket = tls.connect(
      { host, port, rejectUnauthorized: false, servername: host },
      () => resolve(socket),
    );
    socket.setTimeout(10_000);
    socket.on("timeout", () => {
      socket.destroy();
      reject(new Error(`Connection to ${host}:${port} timed out`));
    });
    socket.on("error", reject);
  });
}
 
function formatDN(dn: Record<string, string> | undefined): string {
  if (!dn) return "unknown";
  return Object.entries(dn)
    .map(([k, v]) => `${k}=${v}`)
    .join(", ");
}
 
async function checkCertificate(host: string, port: number): Promise<CertResult> {
  const socket = await tlsConnect(host, port);
  try {
    const cert = socket.getPeerCertificate();
 
    if (!cert || !cert.valid_to) {
      return {
        host, port,
        subject:         "unknown",
        issuer:          "unknown",
        validFrom:       "unknown",
        validTo:         "unknown",
        daysUntilExpiry: -1,
        isExpired:       true,
        isExpiringSoon:  true,
        error:           "No certificate returned",
      };
    }
 
    const validTo   = new Date(cert.valid_to);
    const validFrom = new Date(cert.valid_from);
    const now       = new Date();
    const msPerDay  = 1000 * 60 * 60 * 24;
    const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / msPerDay);
 
    return {
      host,
      port,
      subject:         formatDN(cert.subject as unknown as Record<string, string>),
      issuer:          formatDN(cert.issuer  as unknown as Record<string, string>),
      validFrom:       validFrom.toISOString(),
      validTo:         validTo.toISOString(),
      daysUntilExpiry,
      isExpired:       daysUntilExpiry < 0,
      isExpiringSoon:  daysUntilExpiry >= 0 && daysUntilExpiry < 30,
    };
  } finally {
    socket.destroy();
  }
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  host,
  port = 443,
}: {
  host:  string;
  port?: number;
}) {
  // Validate host — reject anything that looks like shell injection
  if (!/^[a-zA-Z0-9.\-]+$/.test(host)) {
    throw new Error(`[check_certificate_expiry] Invalid hostname: ${host}`);
  }
  if (port < 1 || port > 65535) {
    throw new Error(`[check_certificate_expiry] Invalid port: ${port}`);
  }
 
  const platform = os.platform();
  try {
    const result = await checkCertificate(host, port);
    return { platform, ...result };
  } catch (err) {
    return {
      platform,
      host,
      port,
      subject:         "unknown",
      issuer:          "unknown",
      validFrom:       "unknown",
      validTo:         "unknown",
      daysUntilExpiry: -1,
      isExpired:       true,
      isExpiringSoon:  true,
      error:           (err as Error).message,
    };
  }
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({ host: process.argv[2] ?? "google.com" })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}