MCP server shadow API security: undocumented tools, version drift, and stale endpoint discovery
Shadow APIs — tools registered in your MCP server that are never mentioned in documentation or changelogs — are one of the most underappreciated attack surface expansion mechanisms in MCP server development. They accumulate through debug tools left enabled in production, deprecated tools never removed, and version mismatches between what tools/list advertises and what handlers actually implement. An attacker who can call tools/list sees everything; your users and security reviewers see only what you wrote down.
How shadow tools form
Shadow tools aren't usually malicious in origin. They arise from three common development patterns:
Debug tools promoted to production. During development, engineers add temporary tools like debug_dump_state, reset_all_data, or show_config for local testing. These get committed, deployed, and forgotten — but they remain callable in production.
Deprecated tools never removed. A tool gets replaced by a new version but the old handler stays registered for "backward compatibility." Over time, the old tool stops being tested, stops receiving security patches, and starts drifting from the security posture of the rest of the server.
Version drift between schema and implementation. The registered JSON schema for a tool gets updated (tighter input validation, new required fields) but the underlying handler still accepts the old, wider input shape — or vice versa. Callers using the new schema see the stricter interface; an attacker who bypasses schema validation calls the old handler directly.
Discovery via tools/list enumeration
The MCP protocol's tools/list endpoint returns all registered tools with their full JSON Schema definitions. This is by design — clients need to know what tools are available. But it also means an attacker can enumerate your complete tool registry in one API call:
// What an attacker sees when they call tools/list:
{
"tools": [
{ "name": "get_report", "description": "Fetch a report by ID", ... },
{ "name": "create_report", "description": "Create a new report", ... },
{ "name": "debug_dump_all_reports", "description": "Debug: dumps all reports without auth", ... },
{ "name": "admin_reset_org_data", "description": "Admin only: resets all org data", ... }
]
}
The debug and admin tools in this example are immediately obvious to an attacker. Even if they require special permissions, their existence in the registry tells the attacker what sensitive operations the server supports — useful for social engineering and targeted exploitation.
Less obvious is the schema-drift case. An attacker who spots get_report_v1 and get_report_v2 can try calling get_report_v1 with payloads that get_report_v2 would reject — exploiting validation gaps in the older handler.
Defenses
1. Schema registry with CI enforcement
Maintain a canonical list of every tool your server is permitted to expose, and fail CI if the running server registers any tool not on the list:
// scripts/check-shadow-tools.ts
import { createServer } from "../src/server.js";
import { readFileSync } from "fs";
const APPROVED_TOOLS: string[] = JSON.parse(
readFileSync("./tool-registry.json", "utf8")
).tools;
const server = createServer();
const registered = server.listTools().map(t => t.name);
const shadows = registered.filter(name => !APPROVED_TOOLS.includes(name));
if (shadows.length > 0) {
console.error("Shadow tools detected (not in tool-registry.json):");
shadows.forEach(t => console.error(` - ${t}`));
process.exit(1);
}
console.log(`OK: ${registered.length} tools, all approved`);
The tool-registry.json file is the source of truth. Adding a new tool requires an explicit entry there — it becomes a code review artifact, not a runtime surprise.
2. Environment-gated debug tools
Never register debug tools unconditionally. Gate them behind an environment variable that is never set in production:
// src/server.ts
const server = new McpServer({ name: "skillaudit-mcp", version: "1.0.0" });
// Production tools — always registered
registerCoreTools(server);
// Debug tools — only in development
if (process.env.NODE_ENV === "development" && process.env.ENABLE_DEBUG_TOOLS === "true") {
registerDebugTools(server);
}
// Never register admin tools in the public-facing server process at all
// Admin operations go through a separate, authenticated admin API
Checking NODE_ENV !== "production" is not enough — staging environments often set NODE_ENV=production inconsistently. Use a separate, explicit ENABLE_DEBUG_TOOLS flag that is never set in any production or staging environment.
3. Explicit deprecation and removal schedule
When a tool is replaced, set a removal date and enforce it:
// Deprecated tool — removed after 2026-09-01
function registerDeprecatedTools(server: McpServer, cutoffDate: Date) {
if (new Date() > cutoffDate) return; // automatically stops registering
server.tool("get_report_v1", GetReportV1Schema, async (args, ctx) => {
// Log every call to deprecated tool for migration tracking
logger.warn("deprecated_tool_call", { tool: "get_report_v1", callerId: ctx.callerId });
// Delegate to v2 handler — don't maintain separate code
return getReportV2Handler(migrateV1ToV2Args(args), ctx);
});
}
registerDeprecatedTools(server, new Date("2026-09-01"));
The cutoff date is enforced at startup — once the date passes, the old tool simply doesn't register. No PR required for removal; no risk of forgetting.
4. Schema drift detection
If your JSON Schema definition and your Zod validation schema can diverge (common when one is generated from the other), add a CI step that verifies they match:
// scripts/check-schema-drift.ts
import { zodToJsonSchema } from "zod-to-json-schema";
import { GetReportSchema } from "../src/tools/get-report.js";
import { readFileSync } from "fs";
const currentJsonSchema = zodToJsonSchema(GetReportSchema);
const registeredSchema = JSON.parse(
readFileSync("./schemas/get-report.json", "utf8")
);
if (JSON.stringify(currentJsonSchema) !== JSON.stringify(registeredSchema)) {
console.error("Schema drift detected in get_report: Zod schema and JSON Schema file are out of sync");
console.error("Run: npm run generate-schemas");
process.exit(1);
}
SkillAudit findings for shadow tools
tools/list with no separate auth surface — existence disclosed to all callersRun a free SkillAudit on your MCP server to see if any of these findings appear in your report. Our static analysis scans server.tool() call sites for unconditional debug registrations and schema files for drift against in-code validators. Paste your GitHub URL →