Guide · MCP API Gateway Integration
MCP Server Apache APISIX — Admin API tools, active health checks, plugin management, etcd dependency
Apache APISIX is a high-performance API gateway built on OpenResty (nginx + LuaJIT) that stores all configuration in etcd. Its Admin API exposes the full routing and upstream configuration, making it a rich target for MCP tools that inspect routes, manage plugins, check upstream health, and diagnose connectivity issues. This guide covers Admin API authentication, building list_routes, check_upstream_health, enable_plugin, and audit_plugin_priority tools, the critical etcd dependency that MCP health probes must account for, and the two-layer /health/apisix endpoint that monitors both the data plane and the control store.
TL;DR
Connect to the APISIX Admin API on port 9180 using the X-API-KEY header (default: edd1c9f034335f136f87ad84b625c8f1 — change this immediately). Use GET /apisix/admin/routes, GET /apisix/admin/upstreams, and GET /v1/healthcheck (port 9090) for route and upstream health. The critical gotcha: APISIX can serve cached traffic even when etcd is down — so a successful data-plane request doesn't mean configuration changes will persist. Always include an etcd connectivity check in your health probe alongside the data-plane check.
Admin API authentication and client setup
APISIX uses a static API key for Admin API authentication, specified in config.yaml under deployment.admin.admin_key. Unlike Kong's Bearer token, APISIX uses the X-API-KEY header. The key has a role property: admin (full access) or viewer (read-only).
# config.yaml — APISIX configuration
deployment:
role: traditional
role_traditional:
config_provider: etcd
admin:
admin_listen:
ip: 0.0.0.0
port: 9180
admin_key:
- name: admin
key: YOUR_STRONG_RANDOM_KEY_HERE # Change from default immediately
role: admin
- name: viewer
key: YOUR_VIEWER_KEY_HERE
role: viewer
// TypeScript client for APISIX Admin API
const APISIX_ADMIN_URL = process.env.APISIX_ADMIN_URL ?? 'http://localhost:9180';
const APISIX_API_KEY = process.env.APISIX_API_KEY;
if (!APISIX_API_KEY) {
throw new Error('APISIX_API_KEY environment variable required');
}
async function apisixRequest(path: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(`${APISIX_ADMIN_URL}${path}`, {
...options,
headers: {
'X-API-KEY': APISIX_API_KEY!,
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {})
}
});
if (!res.ok) {
const body = await res.text();
throw new Error(`APISIX Admin API ${options.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
}
// APISIX returns 204 No Content for successful DELETEs
if (res.status === 204) return null;
return res.json();
}
// Control plane API (port 9090) — separate port for status and health checks
const APISIX_CONTROL_URL = process.env.APISIX_CONTROL_URL ?? 'http://localhost:9090';
async function apisixControlRequest(path: string): Promise<any> {
const res = await fetch(`${APISIX_CONTROL_URL}${path}`);
if (!res.ok) {
throw new Error(`APISIX control API ${path} → ${res.status}`);
}
return res.json();
}
export { apisixRequest, apisixControlRequest };
| Port | API | Auth | Use for |
|---|---|---|---|
| 9180 | Admin API /apisix/admin/* |
X-API-KEY header | Route/upstream/plugin management |
| 9090 | Control API /v1/* |
None (localhost only) | Health checks, schema, plugin info |
| 9091 | Prometheus metrics /apisix/prometheus/oc |
None (bind to private IP) | Metric scraping |
| 9080 / 9443 | Data plane (HTTP/HTTPS) | Configured by routes | Actual API traffic |
Core APISIX MCP tool patterns
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { apisixRequest, apisixControlRequest } from './apisix-client.js';
// ---- list_routes ----
server.tool(
'list_routes',
{ service_id: z.string().optional().describe('Filter routes by service ID') },
async ({ service_id }) => {
const data = await apisixRequest('/apisix/admin/routes');
// APISIX returns { list: [ { key: "/apisix/routes/1", value: {...} } ] }
const routes = (data.list ?? []) as any[];
const filtered = service_id
? routes.filter(r => r.value?.service_id === service_id)
: routes;
return {
content: [{
type: 'text',
text: JSON.stringify(filtered.map(r => ({
id: r.value?.id,
name: r.value?.name,
uri: r.value?.uri,
uris: r.value?.uris,
methods: r.value?.methods,
host: r.value?.host,
hosts: r.value?.hosts,
service_id: r.value?.service_id,
upstream_id: r.value?.upstream_id,
plugins: Object.keys(r.value?.plugins ?? {}),
status: r.value?.status === 1 ? 'enabled' : 'disabled',
priority: r.value?.priority ?? 0
})), null, 2)
}]
};
}
);
// ---- list_upstreams ----
server.tool(
'list_upstreams',
{},
async () => {
const data = await apisixRequest('/apisix/admin/upstreams');
const upstreams = (data.list ?? []) as any[];
return {
content: [{
type: 'text',
text: JSON.stringify(upstreams.map(u => ({
id: u.value?.id,
name: u.value?.name,
type: u.value?.type ?? 'roundrobin',
scheme: u.value?.scheme ?? 'http',
nodes: u.value?.nodes,
retries: u.value?.retries,
retry_timeout: u.value?.retry_timeout,
timeout: u.value?.timeout,
// health checks configured on this upstream
active_health_check: u.value?.checks?.active ? {
http_path: u.value.checks.active.http_path,
interval: u.value.checks.active.healthy?.interval,
unhealthy_failures: u.value.checks.active.unhealthy?.http_failures
} : null,
passive_health_check: !!u.value?.checks?.passive
})), null, 2)
}]
};
}
);
// ---- check_upstream_health ----
// Uses the control API /v1/healthcheck which returns actual health state
server.tool(
'check_upstream_health',
{ upstream_id: z.string().optional().describe('Check specific upstream; omit for all') },
async ({ upstream_id }) => {
// /v1/healthcheck returns health state for all upstreams that have active health checks configured
const health = await apisixControlRequest('/v1/healthcheck');
let result = health;
if (upstream_id) {
// Filter to specific upstream
if (Array.isArray(health)) {
result = health.filter((h: any) => h.name?.includes(upstream_id) || h.id === upstream_id);
}
}
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
);
// ---- enable_plugin_on_route ----
server.tool(
'enable_plugin_on_route',
{
route_id: z.string().min(1),
plugin_name: z.string().min(1),
plugin_config: z.record(z.unknown()),
confirm: z.literal(true)
},
async ({ route_id, plugin_name, plugin_config }) => {
// Fetch current route to merge, not overwrite
const current = await apisixRequest(`/apisix/admin/routes/${route_id}`);
const existingPlugins = current.value?.plugins ?? {};
const updated = await apisixRequest(`/apisix/admin/routes/${route_id}`, {
method: 'PATCH',
body: JSON.stringify({
plugins: {
...existingPlugins,
[plugin_name]: plugin_config
}
})
});
return {
content: [{
type: 'text',
text: JSON.stringify({
route_id,
plugin: plugin_name,
active_plugins: Object.keys(updated.value?.plugins ?? {})
})
}]
};
}
);
// ---- configure_upstream_health_check ----
server.tool(
'configure_upstream_health_check',
{
upstream_id: z.string().min(1),
health_path: z.string().default('/health'),
check_interval: z.number().int().default(1).describe('Active check interval in seconds'),
unhealthy_failures: z.number().int().default(3),
healthy_successes: z.number().int().default(2),
confirm: z.literal(true)
},
async ({ upstream_id, health_path, check_interval, unhealthy_failures, healthy_successes }) => {
const result = await apisixRequest(`/apisix/admin/upstreams/${upstream_id}`, {
method: 'PATCH',
body: JSON.stringify({
checks: {
active: {
type: 'http',
http_path: health_path,
timeout: 1,
concurrency: 10,
healthy: {
interval: check_interval,
successes: healthy_successes,
http_statuses: [200, 201, 204]
},
unhealthy: {
interval: check_interval,
http_failures: unhealthy_failures,
tcp_failures: unhealthy_failures,
http_statuses: [429, 500, 502, 503, 504]
}
}
}
})
});
return { content: [{ type: 'text', text: JSON.stringify({ updated: upstream_id, checks: result.value?.checks }) }] };
}
);
The PATCH method on APISIX Admin API merges top-level fields but replaces nested objects. Always fetch the current resource and merge in your changes before sending a PATCH — especially for plugins objects, where a partial PATCH would delete existing plugins not included in the request body.
Plugin priority system and ordering
APISIX executes plugins in priority order — higher priority numbers run first. Plugin priority is fixed per plugin type, but you need to understand the order for debugging authentication + rate limiting interactions.
| Plugin | Priority | Phase | Notes |
|---|---|---|---|
grpc-transcode |
506 | rewrite | Protocol translation |
key-auth |
2500 | rewrite | API key authentication |
jwt-auth |
2510 | rewrite | JWT bearer token auth |
limit-req |
1001 | access | Request rate limiting |
limit-conn |
1003 | access | Connection count limiting |
limit-count |
1002 | access | Token bucket rate limiting |
ip-restriction |
3000 | access | IP whitelist/blacklist |
proxy-rewrite |
1008 | rewrite | Path/header rewriting |
// Audit which plugins are active on a route and in what order
server.tool(
'audit_route_plugins',
{ route_id: z.string().min(1) },
async ({ route_id }) => {
const [route, pluginSchemas] = await Promise.all([
apisixRequest(`/apisix/admin/routes/${route_id}`),
apisixControlRequest('/v1/plugins?all=true')
]);
const activePlugins = route.value?.plugins ?? {};
// Get priority for each active plugin from the schema endpoint
const withPriority = Object.entries(activePlugins).map(([name, config]) => {
const schema = (pluginSchemas as any[]).find(p => p.name === name);
return {
name,
priority: schema?.priority ?? 'unknown',
enabled: (config as any).disable !== true,
config
};
}).sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0));
return {
content: [{
type: 'text',
text: JSON.stringify({
route_id,
plugin_count: withPriority.length,
execution_order: withPriority
}, null, 2)
}]
};
}
);
Health endpoint: /health/apisix (with etcd check)
APISIX has a critical architectural dependency: all configuration is stored in etcd. If etcd goes down, APISIX continues serving traffic from its in-memory cache — but any configuration changes (new routes, plugin updates) will fail silently. Your health probe must check both the data plane AND etcd connectivity.
import express from 'express';
import { apisixRequest, apisixControlRequest } from './apisix-client.js';
const app = express();
app.get('/health/apisix', async (_req, res) => {
const start = Date.now();
try {
// Layer 1: Data plane — can APISIX serve traffic?
// The control API /v1/healthcheck indicates the gateway's own health
let dataPlaneOk = false;
let healthCheckData: any = null;
try {
healthCheckData = await apisixControlRequest('/v1/healthcheck');
dataPlaneOk = true;
} catch (e: any) {
// Control API unreachable means APISIX process may be down
dataPlaneOk = false;
}
// Layer 2: etcd connectivity — can configuration be written?
// If etcd is down, GET /apisix/admin/routes still returns cached data (200)
// but writes will fail. Test by checking etcd health via APISIX's etcd endpoints.
let etcdOk = false;
let etcdError: string | null = null;
try {
// Attempt a lightweight Admin API read — uses etcd under the hood
// If etcd is partitioned, this will return stale data with no error.
// For a true etcd check, query etcd directly if accessible:
const etcdUrl = process.env.ETCD_URL ?? 'http://localhost:2379';
const etcdRes = await fetch(`${etcdUrl}/health`, { signal: AbortSignal.timeout(3000) });
etcdOk = etcdRes.status === 200;
if (!etcdOk) {
const body = await etcdRes.text();
etcdError = `etcd /health returned ${etcdRes.status}: ${body}`;
}
} catch (e: any) {
etcdOk = false;
etcdError = e.message;
}
// Check upstream health from the healthcheck endpoint
const unhealthyUpstreams = Array.isArray(healthCheckData)
? healthCheckData.filter((h: any) => {
const nodes = h.nodes ?? {};
return Object.values(nodes).some((status: any) => status !== 'healthy');
})
: [];
const latencyMs = Date.now() - start;
const ok = dataPlaneOk && etcdOk && unhealthyUpstreams.length === 0;
res.status(ok ? 200 : 503).json({
status: ok ? 'ok' : 'degraded',
data_plane: dataPlaneOk ? 'ok' : 'down',
etcd: etcdOk ? 'ok' : 'down',
etcd_error: etcdError,
upstream_health: {
total: Array.isArray(healthCheckData) ? healthCheckData.length : 0,
unhealthy: unhealthyUpstreams.length,
unhealthy_names: unhealthyUpstreams.map((h: any) => h.name)
},
warning: !etcdOk ? 'APISIX is serving cached config. New routes and plugin changes will fail until etcd recovers.' : null,
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 | Data plane behavior | Admin API behavior | etcd /health |
|---|---|---|---|
| APISIX process crash | No traffic served | Connection refused | Still responds (separate process) |
| etcd down, APISIX running | Serves cached routes | Reads return cached data; writes fail | 503 or connection refused |
| Backend upstream unhealthy | 502 to upstream requests | /v1/healthcheck shows unhealthy nodes | Unaffected |
| Active health check not configured | Blind — no health data | /v1/healthcheck returns empty or [] | Unaffected |
Frequently asked questions
What's the difference between upstream active and passive health checks in APISIX?
Active health checks (checks.active) are periodic probes that APISIX sends to each upstream node's health endpoint. They run regardless of real traffic and can detect failures before any user request hits the broken node. Passive health checks (checks.passive) observe actual traffic responses and mark nodes unhealthy based on error thresholds — they require real traffic to trigger, so a node added to an upstream with zero traffic will appear healthy until a request hits it. For MCP servers where the upstream handles low-traffic but critical requests, use active health checks. For high-traffic services where active checks would add noise, use passive health checks. The /v1/healthcheck control API only reflects active health check state — passive state is tracked internally and affects routing but isn't exposed via the healthcheck endpoint.
Why does GET /apisix/admin/routes succeed even when etcd is down?
APISIX reads all route configuration from etcd at startup and caches it in nginx's shared memory dict. When etcd goes down, APISIX continues serving and reading from this cache — the Admin API GET endpoints return data from the same cache, which is why reads succeed. However, POST/PUT/PATCH/DELETE operations write to etcd first and return an error if etcd is unreachable. The misleading part is that this etcd write failure doesn't surface in data-plane health checks — APISIX will continue routing all existing traffic normally. This is why your health probe must check etcd directly, not just the Admin API GET or data-plane connectivity.
How do I change the default Admin API key to something secure?
The default APISIX Admin API key (edd1c9f034335f136f87ad84b625c8f1) is well-known and must be changed before deployment. Update config.yaml under deployment.admin.admin_key[].key with a randomly generated 32+ character key: openssl rand -hex 32. Restart APISIX after changing. Store the key in your secrets manager (AWS Secrets Manager, Kubernetes Secret, HashiCorp Vault) and inject via environment variable in your MCP server. Never commit the key to source control. For environments where the Admin API must be exposed on a non-localhost interface, add IP allow-listing at the nginx level or a separate reverse proxy with mutual TLS.
How do I handle APISIX routes that use global plugin_configs?
APISIX supports plugin_configs (reusable plugin bundles) that can be attached to routes via plugin_config_id. When a route has a plugin_config_id, its effective plugin set is the merge of the route's own plugins and the referenced plugin_config. Your MCP audit_route_plugins tool should also fetch /apisix/admin/plugin_configs/:id when a route has a plugin_config_id — otherwise the plugin audit will be incomplete. Similarly, routes can reference a service_id, which itself may have plugins. The full effective plugin set is: plugin_config plugins → service plugins → route plugins (route wins on conflict).
Further reading
- MCP Server Kong Gateway — Admin API tools, upstream health checks, plugin management
- MCP Server Traefik — Router inspection, middleware management, and /ping health monitoring
- MCP Server Envoy — Admin API cluster health, config dump, and graceful drain
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Server API Gateway — generic patterns for gateway tool development