Guide · MCP API Gateway Integration
MCP Server Kong Gateway — Admin API tools, upstream health checks, plugin management
Kong Gateway 3.x is one of the most widely deployed open-source API gateways, and its Admin API makes it a natural target for MCP tools that inspect traffic routing, diagnose upstream failures, or automate plugin configuration. This guide covers connecting to the Kong Admin API from TypeScript, building list_services, list_routes, check_upstream_health, and enable_plugin tools, understanding the difference between active and passive health checks, and wiring a /health/kong endpoint that AliveMCP can poll to detect when your Kong instance itself goes down.
TL;DR
Connect to the Kong Admin API at http://localhost:8001 using the Kong-Admin-Token header (Enterprise) or no auth (OSS on a private network). Use GET /services, GET /upstreams/:name/health, and POST /services/:id/plugins as your primary MCP tool endpoints. The critical health distinction: GET /health checks the Kong node process, while GET /upstreams/:name/health checks your backend target servers — both are needed. Register the upstream health endpoint with AliveMCP to detect degraded targets before traffic degrades for users.
Admin API authentication and client setup
Kong Gateway OSS exposes the Admin API on port 8001 with no built-in authentication — it must be bound to a private interface or protected by network policy. Kong Enterprise (Konnect or self-hosted) adds RBAC with a Kong-Admin-Token header. The client setup is straightforward: plain HTTP with headers.
import fetch from 'node-fetch';
const KONG_ADMIN_URL = process.env.KONG_ADMIN_URL ?? 'http://localhost:8001';
const KONG_ADMIN_TOKEN = process.env.KONG_ADMIN_TOKEN; // undefined on OSS
async function kongRequest(path: string, options: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {})
};
// Enterprise RBAC: token required; OSS: header is silently ignored
if (KONG_ADMIN_TOKEN) {
headers['Kong-Admin-Token'] = KONG_ADMIN_TOKEN;
}
const res = await fetch(`${KONG_ADMIN_URL}${path}`, {
...options,
headers
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Kong Admin API ${options.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
}
return res.json();
}
// Paginate through all results (Kong paginates at 100 by default)
async function kongPaginate(path: string): Promise<any[]> {
const results: any[] = [];
let next: string | null = `${path}?size=100`;
while (next) {
const data = await kongRequest(next.startsWith('http') ? next.replace(KONG_ADMIN_URL, '') : next);
results.push(...(data.data ?? []));
next = data.next ?? null;
// Kong returns absolute next URLs — strip the base
if (next?.startsWith(KONG_ADMIN_URL)) {
next = next.slice(KONG_ADMIN_URL.length);
}
}
return results;
}
export { kongRequest, kongPaginate };
The Kong Admin API uses pagination for collection endpoints. The response includes a data array and a next field containing the URL of the next page (absolute URL, not a path). The kongPaginate helper handles stripping the base URL from the next pointer so it can be used as a path.
| Edition | Auth method | Default Admin port | RBAC |
|---|---|---|---|
| Kong OSS | None (network protection) | 8001 (HTTP) / 8444 (HTTPS) | Not supported |
| Kong Enterprise | Kong-Admin-Token header |
8001 / 8444 | Admin roles: super-admin, read-only, custom |
| Konnect (cloud) | Personal access token (Bearer) | Konnect API endpoint | Workspace-scoped tokens |
Core Kong MCP tool patterns
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { kongRequest, kongPaginate } from './kong-client.js';
// ---- list_services ----
server.tool(
'list_services',
{ name_filter: z.string().optional().describe('Filter services by name substring') },
async ({ name_filter }) => {
const services = await kongPaginate('/services');
const filtered = name_filter
? services.filter(s => s.name?.toLowerCase().includes(name_filter.toLowerCase()))
: services;
return {
content: [{
type: 'text',
text: JSON.stringify(filtered.map(s => ({
id: s.id,
name: s.name,
protocol: s.protocol,
host: s.host,
port: s.port,
path: s.path,
enabled: s.enabled,
tags: s.tags ?? [],
connect_timeout_ms: s.connect_timeout,
retries: s.retries
})), null, 2)
}]
};
}
);
// ---- list_routes ----
server.tool(
'list_routes',
{ service_name: z.string().optional().describe('List routes for a specific service only') },
async ({ service_name }) => {
const path = service_name ? `/services/${encodeURIComponent(service_name)}/routes` : '/routes';
const routes = await kongPaginate(path);
return {
content: [{
type: 'text',
text: JSON.stringify(routes.map(r => ({
id: r.id,
name: r.name,
service_id: r.service?.id,
protocols: r.protocols,
methods: r.methods,
hosts: r.hosts,
paths: r.paths,
strip_path: r.strip_path,
preserve_host: r.preserve_host,
priority: r.priority ?? 0
})), null, 2)
}]
};
}
);
// ---- check_upstream_health ----
// Returns per-target health for a Kong upstream
server.tool(
'check_upstream_health',
{ upstream_name: z.string().min(1) },
async ({ upstream_name }) => {
try {
const health = await kongRequest(`/upstreams/${encodeURIComponent(upstream_name)}/health`);
const summary = {
upstream: health.data?.name ?? upstream_name,
algorithm: health.data?.algorithm ?? 'round-robin',
targets: (health.data?.targets ?? []).map((t: any) => ({
target: t.target,
weight: t.weight,
health: t.health, // HEALTHY | UNHEALTHY | DNS_ERROR | TIMEOUT
total_requests: t.data?.totals?.calls?.total,
unhealthy_count: t.data?.totals?.successes === undefined ? null : (t.data?.totals?.calls?.total - t.data?.totals?.successes)
})),
healthy_count: (health.data?.targets ?? []).filter((t: any) => t.health === 'HEALTHY').length,
unhealthy_count: (health.data?.targets ?? []).filter((t: any) => t.health !== 'HEALTHY').length
};
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
} catch (err: any) {
if (err.message.includes('404')) {
throw new McpError(ErrorCode.InvalidParams, `Upstream not found: ${upstream_name}`);
}
throw err;
}
}
);
// ---- list_plugins ----
server.tool(
'list_plugins',
{ service_name: z.string().optional(), route_id: z.string().optional() },
async ({ service_name, route_id }) => {
let path = '/plugins';
if (service_name) path = `/services/${encodeURIComponent(service_name)}/plugins`;
else if (route_id) path = `/routes/${route_id}/plugins`;
const plugins = await kongPaginate(path);
return {
content: [{
type: 'text',
text: JSON.stringify(plugins.map(p => ({
id: p.id,
name: p.name,
enabled: p.enabled,
protocols: p.protocols,
config: p.config,
tags: p.tags ?? []
})), null, 2)
}]
};
}
);
// ---- enable_plugin ----
// Enables a Kong plugin on a service (requires write permission in Enterprise RBAC)
server.tool(
'enable_plugin',
{
service_name: z.string().min(1),
plugin_name: z.enum(['rate-limiting', 'key-auth', 'jwt', 'cors', 'request-transformer',
'response-transformer', 'proxy-cache', 'bot-detection', 'ip-restriction']),
config: z.record(z.unknown()).describe('Plugin configuration object'),
confirm: z.literal(true)
},
async ({ service_name, plugin_name, config }) => {
const result = await kongRequest(`/services/${encodeURIComponent(service_name)}/plugins`, {
method: 'POST',
body: JSON.stringify({ name: plugin_name, config })
});
return {
content: [{
type: 'text',
text: JSON.stringify({
plugin_id: result.id,
service: service_name,
plugin: plugin_name,
enabled: result.enabled,
config: result.config
})
}]
};
}
);
The check_upstream_health tool returns per-target health status. A target can be in one of four states: HEALTHY, UNHEALTHY (failed health checks), DNS_ERROR (unable to resolve hostname), or TIMEOUT (health check timed out). This is the data that AliveMCP should monitor — not just whether Kong is running, but whether your backend targets are reachable.
Active vs passive health checks in Kong
Kong implements two orthogonal health check mechanisms for upstreams, and MCP tools that surface upstream health need to understand which one is active.
| Type | How it works | When to use | Config location |
|---|---|---|---|
| Active | Kong polls each target at a configured interval using HTTP/HTTPS/TCP | Low-traffic backends where passive checks won't fire often | upstream.healthchecks.active |
| Passive (circuit breaker) | Kong tracks success/failure ratios on real traffic | High-traffic services where any probe adds overhead | upstream.healthchecks.passive |
// Configure active health checks on an upstream via MCP tool
server.tool(
'configure_upstream_health_checks',
{
upstream_name: z.string().min(1),
http_path: z.string().default('/health'),
interval_seconds: z.number().int().min(1).max(300).default(10),
healthy_successes: z.number().int().min(1).max(255).default(2),
unhealthy_failures: z.number().int().min(1).max(255).default(3),
confirm: z.literal(true)
},
async ({ upstream_name, http_path, interval_seconds, healthy_successes, unhealthy_failures }) => {
const result = await kongRequest(`/upstreams/${encodeURIComponent(upstream_name)}`, {
method: 'PATCH',
body: JSON.stringify({
healthchecks: {
active: {
type: 'http',
http_path,
timeout: 5,
concurrency: 10,
healthy: {
interval: interval_seconds,
successes: healthy_successes,
http_statuses: [200, 302]
},
unhealthy: {
interval: interval_seconds,
tcp_failures: 0,
timeouts: unhealthy_failures,
http_failures: unhealthy_failures,
http_statuses: [429, 500, 502, 503, 504]
}
},
passive: {
type: 'http',
healthy: { successes: 5, http_statuses: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308] },
unhealthy: { tcp_failures: 0, timeouts: 3, http_failures: 3, http_statuses: [429, 500, 502, 503, 504] }
}
}
})
});
return { content: [{ type: 'text', text: JSON.stringify({ updated: upstream_name, healthchecks: result.healthchecks }) }] };
}
);
The healthy.interval and unhealthy.interval are separate in Kong's active health check config. When a target is already healthy, Kong checks it every healthy.interval seconds. When a target is unhealthy, Kong checks more aggressively using unhealthy.interval. Set the unhealthy interval to 5-10 seconds to recover targets quickly after a restart, while keeping the healthy interval at 30-60 seconds to reduce overhead on healthy targets.
Plugin precedence and scope
Kong applies plugins based on a specificity hierarchy. Understanding this is essential for MCP tools that need to diagnose why a plugin's config isn't applying as expected.
| Scope | Applies to | Specificity (higher wins) |
|---|---|---|
| Global plugin | All requests through Kong | 1 (lowest) |
| Service plugin | All routes on a specific service | 2 |
| Route plugin | One specific route | 3 |
| Consumer plugin | Requests from a specific consumer | 4 (highest) |
// Audit plugin precedence for a specific request path
server.tool(
'audit_route_plugins',
{ service_name: z.string().min(1), route_name: z.string().optional() },
async ({ service_name, route_name }) => {
// Collect plugins at global, service, and route scope
const [globalPlugins, servicePlugins] = await Promise.all([
kongPaginate('/plugins'),
kongPaginate(`/services/${encodeURIComponent(service_name)}/plugins`)
]);
let routePlugins: any[] = [];
if (route_name) {
// Find route by name to get its ID
const routes = await kongPaginate(`/services/${encodeURIComponent(service_name)}/routes`);
const route = routes.find(r => r.name === route_name);
if (route) {
routePlugins = await kongPaginate(`/routes/${route.id}/plugins`);
}
}
// Group by plugin name to show overrides
const pluginNames = new Set([
...globalPlugins.map(p => p.name),
...servicePlugins.map(p => p.name),
...routePlugins.map(p => p.name)
]);
const audit = Array.from(pluginNames).map(name => ({
plugin: name,
global: globalPlugins.find(p => p.name === name)?.enabled ? 'enabled' : null,
service: servicePlugins.find(p => p.name === name)?.enabled ? 'enabled' : null,
route: routePlugins.find(p => p.name === name)?.enabled ? 'enabled' : null,
effective_scope: routePlugins.find(p => p.name === name)
? 'route' : servicePlugins.find(p => p.name === name)
? 'service' : 'global'
}));
return { content: [{ type: 'text', text: JSON.stringify(audit, null, 2) }] };
}
);
Health endpoint: /health/kong
Kong exposes its own node health at /health on the Admin API, but that endpoint only reports whether the Kong node process is running. A more useful health probe for AliveMCP checks both the Kong process and the health of your most critical upstream.
import express from 'express';
import { kongRequest } from './kong-client.js';
const app = express();
const CRITICAL_UPSTREAM = process.env.KONG_CRITICAL_UPSTREAM; // e.g., 'api-backend'
app.get('/health/kong', async (_req, res) => {
const start = Date.now();
try {
// Layer 1: Kong node health
const nodeHealth = await kongRequest('/health');
const nodeOk = nodeHealth.status === 'ok';
let upstreamHealth: any = null;
let upstreamOk = true;
// Layer 2: Critical upstream target health (if configured)
if (CRITICAL_UPSTREAM) {
const uh = await kongRequest(`/upstreams/${CRITICAL_UPSTREAM}/health`);
const targets = uh.data?.targets ?? [];
const unhealthy = targets.filter((t: any) => t.health !== 'HEALTHY');
upstreamOk = targets.length > 0 && unhealthy.length === 0;
upstreamHealth = {
upstream: CRITICAL_UPSTREAM,
total_targets: targets.length,
healthy: targets.filter((t: any) => t.health === 'HEALTHY').length,
unhealthy: unhealthy.length,
unhealthy_targets: unhealthy.map((t: any) => ({ target: t.target, status: t.health }))
};
}
const ok = nodeOk && upstreamOk;
const latencyMs = Date.now() - start;
res.status(ok ? 200 : 503).json({
status: ok ? 'ok' : 'degraded',
kong_node: nodeHealth.status,
upstream: upstreamHealth,
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 | /health (node) | /upstreams/:name/health | Detected |
|---|---|---|---|
| Kong process crash | Connection refused | N/A | Yes — 503 immediately |
| All backend targets down | 200 ok | Targets show UNHEALTHY | Yes — if upstream check included |
| One of three targets unhealthy | 200 ok | Mixed health | Yes — unhealthy_count > 0 |
| DNS failure for upstream host | 200 ok | Target shows DNS_ERROR | Yes — health !== HEALTHY |
| Kong DB (Postgres) unavailable | 200 ok (routes cached) | Depends on config | Only via /status endpoint on 8100 |
Kong separates its node health (/health on port 8001) from its status (/status on port 8100). The /health endpoint reports whether the data plane can serve traffic. For DB-backed Kong, the /status endpoint also exposes database connection state — but for most AliveMCP monitoring purposes, the upstream target health is more actionable than the DB connection state.
Declarative config with deck
The deck CLI tool synchronizes Kong configuration from a YAML file. For MCP tools that modify Kong config, the safest pattern is dry-run before apply: compute the diff, surface it in the tool response, and require confirmation before applying.
// MCP tool wrapping deck diff + apply
server.tool(
'apply_kong_config',
{
config_yaml: z.string().min(1).describe('Full deck YAML config to apply'),
dry_run: z.boolean().default(true).describe('If true, show diff without applying')
},
async ({ config_yaml, dry_run }) => {
const { execFile } = await import('child_process');
const { writeFile, unlink } = await import('fs/promises');
const path = await import('path');
const os = await import('os');
// Write config to temp file
const tmpFile = path.join(os.tmpdir(), `kong-config-${Date.now()}.yaml`);
await writeFile(tmpFile, config_yaml, 'utf-8');
try {
const deckArgs = dry_run
? ['diff', '--config', tmpFile, '--kong-addr', process.env.KONG_ADMIN_URL ?? 'http://localhost:8001']
: ['sync', '--config', tmpFile, '--kong-addr', process.env.KONG_ADMIN_URL ?? 'http://localhost:8001'];
if (process.env.KONG_ADMIN_TOKEN) {
deckArgs.push('--headers', `Kong-Admin-Token:${process.env.KONG_ADMIN_TOKEN}`);
}
const output = await new Promise<string>((resolve, reject) => {
execFile('deck', deckArgs, (err, stdout, stderr) => {
if (err && !dry_run) reject(new Error(stderr || err.message));
else resolve(stdout + (stderr ? `\nSTDERR: ${stderr}` : ''));
});
});
return { content: [{ type: 'text', text: JSON.stringify({ dry_run, output }) }] };
} finally {
await unlink(tmpFile).catch(() => {});
}
}
);
Always run deck diff before deck sync in production. The diff output shows exactly which routes, services, and plugins would be created, updated, or deleted. Surface this in the MCP tool response before asking the agent (or user) to confirm the dry_run: false call.
Frequently asked questions
How do I find which upstream a service uses?
A Kong service's host field can point directly to an IP/hostname (DNS-based routing) or to an upstream name (load-balanced routing). If service.host matches an upstream in GET /upstreams, Kong routes through that upstream's targets. If it's an arbitrary hostname, Kong resolves it via DNS with no health-checking. Use this MCP tool pattern to identify services using upstreams: fetch all services, then filter those whose host value appears in the upstreams list. Services pointing at raw hostnames won't appear in /upstreams/:name/health and won't benefit from Kong's circuit breaker — they'll only fail when real traffic hits a bad target.
What's the difference between Kong OSS and Enterprise for MCP tools?
The Admin API schema is identical between OSS and Enterprise for core objects (services, routes, upstreams, plugins). Enterprise adds RBAC tokens, workspaces, and additional plugins (mTLS, OPA, advanced rate limiting). For MCP tools: OSS has no auth on the Admin API (rely on network isolation); Enterprise uses Kong-Admin-Token header. Konnect (Kong's cloud offering) uses a different base URL and personal access tokens (Bearer) for its control plane API, with a separate data plane that runs locally. If you're building MCP tools for Konnect, use the Konnect API at https://us.api.konghq.com/v2/ instead of the local Admin API.
How do I safely PATCH a Kong upstream without overwriting health check config?
Kong's PATCH endpoint merges top-level fields but replaces nested objects entirely. If you PATCH /upstreams/:name with { "slots": 200 }, only the slots field changes — safe. But if you PATCH with { "healthchecks": { "active": { "interval": 10 } } }, you'll overwrite the entire healthchecks object and lose passive check config. The safe pattern: always fetch the current upstream with GET /upstreams/:name first, merge your changes into the returned object in TypeScript, then PATCH the full merged object. Never send partial nested objects to Kong PATCH endpoints.
How do I test Kong Admin API tools without a live Kong instance?
Run Kong in Docker with a SQLite-based DB-less mode for testing: docker run -d --name kong -e KONG_DATABASE=off -e KONG_ADMIN_LISTEN="0.0.0.0:8001" -p 8001:8001 -p 8000:8000 kong:3 kong start --declarative-config /dev/null. DB-less mode doesn't persist config between restarts but is ideal for unit testing MCP tools — load a test config via deck, exercise your tools, then discard the container. For integration tests in CI, use docker-compose with a Postgres container and the Kong official image.
Further reading
- MCP Server Traefik — Router inspection, middleware management, and /ping health monitoring
- MCP Server Envoy — Admin API cluster health, config dump, and graceful drain via /healthcheck
- MCP Server nginx — reverse proxy configuration and health monitoring patterns
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Server API Gateway — generic patterns for gateway tool development