Guide · Kubernetes & TLS

MCP Tools for cert-manager — Certificate lifecycle, Issuer health, and ACME challenge debugging

cert-manager automates TLS certificate issuance and renewal within Kubernetes clusters. When building MCP tools that monitor cert-manager, three distinctions determine correctness: Issuer health vs Certificate health (an Issuer can be Ready: True while individual Certificates fail to renew due to DNS misconfiguration or rate limits), the Certificate Ready condition vs actual expiry (Ready: True means the last issuance succeeded, not that the certificate is still valid — always read status.notAfter for the real expiry timestamp), and ACME challenges and Orders (when a Certificate enters the Issuing state, cert-manager creates intermediate CertificateRequest and Order resources that surface the real failure reason — never diagnose ACME failures from the Certificate condition alone).

TL;DR

cert-manager resources are read via the Kubernetes API: GET /apis/cert-manager.io/v1/namespaces/{ns}/certificates/{name} for Certificate status, /apis/cert-manager.io/v1/issuers and /apis/cert-manager.io/v1/clusterissuers for issuer configuration health. The status.notAfter field on a Certificate is the authoritative expiry time — alert at 30 days remaining. The status.conditions array has type: Ready (overall cert health) and type: Issuing (renewal in progress). When Issuing: True, the active CertificateRequest is found via kubectl get certificaterequest -l cert-manager.io/certificate-name={cert-name} — the CertificateRequest has a status.conditions with type: Approved, type: Ready, and type: Denied conditions that reveal whether the failure is a policy denial, a CA error, or an ACME challenge failure.

cert-manager architecture for MCP tool authors

cert-manager runs as a controller deployment in the cluster. It watches Certificate, Issuer, ClusterIssuer, CertificateRequest, Order, and Challenge custom resources. The controller's reconciliation loop handles renewals automatically: when a Certificate's status.notAfter is within the renewal window (default: 2/3 of the certificate's lifetime, or spec.renewBefore if specified), cert-manager creates a new CertificateRequest to trigger renewal.

MCP tools should authenticate with a ServiceAccount that has get and list on the cert-manager CRD groups (cert-manager.io and acme.cert-manager.io). Never grant update or delete to a monitoring ServiceAccount — an MCP tool should not modify certificate resources, only observe them. Read-only access is sufficient for all monitoring use cases.

async function createCertManagerClient(k8sBaseUrl, token) {
  const headers = {
    'Authorization': `Bearer ${token}`,
    'Accept': 'application/json',
  };

  async function get(path) {
    const res = await fetch(`${k8sBaseUrl}${path}`, { headers });
    if (!res.ok) throw new Error(`K8s API ${path} → ${res.status}`);
    return res.json();
  }

  async function listCertificates(namespace) {
    const result = await get(
      `/apis/cert-manager.io/v1/namespaces/${namespace}/certificates`
    );
    return (result.items ?? []).map(parseCertificate);
  }

  async function listAllCertificates() {
    const result = await get('/apis/cert-manager.io/v1/certificates');
    return (result.items ?? []).map(parseCertificate);
  }

  async function getIssuerHealth(namespace, name) {
    const issuer = await get(
      `/apis/cert-manager.io/v1/namespaces/${namespace}/issuers/${name}`
    );
    return parseIssuer(issuer);
  }

  async function listClusterIssuers() {
    const result = await get('/apis/cert-manager.io/v1/clusterissuers');
    return (result.items ?? []).map(parseIssuer);
  }

  async function getCertificateRequests(namespace, certName) {
    const result = await get(
      `/apis/cert-manager.io/v1/namespaces/${namespace}/certificaterequests` +
      `?labelSelector=cert-manager.io%2Fcertificate-name%3D${certName}`
    );
    return (result.items ?? []).sort(
      (a, b) => new Date(b.metadata.creationTimestamp) - new Date(a.metadata.creationTimestamp)
    );
  }

  return { listCertificates, listAllCertificates, getIssuerHealth, listClusterIssuers, getCertificateRequests };
}

Certificate conditions and expiry monitoring

The Certificate resource's status.notAfter is the single most important field for TLS health monitoring — it is the expiry time of the certificate currently stored in the target Secret. Ready: True means cert-manager last successfully issued to this Certificate, but the expiry is only known from status.notAfter. A Certificate with Ready: True and notAfter one day away is in crisis — the automatic renewal may be stuck.

cert-manager sets Issuing: True when it begins the renewal process. If Issuing: True persists for more than 5–10 minutes, renewal is stuck — investigate the active CertificateRequest and any associated Challenge resources. Common causes: DNS propagation delay for DNS-01 challenges, rate limit errors from Let's Encrypt (429 on the ACME order endpoint), expired ACME account credentials, or Ingress controller not serving the HTTP-01 challenge path.

function parseCertificate(cert) {
  const conditions = cert.status?.conditions ?? [];
  const readyCondition = conditions.find(c => c.type === 'Ready');
  const issuingCondition = conditions.find(c => c.type === 'Issuing');

  const notAfter = cert.status?.notAfter ? new Date(cert.status.notAfter) : null;
  const notBefore = cert.status?.notBefore ? new Date(cert.status.notBefore) : null;
  const now = new Date();

  const daysUntilExpiry = notAfter ? (notAfter - now) / (1000 * 60 * 60 * 24) : null;
  const totalLifetimeDays = (notAfter && notBefore) ? (notAfter - notBefore) / (1000 * 60 * 60 * 24) : null;

  // cert-manager default renewal window: 2/3 of lifetime
  // renewBefore overrides this (as a duration string: "360h", "30d", etc.)
  const renewBeforeStr = cert.spec?.renewBefore ?? null;

  return {
    name: cert.metadata.name,
    namespace: cert.metadata.namespace,

    // Target Secret and DNS names
    secretName: cert.spec?.secretName,
    dnsNames: cert.spec?.dnsNames ?? [],
    issuerRef: {
      name: cert.spec?.issuerRef?.name,
      kind: cert.spec?.issuerRef?.kind ?? 'Issuer',  // 'Issuer' | 'ClusterIssuer'
      group: cert.spec?.issuerRef?.group ?? 'cert-manager.io',
    },

    // Health conditions
    ready: readyCondition?.status === 'True',
    readyReason: readyCondition?.reason ?? null,
    readyMessage: readyCondition?.message ?? null,
    issuing: issuingCondition?.status === 'True',  // Renewal in progress

    // Expiry details — this is the real health signal for TLS
    notBefore: notBefore?.toISOString() ?? null,
    notAfter: notAfter?.toISOString() ?? null,
    daysUntilExpiry,
    expired: notAfter !== null && notAfter < now,
    criticalExpiry: daysUntilExpiry !== null && daysUntilExpiry < 7,   // < 7 days
    warningExpiry: daysUntilExpiry !== null && daysUntilExpiry < 30,   // < 30 days
    totalLifetimeDays,

    // Renewal stuck detection: Issuing for too long
    issuingStuck: issuingCondition?.status === 'True' &&
      issuingCondition?.lastTransitionTime &&
      (Date.now() - new Date(issuingCondition.lastTransitionTime).getTime()) > 10 * 60 * 1000,  // > 10 min

    renewBefore: renewBeforeStr,
    revision: cert.status?.revision ?? null,  // Increments on each successful renewal
  };
}

function parseIssuer(issuer) {
  const conditions = issuer.status?.conditions ?? [];
  const readyCondition = conditions.find(c => c.type === 'Ready');

  return {
    name: issuer.metadata.name,
    namespace: issuer.metadata.namespace ?? null,  // null for ClusterIssuer
    kind: issuer.kind,  // 'Issuer' | 'ClusterIssuer'

    // Issuer type
    type: issuer.spec?.acme ? 'acme' :
          issuer.spec?.ca ? 'ca' :
          issuer.spec?.vault ? 'vault' :
          issuer.spec?.selfSigned ? 'selfSigned' : 'unknown',

    ready: readyCondition?.status === 'True',
    readyReason: readyCondition?.reason ?? null,
    readyMessage: readyCondition?.message ?? null,

    // ACME-specific: server URL and account key
    acmeServer: issuer.spec?.acme?.server ?? null,
    acmeEmail: issuer.spec?.acme?.email ?? null,
    acmeAccountUri: issuer.status?.acme?.lastRegisteredEmail ? issuer.status.acme.uri : null,
  };
}

ACME challenges: HTTP-01 and DNS-01 failure diagnosis

When cert-manager uses an ACME issuer (Let's Encrypt, ZeroSSL, or a private ACME CA), it creates an Order resource for each CertificateRequest. The Order creates one or more Challenge resources — one per DNS name being validated. The Challenge resource tracks the state of the ACME challenge: pending (waiting for challenge to be set up), processing (challenge placed, waiting for ACME server to verify), valid (challenge passed), or invalid (challenge failed).

For HTTP-01 challenges: cert-manager creates a temporary Ingress or modifies the existing Ingress to serve the challenge token at /.well-known/acme-challenge/{token}. Failure causes are: Ingress controller not routing the path (Nginx class mismatch, no IngressClass specified), cluster not reachable from ACME server (firewall, private cluster), or challenge token not served within the ACME server's timeout window. For DNS-01 challenges: cert-manager updates a DNS TXT record via the configured DNS provider. Failure causes are: DNS provider API credentials expired, DNS propagation delay exceeding ACME server's wait window, or the configured solver does not match the DNS zone.

async function diagnoseCertificateRenewal(certMgr, namespace, certName) {
  const [certReqs, challenges] = await Promise.all([
    certMgr.getCertificateRequests(namespace, certName),
    certMgr.getResource(
      `/apis/acme.cert-manager.io/v1/namespaces/${namespace}/challenges` +
      `?labelSelector=cert-manager.io%2Fcertificate-name%3D${certName}`
    ).then(r => r.items ?? []).catch(() => []),
  ]);

  const activeCertReq = certReqs[0];  // Most recent request
  if (!activeCertReq) {
    return { diagnosisStatus: 'no_certificate_request', action: 'check_cert_manager_controller_logs' };
  }

  const crConditions = activeCertReq.status?.conditions ?? [];
  const crReady = crConditions.find(c => c.type === 'Ready');
  const crApproved = crConditions.find(c => c.type === 'Approved');
  const crDenied = crConditions.find(c => c.type === 'Denied');
  const crInvalidRequest = crConditions.find(c => c.type === 'InvalidRequest');

  // Certificate policy denial
  if (crDenied?.status === 'True') {
    return {
      diagnosisStatus: 'policy_denied',
      reason: crDenied.message,
      action: 'check_certificate_policy_approver_configuration',
    };
  }

  // Invalid certificate request (bad spec)
  if (crInvalidRequest?.status === 'True') {
    return {
      diagnosisStatus: 'invalid_request',
      reason: crInvalidRequest.message,
      action: 'check_certificate_spec_dns_names_and_issuer_ref',
    };
  }

  // ACME challenge failures
  const failedChallenges = challenges.filter(c => c.status?.state === 'invalid');
  const pendingChallenges = challenges.filter(c => ['pending', 'processing'].includes(c.status?.state));

  return {
    diagnosisStatus: crReady?.status === 'True' ? 'certificate_request_ready' : 'certificate_request_pending',
    certificateRequest: {
      name: activeCertReq.metadata.name,
      ready: crReady?.status === 'True',
      approved: crApproved?.status !== 'False',
      reason: crReady?.reason,
      message: crReady?.message,
    },
    acmeChallenges: {
      total: challenges.length,
      failed: failedChallenges.length,
      pending: pendingChallenges.length,
      valid: challenges.filter(c => c.status?.state === 'valid').length,
      failedDetails: failedChallenges.map(c => ({
        dnsName: c.spec?.dnsName,
        type: c.spec?.type,   // 'HTTP-01' | 'DNS-01'
        reason: c.status?.reason,
        // For HTTP-01: check if Ingress class is correct
        // For DNS-01: check if DNS provider credentials are valid
      })),
    },
  };
}

AliveMCP integration for cert-manager health monitoring

TLS certificate expiry is one of the most avoidable yet common production outages. cert-manager automates renewal, but renewal can fail silently for weeks before the certificate expires — failed ACME challenges do not produce Kubernetes Events that survive the default 1-hour retention window, and the Certificate's Ready: True condition remains from the last successful issuance until the certificate actually expires.

Register an AliveMCP probe that checks all certificates across all namespaces and returns a structured health report. Alert at 30 days remaining (warning — renewal should begin soon) and at 7 days remaining (critical — renewal may be stuck). Alert immediately on Issuing: True for more than 10 minutes (stuck renewal). Set the probe interval to 1 hour — certificate expiry changes gradually and there's no benefit to sub-minute polling.

async function certManagerHealthProbe(k8sBaseUrl, token) {
  const headers = { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' };
  const get = path => fetch(`${k8sBaseUrl}${path}`, { headers }).then(r => r.json());

  const [allCerts, clusterIssuers, controllerDeploy] = await Promise.all([
    get('/apis/cert-manager.io/v1/certificates'),
    get('/apis/cert-manager.io/v1/clusterissuers'),
    get('/apis/apps/v1/namespaces/cert-manager/deployments/cert-manager'),
  ]);

  const controllerReady = (controllerDeploy.status?.readyReplicas ?? 0) > 0;
  const certs = (allCerts.items ?? []).map(parseCertificate);
  const issuers = (clusterIssuers.items ?? []).map(parseIssuer);

  const expiredCerts = certs.filter(c => c.expired);
  const criticalCerts = certs.filter(c => !c.expired && c.criticalExpiry);
  const warningCerts = certs.filter(c => !c.expired && !c.criticalExpiry && c.warningExpiry);
  const stuckRenewals = certs.filter(c => c.issuingStuck);
  const failedIssuers = issuers.filter(i => !i.ready);

  return {
    healthy: controllerReady && expiredCerts.length === 0 && criticalCerts.length === 0 && stuckRenewals.length === 0,
    controllerReady,
    summary: {
      total: certs.length,
      expired: expiredCerts.length,
      critical: criticalCerts.length,   // < 7 days
      warning: warningCerts.length,      // < 30 days
      stuckRenewals: stuckRenewals.length,
      failedIssuers: failedIssuers.length,
    },
    expired: expiredCerts.map(c => ({ name: c.name, namespace: c.namespace, notAfter: c.notAfter })),
    critical: criticalCerts.map(c => ({ name: c.name, namespace: c.namespace, daysUntilExpiry: Math.floor(c.daysUntilExpiry), notAfter: c.notAfter })),
    stuckRenewals: stuckRenewals.map(c => ({ name: c.name, namespace: c.namespace, issuerRef: c.issuerRef })),
    failedIssuers: failedIssuers.map(i => ({ name: i.name, reason: i.readyReason, message: i.readyMessage })),
  };
}

Register this probe with AliveMCP at a 1-hour interval with a 15-second timeout. Configure three separate alert channels: expired certificates = P0 (immediate page — TLS handshakes failing now); critical expiry or stuck renewal = P1 (urgent action required — certificate expires within 7 days and renewal is not resolving); warning expiry = P2 (investigate why renewal hasn't triggered — may be a Rate Limit backoff from Let's Encrypt).

Related guides