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
- MCP tools for Kubernetes API — Deployment rollout status, Pod conditions, and Event-based failure diagnosis
- MCP tools for Prometheus — PromQL queries, alerting rules, and metrics scrape health
- MCP tools for RabbitMQ — queue depth, consumer health, and connection monitoring
- MCP tools for AWS SQS — queue depth, DLQ monitoring, and visibility timeout
- MCP tools for Kafka — consumer group lag, topic health, and partition leadership
- MCP server health check patterns — composite endpoints and failure classification