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
- MCP Server Envoy — Admin API cluster health, config dump, and graceful drain
- MCP Server Kubernetes — kubectl client patterns for agent tools
- MCP Server Kong Gateway — Admin API tools, upstream health checks, plugin management
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Tools for DevOps — CI/CD pipelines, Kubernetes, and infrastructure automation