Guide · Kubernetes & Autoscaling

MCP Tools for KEDA — ScaledObject status, TriggerAuthentication health, and metrics server monitoring

KEDA (Kubernetes Event-Driven Autoscaling) scales Kubernetes workloads based on external metrics from queues, streams, databases, and custom sources. When building MCP tools that monitor KEDA, three distinctions determine correctness: KEDA controller health vs ScaledObject health vs scaling activity (a healthy KEDA pod does not mean your ScaledObjects are actively polling metrics), the Active condition vs the Ready condition (Active: False does not mean broken — it means the trigger metric is at zero and the workload is legitimately scaled to zero), and fallback behavior (when a trigger's metric source becomes unavailable, KEDA either scales to spec.fallback.replicas or holds current replicas — the choice has significant impact on availability under outage).

TL;DR

Read KEDA resources via the Kubernetes custom resource API: GET /apis/keda.sh/v1alpha1/namespaces/{ns}/scaledobjects/{name} for ScaledObjects, /apis/keda.sh/v1alpha1/triggerauthentications for auth configurations. The primary health signal is status.conditions — check for type: Ready (False means KEDA cannot establish the scaling relationship) and type: Active (False means trigger metric is at zero, workload scaled to spec.minReplicaCount). Watch status.externalMetricNames to see which metrics KEDA is currently polling. If Ready: False with reason ScalerNotFound, the trigger type plugin is not installed. If Ready: False with reason GeneralTriggerConfigError, the TriggerAuthentication secret reference is invalid or the target endpoint is unreachable from the KEDA operator pod.

KEDA architecture for MCP tool authors

KEDA installs two deployments into the cluster: the keda-operator (watches ScaledObject and ScaledJob resources, manages the HPA for the target workload, polls external trigger metrics) and the keda-operator-metrics-apiserver (implements the Kubernetes Custom Metrics API, allowing the HPA to fetch external metrics). Both must be healthy for autoscaling to work.

The keda-operator creates and manages a Kubernetes HorizontalPodAutoscaler for each ScaledObject. The HPA uses the Custom Metrics API (served by keda-operator-metrics-apiserver) to get the current metric value and decide the target replica count. If the metrics API server is down, the HPA cannot fetch metrics — it falls back to its last known replica count or to the ScaledObject's fallback configuration.

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

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

  async function getScaledObject(namespace, name) {
    const so = await getResource(
      `/apis/keda.sh/v1alpha1/namespaces/${namespace}/scaledobjects/${name}`
    );
    return parseScaledObject(so);
  }

  async function listScaledObjects(namespace) {
    const result = await getResource(
      `/apis/keda.sh/v1alpha1/namespaces/${namespace}/scaledobjects`
    );
    return (result.items ?? []).map(parseScaledObject);
  }

  async function listTriggerAuthentications(namespace) {
    const result = await getResource(
      `/apis/keda.sh/v1alpha1/namespaces/${namespace}/triggerauthentications`
    );
    return result.items ?? [];
  }

  async function getKedaControllerHealth() {
    const [operator, metricsServer] = await Promise.all([
      getResource('/apis/apps/v1/namespaces/keda/deployments/keda-operator'),
      getResource('/apis/apps/v1/namespaces/keda/deployments/keda-operator-metrics-apiserver'),
    ]);
    return {
      operator: {
        ready: (operator.status?.readyReplicas ?? 0) > 0,
        readyReplicas: operator.status?.readyReplicas ?? 0,
      },
      metricsServer: {
        ready: (metricsServer.status?.readyReplicas ?? 0) > 0,
        readyReplicas: metricsServer.status?.readyReplicas ?? 0,
      },
    };
  }

  return { getScaledObject, listScaledObjects, listTriggerAuthentications, getKedaControllerHealth };
}

ScaledObject conditions: Ready vs Active vs Fallback

ScaledObject conditions have three important types. Ready indicates whether KEDA successfully established the scaling relationship between the trigger source and the target workload. Active indicates whether at least one trigger has a metric value above the activation threshold — Active: False is normal and expected when all queues are empty, not an error condition. Fallback indicates whether KEDA is in fallback mode because one or more trigger metric sources are unavailable.

The confusion between Active: False and unhealthy is one of the most common KEDA monitoring mistakes. An e-commerce application with a queue-based ScaledObject will show Active: False overnight when there are no messages — this is correct behavior and the workload should be at spec.minReplicaCount (often 0). Only alert on Active: False when you have independent evidence that there should be queue messages (e.g., a companion metric showing non-zero queue depth).

function parseScaledObject(so) {
  const conditions = so.status?.conditions ?? [];
  const readyCondition = conditions.find(c => c.type === 'Ready');
  const activeCondition = conditions.find(c => c.type === 'Active');
  const fallbackCondition = conditions.find(c => c.type === 'Fallback');
  const pausedCondition = conditions.find(c => c.type === 'Paused');

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

    // Target workload
    scaleTargetRef: {
      kind: so.spec?.scaleTargetRef?.kind ?? 'Deployment',
      name: so.spec?.scaleTargetRef?.name,
      apiVersion: so.spec?.scaleTargetRef?.apiVersion ?? 'apps/v1',
    },

    // Scale bounds
    minReplicaCount: so.spec?.minReplicaCount ?? 0,
    maxReplicaCount: so.spec?.maxReplicaCount ?? 100,
    pollingInterval: so.spec?.pollingInterval ?? 30,  // seconds

    // Primary health conditions
    ready: readyCondition?.status === 'True',
    readyReason: readyCondition?.reason ?? null,
    readyMessage: readyCondition?.message ?? null,

    // Active = at least one trigger above activation threshold
    active: activeCondition?.status === 'True',
    // Active: False is NORMAL when metrics are at zero — do not alert on this alone
    activeReason: activeCondition?.reason ?? null,

    // Fallback = metric source unavailable, scaling from fallback config
    inFallback: fallbackCondition?.status === 'True',
    fallbackReason: fallbackCondition?.message ?? null,
    fallbackConfig: so.spec?.fallback ? {
      failureThreshold: so.spec.fallback.failureThreshold,
      replicas: so.spec.fallback.replicas,
    } : null,

    // Paused autoscaling
    paused: pausedCondition?.status === 'True',
    pausedReplicas: so.metadata?.annotations?.['autoscaling.keda.sh/paused-replicas'] ?? null,

    // Current metric names KEDA is polling
    externalMetricNames: so.status?.externalMetricNames ?? [],
    resourceMetricNames: so.status?.resourceMetricNames ?? [],

    // Trigger configuration
    triggers: (so.spec?.triggers ?? []).map(t => ({
      type: t.type,        // 'rabbitmq' | 'redis' | 'kafka' | 'prometheus' | 'aws-sqs-queue' | etc.
      name: t.name ?? null,
      authRef: t.authenticationRef?.name ?? null,
      authRefKind: t.authenticationRef?.kind ?? 'TriggerAuthentication',  // or 'ClusterTriggerAuthentication'
      metadata: t.metadata,  // trigger-specific config (queue name, consumer group, etc.)
    })),
  };
}

TriggerAuthentication: secret references and provider connections

TriggerAuthentication resources hold the credentials that KEDA uses to connect to external metric sources (RabbitMQ password, AWS SQS credentials, Redis password, etc.). A TriggerAuthentication can source credentials from Kubernetes Secrets via secretTargetRef, from environment variables via env, from a HashiCorp Vault path via hashiCorpVaultSecrets, or from a pod identity provider via podIdentity (AWS IRSA, GKE Workload Identity, Azure Managed Identity).

TriggerAuthentication failures are a frequent root cause of ScaledObject Ready: False. The TriggerAuthentication resource itself does not have a status.conditions array that reports authentication validity — KEDA only discovers auth failures when it attempts to poll the trigger metric. The error surfaces in the ScaledObject's Ready condition message and in the keda-operator pod logs. When diagnosing a GeneralTriggerConfigError, inspect both the ScaledObject condition message and the keda-operator logs filtered by the ScaledObject name.

async function diagnoseTriggerAuthFailure(k8sClient, namespace, scaledObjectName) {
  // 1. Get the ScaledObject to find which trigger is failing
  const so = await k8sClient.getResource(
    `/apis/keda.sh/v1alpha1/namespaces/${namespace}/scaledobjects/${scaledObjectName}`
  );
  const readyCondition = so.status?.conditions?.find(c => c.type === 'Ready');

  if (readyCondition?.status === 'True') {
    return { healthy: true };
  }

  // 2. Find triggers with auth references
  const authFailures = [];
  for (const trigger of so.spec?.triggers ?? []) {
    if (!trigger.authenticationRef) continue;

    const authRefName = trigger.authenticationRef.name;
    const isCluster = trigger.authenticationRef.kind === 'ClusterTriggerAuthentication';
    const authPath = isCluster
      ? `/apis/keda.sh/v1alpha1/clustertriggerauthentications/${authRefName}`
      : `/apis/keda.sh/v1alpha1/namespaces/${namespace}/triggerauthentications/${authRefName}`;

    try {
      const ta = await k8sClient.getResource(authPath);

      // Check each secretTargetRef points to an existing secret key
      for (const secretRef of ta.spec?.secretTargetRef ?? []) {
        try {
          const secret = await k8sClient.getResource(
            `/api/v1/namespaces/${namespace}/secrets/${secretRef.name}`
          );
          const keyExists = secretRef.key in (secret.data ?? {});
          if (!keyExists) {
            authFailures.push({
              trigger: trigger.type,
              auth: authRefName,
              issue: `Secret key '${secretRef.key}' not found in secret '${secretRef.name}'`,
            });
          }
        } catch {
          authFailures.push({
            trigger: trigger.type,
            auth: authRefName,
            issue: `Secret '${secretRef.name}' not found in namespace '${namespace}'`,
          });
        }
      }
    } catch {
      authFailures.push({
        trigger: trigger.type,
        auth: authRefName,
        issue: `TriggerAuthentication '${authRefName}' not found`,
      });
    }
  }

  return {
    healthy: false,
    scaledObjectReady: false,
    readyReason: readyCondition?.reason,
    readyMessage: readyCondition?.message,
    authFailures,
  };
}

// Pause KEDA scaling (set replicas to N, stop polling)
function buildPauseAnnotation(replicas) {
  // Apply this annotation to the ScaledObject to pause scaling at `replicas` count
  // Remove annotation to resume
  return {
    'autoscaling.keda.sh/paused-replicas': String(replicas),
  };
}

ScaledJob: batch workload scaling vs ScaledObject

ScaledJob is the KEDA resource for batch workload scaling — it creates Kubernetes Jobs (not an HPA against a Deployment) in response to trigger metrics. ScaledJob is appropriate when each work item should be processed by a separate Job that terminates when done (Kubernetes Jobs cannot be scaled down in place). The spec.jobTargetRef defines the Job template; KEDA creates one or more Jobs when the trigger metric is non-zero.

ScaledJob conditions follow the same Ready/Active pattern as ScaledObject. Key differences: spec.scalingStrategy.strategy controls how many Jobs KEDA creates per reconciliation (default = one Job per pollingInterval, accurate = one Job per message at polling time). Set spec.maxReplicaCount to cap concurrent Jobs. ScaledJob does not manage an HPA — it creates Jobs directly.

async function getScaledJobStatus(k8sClient, namespace, scaledJobName) {
  const [sj, jobs] = await Promise.all([
    k8sClient.getResource(
      `/apis/keda.sh/v1alpha1/namespaces/${namespace}/scaledjobs/${scaledJobName}`
    ),
    k8sClient.getResource(
      `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=scaledjob.keda.sh%2Fname%3D${scaledJobName}`
    ),
  ]);

  const conditions = sj.status?.conditions ?? [];
  const items = jobs.items ?? [];

  const active = items.filter(j => (j.status?.active ?? 0) > 0).length;
  const succeeded = items.filter(j => (j.status?.succeeded ?? 0) > 0 && (j.status?.active ?? 0) === 0).length;
  const failed = items.filter(j => (j.status?.failed ?? 0) > 0 && (j.status?.active ?? 0) === 0).length;

  return {
    name: scaledJobName,
    ready: conditions.find(c => c.type === 'Ready')?.status === 'True',
    maxReplicaCount: sj.spec?.maxReplicaCount ?? 100,
    currentJobs: items.length,
    activeJobs: active,
    succeededJobs: succeeded,
    failedJobs: failed,
    // Alert if failedJobs / totalJobs exceeds threshold
    failureRate: items.length > 0 ? failed / items.length : 0,
  };
}

AliveMCP integration for KEDA health monitoring

KEDA health requires monitoring three independent layers: the keda-operator deployment (if down, no new scaling actions), the keda-operator-metrics-apiserver deployment (if down, HPA cannot fetch external metrics and existing ScaledObjects may scale based on fallback config), and individual ScaledObject Ready conditions (if Ready: False, that specific workload is not autoscaling based on the configured triggers). Register a combined probe with AliveMCP that surfaces all three layers.

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

  const [operatorDeploy, metricsServerDeploy, allSOs] = await Promise.all([
    get('/apis/apps/v1/namespaces/keda/deployments/keda-operator'),
    get('/apis/apps/v1/namespaces/keda/deployments/keda-operator-metrics-apiserver'),
    get('/apis/keda.sh/v1alpha1/scaledobjects'),  // cluster-wide list
  ]);

  const operatorReady = (operatorDeploy.status?.readyReplicas ?? 0) > 0;
  const metricsReady = (metricsServerDeploy.status?.readyReplicas ?? 0) > 0;

  const scaledObjects = (allSOs.items ?? []).map(so => {
    const conditions = so.status?.conditions ?? [];
    return {
      name: so.metadata.name,
      namespace: so.metadata.namespace,
      ready: conditions.find(c => c.type === 'Ready')?.status === 'True',
      inFallback: conditions.find(c => c.type === 'Fallback')?.status === 'True',
      paused: !!so.metadata?.annotations?.['autoscaling.keda.sh/paused-replicas'],
      readyMessage: conditions.find(c => c.type === 'Ready')?.message,
    };
  });

  const failingObjects = scaledObjects.filter(so => !so.paused && !so.ready);
  const fallbackObjects = scaledObjects.filter(so => so.inFallback);

  return {
    // Controllers
    operatorHealthy: operatorReady,
    metricsServerHealthy: metricsReady,

    // Resource conditions
    totalScaledObjects: scaledObjects.length,
    failingCount: failingObjects.length,
    fallbackCount: fallbackObjects.length,

    // Composite health
    healthy: operatorReady && metricsReady && failingObjects.length === 0,

    // Details for alert body
    failing: failingObjects,
    inFallback: fallbackObjects,
  };
}

Register this probe with AliveMCP at a 60-second interval and a 10-second timeout. Separate alert severity levels: keda-operator or metrics-server pod failure = P1 (all autoscaling disabled); ScaledObject Fallback: True = P2 (metric source down, scaling from fallback config, monitor the fallback replica count against actual load); ScaledObject Ready: False without fallback = P2 (specific workload not autoscaling).

Related guides