Security·Attack Surface·Shadow APIs

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

HIGHDebug tool registered in production with no auth check — direct data access without authentication
HIGHAdmin tool discoverable via tools/list with no separate auth surface — existence disclosed to all callers
MEDIUMDeprecated tool still registered — receives no security patches and may have weaker validation than current tools
MEDIUMSchema drift detected: registered JSON Schema is more permissive than Zod validation — bypassable via direct handler invocation
LOWNo tool registry file — no mechanism to detect shadow tools in CI before deployment

Run 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 →