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

Know when Kong starts dropping traffic before your users notice

AliveMCP polls your /health/kong endpoint every 60 seconds and alerts you the moment upstream targets go unhealthy — before Kong's circuit breaker starts returning 503s to real traffic.

Start monitoring free