Guide · MCP Service Mesh Integration

MCP Server Istio — VirtualService traffic management, mTLS policies, Pilot debug API, and sidecar health

Istio is the dominant service mesh for Kubernetes, adding mTLS, traffic management, and observability without application code changes. Its configuration lives in Kubernetes CRDs — VirtualService, DestinationRule, PeerAuthentication — which makes Istio a natural fit for MCP tools that manage canary deployments, enforce mTLS policies, configure circuit breakers, and diagnose sidecar sync issues. This guide covers building Istio MCP tools using the @kubernetes/client-node SDK, reading the Pilot debug API to check proxy sync state, checking istiod health, and designing a /health/istio endpoint that AliveMCP can monitor.

TL;DR

Istio config is pure Kubernetes CRDs — use @kubernetes/client-node with CustomObjectsApi to read and patch VirtualServices, DestinationRules, and PeerAuthentications. The group/version/plural for Istio CRDs is networking.istio.io/v1alpha1 (or v1beta1) and security.istio.io/v1beta1. For health monitoring: probe istiod at /healthz/ready on port 15014, and check proxy sync state via the Pilot debug API (/debug/syncz) to detect sidecars with stale xDS config. Register the istiod readiness probe with AliveMCP for control plane uptime monitoring.

Kubernetes client setup for Istio CRDs

Istio configuration is entirely expressed as Kubernetes CRDs. There is no separate Istio API server — you interact with Istio the same way you interact with any other Kubernetes resource, using kubectl or the Kubernetes API. The MCP server uses @kubernetes/client-node to access Istio CRDs via the CustomObjectsApi.

import * as k8s from '@kubernetes/client-node';

const kc = new k8s.KubeConfig();

if (process.env.KUBECONFIG_BASE64) {
  // In-cluster or CI: kubeconfig from environment
  kc.loadFromString(Buffer.from(process.env.KUBECONFIG_BASE64, 'base64').toString('utf-8'));
} else if (process.env.KUBERNETES_SERVICE_HOST) {
  // Running inside the cluster — use service account token
  kc.loadFromCluster();
} else {
  // Local development — use ~/.kube/config
  kc.loadFromDefault();
}

const customApi = kc.makeApiClient(k8s.CustomObjectsApi);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);

// Istio CRD group/version constants
const ISTIO_NETWORKING = {
  group: 'networking.istio.io',
  version: 'v1beta1'   // v1beta1 is stable; v1alpha1 also works for older installations
};

const ISTIO_SECURITY = {
  group: 'security.istio.io',
  version: 'v1beta1'
};

// Helper to list Istio resources
async function listIstioResource(
  namespace: string | null,
  plural: string,
  apiGroup = ISTIO_NETWORKING
): Promise<any[]> {
  const result = namespace
    ? await customApi.listNamespacedCustomObject(
        apiGroup.group, apiGroup.version, namespace, plural
      )
    : await customApi.listClusterCustomObject(
        apiGroup.group, apiGroup.version, plural
      );

  return (result.body as any).items ?? [];
}

export { customApi, coreApi, kc, listIstioResource, ISTIO_NETWORKING, ISTIO_SECURITY };
Istio CRD API Group Plural Purpose
VirtualService networking.istio.io/v1beta1 virtualservices Route rules, traffic splitting, retries, timeouts
DestinationRule networking.istio.io/v1beta1 destinationrules Load balancing, circuit breaker, mTLS to backend
PeerAuthentication security.istio.io/v1beta1 peerauthentications mTLS mode (STRICT/PERMISSIVE/DISABLE) per namespace/pod
AuthorizationPolicy security.istio.io/v1beta1 authorizationpolicies Layer 7 access control (which service can call which)
Gateway networking.istio.io/v1beta1 gateways Ingress/egress gateway configuration
ServiceEntry networking.istio.io/v1beta1 serviceentries Register external services in the mesh

Core Istio MCP tool patterns

import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { customApi, listIstioResource, ISTIO_NETWORKING, ISTIO_SECURITY } from './istio-client.js';

// ---- list_virtual_services ----
server.tool(
  'list_virtual_services',
  { namespace: z.string().default('default') },
  async ({ namespace }) => {
    const vsList = await listIstioResource(namespace, 'virtualservices');

    return {
      content: [{
        type: 'text',
        text: JSON.stringify(vsList.map(vs => ({
          name: vs.metadata?.name,
          namespace: vs.metadata?.namespace,
          hosts: vs.spec?.hosts,
          gateways: vs.spec?.gateways,
          route_count: vs.spec?.http?.length ?? 0,
          // Extract traffic split weights from http routes
          traffic_splits: (vs.spec?.http ?? []).map((h: any, i: number) => ({
            match: h.match ? JSON.stringify(h.match[0]) : 'default',
            route: (h.route ?? []).map((r: any) => ({
              destination: `${r.destination?.host}:${r.destination?.subset ?? 'default'}`,
              weight: r.weight ?? 100
            }))
          }))
        })), null, 2)
      }]
    };
  }
);

// ---- get_traffic_split ----
// Returns the current canary/stable split for a service
server.tool(
  'get_traffic_split',
  { namespace: z.string().default('default'), service_name: z.string().min(1) },
  async ({ namespace, service_name }) => {
    const vsList = await listIstioResource(namespace, 'virtualservices');

    const vs = vsList.find(v =>
      v.metadata?.name === service_name ||
      (v.spec?.hosts ?? []).some((h: string) => h === service_name || h.startsWith(service_name))
    );

    if (!vs) {
      throw new McpError(ErrorCode.InvalidParams, `No VirtualService found for service: ${service_name}`);
    }

    const splits = (vs.spec?.http ?? []).flatMap((h: any) =>
      (h.route ?? []).map((r: any) => ({
        destination: r.destination?.host,
        subset: r.destination?.subset ?? 'default',
        weight: r.weight ?? 100,
        port: r.destination?.port?.number
      }))
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          virtual_service: vs.metadata?.name,
          hosts: vs.spec?.hosts,
          traffic_split: splits
        }, null, 2)
      }]
    };
  }
);

// ---- set_traffic_split ----
// Update canary traffic split weights (e.g., 90% stable / 10% canary)
server.tool(
  'set_traffic_split',
  {
    namespace: z.string().default('default'),
    virtual_service_name: z.string().min(1),
    splits: z.array(z.object({
      subset: z.string(),
      weight: z.number().int().min(0).max(100)
    })).refine(splits => splits.reduce((sum, s) => sum + s.weight, 0) === 100, {
      message: 'Traffic split weights must sum to 100'
    }),
    confirm: z.literal(true)
  },
  async ({ namespace, virtual_service_name, splits }) => {
    // Fetch current VS
    const res = await customApi.getNamespacedCustomObject(
      ISTIO_NETWORKING.group, ISTIO_NETWORKING.version,
      namespace, 'virtualservices', virtual_service_name
    );

    const vs = res.body as any;
    if (!vs.spec?.http?.length) {
      throw new McpError(ErrorCode.InvalidParams, 'VirtualService has no http routes to update');
    }

    // Update the default route weights in the first http rule
    vs.spec.http[0].route = splits.map(s => ({
      destination: {
        host: vs.spec.http[0].route[0]?.destination?.host,
        subset: s.subset
      },
      weight: s.weight
    }));

    await customApi.replaceNamespacedCustomObject(
      ISTIO_NETWORKING.group, ISTIO_NETWORKING.version,
      namespace, 'virtualservices', virtual_service_name, vs
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          updated: virtual_service_name,
          new_split: splits
        })
      }]
    };
  }
);

// ---- list_peer_authentications ----
server.tool(
  'list_peer_authentications',
  { namespace: z.string().optional() },
  async ({ namespace }) => {
    const paList = await listIstioResource(namespace ?? null, 'peerauthentications', ISTIO_SECURITY);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify(paList.map(pa => ({
          name: pa.metadata?.name,
          namespace: pa.metadata?.namespace,
          // If no selector, applies to all workloads in namespace
          selector: pa.spec?.selector?.matchLabels ?? 'all workloads in namespace',
          mtls_mode: pa.spec?.mtls?.mode ?? 'UNSET (inherits from parent)',
          // Port-level overrides if any
          port_level_mtls: pa.spec?.portLevelMtls ?? null
        })), null, 2)
      }]
    };
  }
);

// ---- get_circuit_breaker ----
server.tool(
  'get_circuit_breaker',
  { namespace: z.string().default('default'), service_name: z.string().min(1) },
  async ({ namespace, service_name }) => {
    const drList = await listIstioResource(namespace, 'destinationrules');

    const dr = drList.find(d =>
      d.metadata?.name === service_name ||
      d.spec?.host === service_name
    );

    if (!dr) {
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            service: service_name,
            circuit_breaker: null,
            message: 'No DestinationRule found — Istio uses default settings (no circuit breaker)'
          })
        }]
      };
    }

    const outlierDetection = dr.spec?.trafficPolicy?.outlierDetection;
    const connectionPool = dr.spec?.trafficPolicy?.connectionPool;

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          destination_rule: dr.metadata?.name,
          host: dr.spec?.host,
          circuit_breaker: outlierDetection ? {
            consecutive_errors: outlierDetection.consecutiveGatewayErrors ?? outlierDetection.consecutive5xxErrors,
            interval: outlierDetection.interval ?? '10s',
            base_ejection_time: outlierDetection.baseEjectionTime ?? '30s',
            max_ejection_percent: outlierDetection.maxEjectionPercent ?? 10,
            min_health_percent: outlierDetection.minHealthPercent ?? 0
          } : null,
          connection_pool: connectionPool ? {
            max_connections: connectionPool.tcp?.maxConnections,
            http1_max_pending_requests: connectionPool.http?.http1MaxPendingRequests,
            http2_max_requests: connectionPool.http?.http2MaxRequests
          } : null
        }, null, 2)
      }]
    };
  }
);

Pilot debug API: checking proxy sync state

Istiod (Pilot) pushes xDS configuration to all Envoy sidecars in the mesh. When a VirtualService or DestinationRule changes, Istiod pushes the update — but sidecars may be temporarily out of sync. The Pilot debug API exposes the sync state for every connected sidecar, which is invaluable for diagnosing routing changes that don't take effect immediately.

// Access Pilot debug API via port-forward or in-cluster direct call
// Istiod listens on port 15014 for the debug endpoints

const ISTIOD_DEBUG_URL = process.env.ISTIOD_DEBUG_URL ?? 'http://istiod.istio-system:15014';

async function pilotDebugRequest(path: string): Promise<any> {
  const res = await fetch(`${ISTIOD_DEBUG_URL}${path}`, {
    signal: AbortSignal.timeout(10000)
  });

  if (!res.ok) throw new Error(`Pilot debug ${path} → ${res.status}`);
  return res.json();
}

// ---- check_proxy_sync ----
// Shows which sidecars are out of sync with the current xDS state
server.tool(
  'check_proxy_sync',
  { namespace_filter: z.string().optional() },
  async ({ namespace_filter }) => {
    const syncz = await pilotDebugRequest('/debug/syncz');

    // syncz returns array of { proxy, synced/not-synced status per xDS type }
    const statuses = (Array.isArray(syncz) ? syncz : syncz.syncz ?? []) as any[];

    const filtered = namespace_filter
      ? statuses.filter(s => s.proxy?.includes(`.${namespace_filter}.`))
      : statuses;

    const outOfSync = filtered.filter(s =>
      s.cluster_sent !== s.cluster_acked ||
      s.listener_sent !== s.listener_acked ||
      s.route_sent !== s.route_acked ||
      s.endpoint_sent !== s.endpoint_acked
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          total_proxies: filtered.length,
          out_of_sync: outOfSync.length,
          in_sync: filtered.length - outOfSync.length,
          out_of_sync_proxies: outOfSync.map(s => ({
            proxy: s.proxy,
            cluster_lag: (s.cluster_sent ?? 0) - (s.cluster_acked ?? 0),
            listener_lag: (s.listener_sent ?? 0) - (s.listener_acked ?? 0),
            route_lag: (s.route_sent ?? 0) - (s.route_acked ?? 0),
            endpoint_lag: (s.endpoint_sent ?? 0) - (s.endpoint_acked ?? 0)
          }))
        }, null, 2)
      }]
    };
  }
);
Debug endpoint What it shows Use for
/debug/syncz xDS send/ack counts per proxy Detecting stale sidecar configs after a push
/debug/config_dump Full xDS config for all proxies Deep debugging of routing decisions (large payload)
/debug/adsz Active xDS connections and subscriptions Counting connected proxies, seeing disconnected sidecars
/debug/push_status History of recent xDS pushes Debugging push frequency and trigger events
/healthz/ready Istiod readiness (cache warmed, xDS serving) Liveness/readiness probe for istiod pod

Health endpoint: /health/istio

import express from 'express';
import { kc } from './istio-client.js';

const app = express();

const ISTIOD_READINESS_URL = process.env.ISTIOD_READINESS_URL ?? 'http://istiod.istio-system:15014';
const ISTIOD_DEBUG_URL = process.env.ISTIOD_DEBUG_URL ?? 'http://istiod.istio-system:15014';

app.get('/health/istio', async (_req, res) => {
  const start = Date.now();

  try {
    // Layer 1: Istiod control plane readiness
    let istiodReady = false;
    let istiodError: string | null = null;

    try {
      const readyRes = await fetch(`${ISTIOD_READINESS_URL}/healthz/ready`, {
        signal: AbortSignal.timeout(5000)
      });
      istiodReady = readyRes.status === 200;
      if (!istiodReady) {
        istiodError = await readyRes.text();
      }
    } catch (e: any) {
      istiodReady = false;
      istiodError = e.message;
    }

    // Layer 2: Proxy sync state — are sidecars up to date?
    let syncSummary: any = null;
    let syncError: string | null = null;

    try {
      const syncz = await fetch(`${ISTIOD_DEBUG_URL}/debug/syncz`, {
        signal: AbortSignal.timeout(5000)
      }).then(r => r.json());

      const proxies = Array.isArray(syncz) ? syncz : syncz.syncz ?? [];
      const outOfSync = proxies.filter((s: any) =>
        (s.cluster_sent ?? 0) > (s.cluster_acked ?? 0) ||
        (s.listener_sent ?? 0) > (s.listener_acked ?? 0)
      );

      syncSummary = {
        total_proxies: proxies.length,
        out_of_sync: outOfSync.length,
        stale_proxies: outOfSync.slice(0, 5).map((s: any) => s.proxy)
      };
    } catch (e: any) {
      syncError = e.message;
    }

    const latencyMs = Date.now() - start;
    const ok = istiodReady && (syncSummary === null || syncSummary.out_of_sync === 0);

    res.status(ok ? 200 : 503).json({
      status: ok ? 'ok' : 'degraded',
      istiod: istiodReady ? 'ready' : 'not_ready',
      istiod_error: istiodError,
      proxy_sync: syncSummary,
      sync_error: syncError,
      latency_ms: latencyMs
    });
  } catch (err: any) {
    res.status(503).json({
      status: 'error',
      error: err.message,
      elapsed_ms: Date.now() - start
    });
  }
});

app.listen(3001);
Failure mode istiod /healthz/ready /debug/syncz Traffic impact
Istiod pod crash Connection refused Unavailable Sidecars keep last config — traffic continues with stale routing
Istiod not yet warm (startup) 503 "not ready" Empty proxy list New pods may not get injected; existing traffic continues
VirtualService push in progress 200 out_of_sync > 0 Traffic routing may be inconsistent across pods (some old, some new)
All sidecars synced 200 out_of_sync == 0 All routing changes have propagated

mTLS policy management with PeerAuthentication

// ---- set_mtls_mode ----
// Set mTLS mode for a namespace or specific workload
server.tool(
  'set_mtls_mode',
  {
    namespace: z.string().min(1),
    mode: z.enum(['STRICT', 'PERMISSIVE', 'DISABLE']),
    workload_selector: z.record(z.string()).optional()
      .describe('Optional: label selector to target specific pods. Omit to apply namespace-wide.'),
    name: z.string().default('default-mtls').describe('PeerAuthentication resource name'),
    confirm: z.literal(true)
  },
  async ({ namespace, mode, workload_selector, name }) => {
    const paSpec: any = {
      apiVersion: 'security.istio.io/v1beta1',
      kind: 'PeerAuthentication',
      metadata: { name, namespace },
      spec: {
        mtls: { mode },
        ...(workload_selector ? { selector: { matchLabels: workload_selector } } : {})
      }
    };

    // Check if PeerAuthentication already exists
    let exists = false;
    try {
      await customApi.getNamespacedCustomObject(
        'security.istio.io', 'v1beta1', namespace, 'peerauthentications', name
      );
      exists = true;
    } catch { }

    if (exists) {
      await customApi.replaceNamespacedCustomObject(
        'security.istio.io', 'v1beta1', namespace, 'peerauthentications', name, paSpec
      );
    } else {
      await customApi.createNamespacedCustomObject(
        'security.istio.io', 'v1beta1', namespace, 'peerauthentications', paSpec
      );
    }

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          action: exists ? 'updated' : 'created',
          name,
          namespace,
          mode,
          scope: workload_selector ? `pods with labels ${JSON.stringify(workload_selector)}` : 'all pods in namespace',
          note: mode === 'STRICT'
            ? 'All traffic to this namespace must now use mTLS. Non-Istio clients will be rejected.'
            : mode === 'PERMISSIVE'
            ? 'Both mTLS and plaintext traffic accepted. Use for migration.'
            : 'mTLS disabled. Only use for debugging or legacy integrations.'
        })
      }]
    };
  }
);
mTLS Mode Accepts mTLS Accepts plaintext Use case
STRICT Yes No — rejected with connection error Fully meshed namespace; all callers are Istio sidecars
PERMISSIVE Yes Yes Migration period; mix of meshed and non-meshed callers
DISABLE No Yes Debugging mTLS issues; legacy services that can't support mTLS

Frequently asked questions

How do I check if a sidecar is actually injected into a pod?

Use the kubectl get pod -o json output and check for a container named istio-proxy in the pod's spec.containers. Via the Kubernetes client: await coreApi.readNamespacedPod(podName, namespace), then check pod.body.spec?.containers?.some(c => c.name === 'istio-proxy'). Sidecar injection is controlled by the namespace label istio-injection: enabled and the pod annotation sidecar.istio.io/inject: "true". If injection is expected but not happening, check: (1) the namespace has istio-injection: enabled label; (2) the pod doesn't have sidecar.istio.io/inject: "false"; (3) istiod is healthy — a crashed istiod can't inject sidecars into new pods. Existing pods continue running without the sidecar until they restart.

What happens to traffic when istiod crashes?

Existing sidecars keep their last-pushed xDS configuration and continue routing traffic. This is by design — the data plane is decoupled from the control plane and doesn't require istiod to be running to forward traffic. What stops working: (1) new pods can't get sidecars injected (the mutating webhook requires istiod); (2) configuration changes (new VirtualServices, DestinationRules) won't be pushed to sidecars; (3) certificate rotation may fail if istiod is down for more than the certificate TTL (default 24h). For AliveMCP monitoring, an istiod crash is a high-severity alert because it blocks all mesh configuration changes, even though traffic itself continues.

How do I debug a VirtualService that isn't routing traffic as expected?

Follow this sequence: (1) check /debug/syncz to confirm the affected sidecar has received the latest config (cluster_acked should equal cluster_sent); (2) use istioctl proxy-config route <pod> --name 80 equivalent via the Envoy admin API on the sidecar (kubectl port-forward pod/<pod> 15000:15000, then GET /config_dump?resource=dynamic_route_configs); (3) check if the VirtualService's host field matches the service's hostname exactly — Istio uses FQDN matching and a VirtualService with host: my-svc in namespace A doesn't affect traffic in namespace B; (4) verify that the DestinationRule defines the subsets referenced by the VirtualService's route destinations — referencing an undefined subset causes Istio to send traffic to no endpoints.

How do I monitor Istio in ambient mesh mode (sidecarless)?

Istio ambient mesh (stable in Istio 1.23+) replaces per-pod Envoy sidecars with a per-node ztunnel proxy and an optional per-namespace waypoint proxy. The architecture change affects MCP health monitoring: instead of checking individual sidecar sync state, check the ztunnel DaemonSet health (kubectl get ds ztunnel -n istio-system) and, if L7 policy is needed, the waypoint deployment per namespace. The Pilot debug API still works for ambient mode — /debug/syncz shows ztunnel proxies rather than sidecars. The main difference: ambient mode can't be debugged with per-pod Envoy admin API port-forwarding since there's no Envoy sidecar — use kubectl exec -n istio-system ztunnel-<node-suffix> -- curl localhost:15000/config_dump instead.

Further reading

Know when istiod goes down before your mesh config changes stop propagating

AliveMCP monitors your /health/istio endpoint every 60 seconds and alerts you the moment istiod becomes unavailable — before engineers start wondering why VirtualService changes aren't taking effect.

Start monitoring free