Guide · Kubernetes & Secrets Management

MCP Tools for External Secrets Operator — SecretStore health, ExternalSecret sync status, and refresh monitoring

External Secrets Operator (ESO) synchronizes secrets from external providers (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, and others) into Kubernetes Secrets. When building MCP tools that monitor ESO, three distinctions determine correctness: SecretStore provider health vs ExternalSecret sync health (a SecretStore can authenticate successfully to the provider while individual ExternalSecrets fail because the referenced secret path doesn't exist), sync success vs refresh freshness (Ready: True on an ExternalSecret means the last sync succeeded, but if status.refreshTime is older than spec.refreshInterval by more than 2x, the controller may be failing silently on the next refresh), and stale Kubernetes secrets after provider rotation (if ESO syncs on a 1-hour interval and a secret was rotated in the provider, applications using the old Kubernetes Secret continue receiving the stale value for up to an hour).

TL;DR

Read ESO resources via the Kubernetes API: GET /apis/external-secrets.io/v1beta1/namespaces/{ns}/secretstores/{name} for SecretStore health, /apis/external-secrets.io/v1beta1/externalsecrets for sync status. SecretStore health is in status.conditions with type: ValidValid: True means ESO can authenticate to the provider; Valid: False with reason InvalidProviderConfig or SecretSyncError means credentials are expired or the provider endpoint is unreachable. ExternalSecret health is in status.conditions with type: Ready — the Ready condition's reason field identifies whether the failure is a store connection problem (SecretSyncError), a missing secret path (NoSecretForDeletePolicy), or a transformation error. Check status.refreshTime against spec.refreshInterval to detect controllers that have stopped refreshing without reporting a failure condition.

External Secrets Operator architecture for MCP tool authors

ESO runs as a controller deployment in the cluster. It watches SecretStore, ClusterSecretStore, ExternalSecret, ClusterExternalSecret, PushSecret, and ClusterPushSecret custom resources. The controller connects to external secret providers using credentials configured in the SecretStore. For each ExternalSecret, it reads the referenced secret values from the provider and writes them into a Kubernetes Secret in the same namespace.

The authentication model for ESO itself (how ESO connects to the external provider) is separate from the authentication your MCP tool needs (how your MCP tool reads ESO CRD status). MCP tools need only read access to the ESO CRD groups — never access to the actual Kubernetes Secrets that ESO syncs into, which may contain production credentials. Use a separate ServiceAccount with get and list on external-secrets.io and generators.external-secrets.io API groups.

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

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

  // SecretStore: namespace-scoped provider configuration
  async function listSecretStores(namespace) {
    const result = await get(
      `/apis/external-secrets.io/v1beta1/namespaces/${namespace}/secretstores`
    );
    return (result.items ?? []).map(parseSecretStore);
  }

  // ClusterSecretStore: cluster-scoped provider configuration
  async function listClusterSecretStores() {
    const result = await get('/apis/external-secrets.io/v1beta1/clustersecretstores');
    return (result.items ?? []).map(parseSecretStore);
  }

  async function listExternalSecrets(namespace) {
    const result = await get(
      `/apis/external-secrets.io/v1beta1/namespaces/${namespace}/externalsecrets`
    );
    return (result.items ?? []).map(parseExternalSecret);
  }

  async function listAllExternalSecrets() {
    const result = await get('/apis/external-secrets.io/v1beta1/externalsecrets');
    return (result.items ?? []).map(parseExternalSecret);
  }

  return { listSecretStores, listClusterSecretStores, listExternalSecrets, listAllExternalSecrets };
}

SecretStore conditions: Valid vs not-valid and provider-specific errors

SecretStore and ClusterSecretStore use a type: Valid condition (not Ready) to report provider connectivity health. Valid: True means ESO successfully authenticated to the provider at last check. Valid: False has several distinct reasons: InvalidProviderConfig (bad credentials reference, usually the Kubernetes Secret holding the provider credentials doesn't exist), SecretSyncError (credentials exist but authentication to the provider failed — expired token, insufficient IAM permissions, wrong region), or ValidationFailed (provider endpoint unreachable).

ESO validates SecretStore credentials on creation and on a periodic schedule. The validation interval is not configurable per-store — it's a global ESO controller setting (default: re-validate when ESO processes an ExternalSecret referencing that store). This means a SecretStore can show Valid: True with a stale lastTransitionTime if no ExternalSecrets have been synced recently. For air-gapped or low-frequency stores, this timestamp alone is an unreliable freshness signal.

function parseSecretStore(store) {
  const conditions = store.status?.conditions ?? [];
  const validCondition = conditions.find(c => c.type === 'Valid');

  const providerSpec = store.spec?.provider;
  const providerType = providerSpec
    ? Object.keys(providerSpec).find(k => providerSpec[k] !== undefined) ?? 'unknown'
    : 'unknown';

  return {
    name: store.metadata.name,
    namespace: store.metadata.namespace ?? null,   // null for ClusterSecretStore
    kind: store.kind,   // 'SecretStore' | 'ClusterSecretStore'
    providerType,       // 'aws' | 'vault' | 'gcpsm' | 'azurekv' | 'kubernetes' | etc.

    // Primary health signal
    valid: validCondition?.status === 'True',
    validStatus: validCondition?.status ?? 'Unknown',
    validReason: validCondition?.reason ?? null,
    validMessage: validCondition?.message ?? null,
    lastValidationTime: validCondition?.lastTransitionTime ?? null,

    // Provider-specific config (no secrets — just structural info)
    providerRegion: providerSpec?.aws?.region ?? providerSpec?.gcpsm?.projectID ?? null,
    providerServer: providerSpec?.vault?.server ?? null,
    providerVaultPath: providerSpec?.vault?.path ?? null,
    providerAuthType: providerSpec?.vault?.auth ? Object.keys(providerSpec.vault.auth)[0] : null,
  };
}

// Diagnose a SecretStore that shows Valid: False
function diagnoseSecretStoreFailure(store) {
  const { validReason, validMessage, providerType } = store;

  const providerDiagnosis = {
    aws: 'Check IAM role/user permissions for secretsmanager:GetSecretValue; check if IRSA annotation is on ESO pod ServiceAccount; verify AWS region setting',
    vault: 'Check Vault token TTL (Vault tokens expire); verify vault.server URL is reachable from ESO pod; check Vault policy grants read on spec.provider.vault.path',
    gcpsm: 'Check Workload Identity binding on ESO pod; verify GCP project ID; ensure Secret Manager API is enabled in the project',
    azurekv: 'Check Azure Managed Identity or client secret expiry; verify Key Vault name and tenant ID; check if ESO pod identity has get/list permissions on Key Vault secrets',
    kubernetes: 'Check if referenced Kubernetes Secret exists in the configured namespace; verify RBAC permissions for the ESO ServiceAccount to read that Secret',
  };

  return {
    store: store.name,
    providerType,
    failureReason: validReason,
    failureMessage: validMessage,
    diagnosisSteps: providerDiagnosis[providerType] ?? 'Check ESO controller logs for provider-specific error details',
  };
}

ExternalSecret sync status and refresh staleness detection

ExternalSecret resources have a type: Ready condition and a status.refreshTime field. Ready: True means the last sync cycle succeeded — the Kubernetes Secret was created or updated with values from the provider. status.refreshTime is the timestamp of the last sync attempt (whether successful or not). When status.refreshTime is older than spec.refreshInterval by more than 2x, the controller is not processing this ExternalSecret on schedule — check if the ESO controller pod is healthy and whether the ESO controller has been restarted (restarts clear the in-memory retry queue).

The spec.refreshInterval field accepts Go duration strings: "1h", "30m", "0" (never refresh). Never-refresh ExternalSecrets ("0") are intentional for secrets that should only be pulled once — do not alert on staleness for these. The spec.target.creationPolicy controls what happens to the Kubernetes Secret when the ExternalSecret is deleted: Owner (default, Secret is owned by ExternalSecret and deleted together) vs Merge (Secret is not deleted) vs None (ESO does not create the Secret, it must pre-exist).

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

  const refreshIntervalStr = es.spec?.refreshInterval ?? '1h';
  const neverRefresh = refreshIntervalStr === '0';
  const refreshIntervalMs = neverRefresh ? null : parseESOInterval(refreshIntervalStr);

  const refreshTime = es.status?.refreshTime ? new Date(es.status.refreshTime) : null;
  const syncSinceMs = refreshTime ? Date.now() - refreshTime.getTime() : null;

  // Staleness: last sync is more than 2x the interval ago
  const stale = !neverRefresh &&
    syncSinceMs !== null &&
    refreshIntervalMs !== null &&
    syncSinceMs > refreshIntervalMs * 2;

  return {
    name: es.metadata.name,
    namespace: es.metadata.namespace,
    targetSecretName: es.spec?.target?.name ?? es.metadata.name,
    creationPolicy: es.spec?.target?.creationPolicy ?? 'Owner',

    // SecretStore reference
    storeRef: {
      name: es.spec?.secretStoreRef?.name,
      kind: es.spec?.secretStoreRef?.kind ?? 'SecretStore',   // or 'ClusterSecretStore'
    },

    // Refresh configuration
    refreshInterval: refreshIntervalStr,
    neverRefresh,
    refreshIntervalMs,

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

    // Sync freshness
    lastSyncTime: refreshTime?.toISOString() ?? null,
    syncSinceMs,
    stale,
    staleSinceMs: stale ? syncSinceMs - refreshIntervalMs : null,

    // Secret data references (paths, not values)
    dataKeys: (es.spec?.data ?? []).map(d => d.secretKey),
    dataFromCount: (es.spec?.dataFrom ?? []).length,

    // Deletion policy for referenced provider secret
    deletionPolicy: es.spec?.target?.deletionPolicy ?? 'Retain',
  };
}

function parseESOInterval(interval) {
  // Go duration: 1h, 30m, 1h30m, 45s
  let ms = 0;
  const matches = interval.matchAll(/(\d+)(h|m|s)/g);
  for (const [, num, unit] of matches) {
    ms += parseInt(num, 10) * { h: 3_600_000, m: 60_000, s: 1000 }[unit];
  }
  return ms || 3_600_000;  // Default 1h if unparseable
}

Force-sync and troubleshooting stale secrets

When an ExternalSecret is stuck with an old value or a failed sync, ESO supports a force-sync mechanism via annotation. Adding the force-sync annotation with a timestamp value triggers an immediate re-sync outside the normal refreshInterval. This is the primary operational tool for fixing a stuck ExternalSecret — restart ESO controller if force-sync does not resolve within 60 seconds.

A common production scenario: a secret was rotated in AWS Secrets Manager, but applications are still receiving the old value because ESO's next sync hasn't occurred yet (interval is 1 hour). For time-sensitive rotations, the correct approach is to reduce spec.refreshInterval in advance (to 5m or 1m), trigger the rotation, then restore the original interval. Never restart the application Pod before the ExternalSecret refreshes — the Pod will mount the stale Secret from the previous sync.

// Build force-sync annotation patch for kubectl apply or K8s API patch
function buildForceSyncPatch(namespace, externalSecretName) {
  // PATCH /apis/external-secrets.io/v1beta1/namespaces/{ns}/externalsecrets/{name}
  // with Content-Type: application/merge-patch+json
  return {
    patchPath: `/apis/external-secrets.io/v1beta1/namespaces/${namespace}/externalsecrets/${externalSecretName}`,
    patchBody: JSON.stringify({
      metadata: {
        annotations: {
          // ESO watches for this annotation and triggers an immediate re-sync
          'force-sync': Date.now().toString(),
        },
      },
    }),
    contentType: 'application/merge-patch+json',
    // Note: your MCP tool's ServiceAccount needs 'patch' on ExternalSecrets
    // to use this — read-only SAs cannot trigger force sync
  };
}

// Aggregate health across all ExternalSecrets in a namespace
async function externalSecretsNamespaceHealth(esoClient, namespace) {
  const [secrets, stores] = await Promise.all([
    esoClient.listExternalSecrets(namespace),
    esoClient.listSecretStores(namespace),
  ]);

  const failing = secrets.filter(es => !es.ready && !es.neverRefresh);
  const stale = secrets.filter(es => es.stale);
  const invalidStores = stores.filter(s => !s.valid);

  // Cross-reference: find secrets whose store is invalid
  const storeNameSet = new Set(invalidStores.map(s => s.name));
  const failingDueToStore = failing.filter(es =>
    es.storeRef?.kind !== 'ClusterSecretStore' && storeNameSet.has(es.storeRef?.name)
  );

  return {
    healthy: failing.length === 0 && stale.length === 0 && invalidStores.length === 0,
    totals: {
      externalSecrets: secrets.length,
      secretStores: stores.length,
      failingSync: failing.length,
      staleSync: stale.length,
      invalidStores: invalidStores.length,
    },
    failing: failing.map(es => ({
      name: es.name,
      store: es.storeRef.name,
      reason: es.readyReason,
      message: es.readyMessage,
      likelyCause: storeNameSet.has(es.storeRef.name) ? 'store_invalid' : 'path_or_permission',
    })),
    stale: stale.map(es => ({
      name: es.name,
      lastSync: es.lastSyncTime,
      staleSinceMins: Math.round((es.staleSinceMs ?? 0) / 60_000),
    })),
  };
}

AliveMCP integration for External Secrets Operator monitoring

Secret synchronization failures in ESO can cause cascading failures: if an ExternalSecret that provides database credentials fails to sync, any new Pod that mounts that Secret will start with the stale credentials. If the database rotated its password and ESO fails to propagate the new value, all newly started Pods will fail to connect until the sync is resolved — while existing Pods with the old Secret mounted continue working (Kubernetes Secrets are mounted at Pod start, not refreshed dynamically unless using a CSI driver).

Register a probe with AliveMCP that reports SecretStore validity and ExternalSecret sync health. Use a 10-minute probe interval — ESO typically syncs on an hourly basis and stale detection at 2x interval means you only need to probe every 10 minutes to catch hour-long failures within the first cycle. Set a 10-second probe timeout.

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

  const [controllerDeploy, allES, allCSS] = await Promise.all([
    get('/apis/apps/v1/namespaces/external-secrets/deployments/external-secrets'),
    get('/apis/external-secrets.io/v1beta1/externalsecrets'),           // all namespaces
    get('/apis/external-secrets.io/v1beta1/clustersecretstores'),
  ]);

  const controllerReady = (controllerDeploy.status?.readyReplicas ?? 0) > 0;
  const externalSecrets = (allES.items ?? []).map(parseExternalSecret);
  const clusterStores = (allCSS.items ?? []).map(parseSecretStore);

  const failingSecrets = externalSecrets.filter(es => !es.ready && !es.neverRefresh);
  const staleSecrets = externalSecrets.filter(es => es.stale);
  const invalidStores = clusterStores.filter(s => !s.valid);

  return {
    healthy: controllerReady && failingSecrets.length === 0 && invalidStores.length === 0,
    controllerReady,
    summary: {
      totalExternalSecrets: externalSecrets.length,
      failingSyncCount: failingSecrets.length,
      staleCount: staleSecrets.length,
      invalidClusterStoreCount: invalidStores.length,
    },
    failingSecrets: failingSecrets.slice(0, 10).map(es => ({
      name: es.name,
      namespace: es.namespace,
      reason: es.readyReason,
    })),
    invalidClusterStores: invalidStores.map(s => ({
      name: s.name,
      reason: s.validReason,
      provider: s.providerType,
    })),
  };
}

Related guides