Topic: mcp server api versioning security
MCP server API versioning security — version negotiation and schema drift
Versioning an MCP server's tool API — adding a v2 variant of a tool with a changed schema, deprecating a v1 tool, supporting both during a migration window — introduces security risks that don't exist in a single-version server. The four failure modes are: version downgrade attacks (a prompt-injected LLM forces use of the older, less-validated version), schema drift (v1 and v2 have different validation rules, and v1's weaker validation is still reachable), deprecated endpoint reactivation (a tool removed from the schema is still routed and processed), and differential security controls (rate limiting, authentication, and SSRF checks are applied to v2 but not v1). Each has a concrete mitigation.
Attack 1: Version downgrade
If a server exposes both search_v1 and search_v2 tools, and v1 has weaker input validation (because the security fixes landed in v2), a prompt-injected LLM can explicitly call search_v1 to bypass the improved controls:
// WRONG: both versions in the schema with different validation strength
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'search_v1') {
// v1: minimal validation, shipped before SSRF fix
const { query } = request.params.arguments
return await fetchSearchResults(query) // no SSRF check
}
if (request.params.name === 'search_v2') {
// v2: full validation including SSRF check
const { query } = SearchV2Schema.parse(request.params.arguments)
if (isSsrfTarget(query)) throw new Error('SSRF target blocked')
return await fetchSearchResults(query)
}
})
// CORRECT during migration: expose only v2 in the schema; v1 still handled for
// existing clients but with the same validation as v2
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name
if (!['search_v2', 'search_v1'].includes(toolName)) return // not our tool
// Apply v2 validation to ALL versions — the validation is the floor, not per-version
const { query } = SearchV2Schema.parse(request.params.arguments)
if (isSsrfTarget(query)) throw new Error('SSRF target blocked')
return await fetchSearchResults(query)
})
Attack 2: Schema drift between versions
The common source of schema drift: v1 uses a hand-written validation function, v2 introduces Zod. The Zod schema gets a new validation rule (e.g., URL allowlisting), but the hand-written v1 function is not updated because "v1 is deprecated." As long as v1 is still callable, its weaker validation is the real attack surface.
// PATTERN: shared validation layer called by all versions
import { z } from 'zod'
// Define the security-relevant validation in ONE place
const ALLOWED_DOMAINS = ['api.github.com', 'api.linear.app']
const BaseToolInput = z.object({
url: z.string().url().refine(
u => ALLOWED_DOMAINS.includes(new URL(u).hostname),
{ message: 'URL domain not in allowlist' }
),
query: z.string().max(500).trim(),
})
// All versions use the base validation, then version-specific extensions
const V1Input = BaseToolInput // same security baseline
const V2Input = BaseToolInput.extend({
format: z.enum(['json', 'markdown']).default('json'),
})
// Handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const schema = request.params.name === 'search_v2' ? V2Input : V1Input
const args = schema.parse(request.params.arguments) // security validation is shared
return await executeTool(args)
})
Attack 3: Deprecated endpoint reactivation
Removing a tool from the MCP schema (the ListToolsRequestSchema response) does not prevent a client from calling it. The JSON-RPC handler still processes any tools/call request with the deprecated tool name. A prompt-injected LLM that has seen the old tool name (from conversation history or a public schema snapshot) can call it directly.
// WRONG: removed from schema listing but handler still active
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{ name: 'search_v2', description: '...', inputSchema: {...} }]
// search_v1 removed from the listing — but the handler below still processes it
}))
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'search_v1') {
return await legacySearch(request.params.arguments) // still reachable!
}
if (request.params.name === 'search_v2') {
return await modernSearch(request.params.arguments)
}
})
// CORRECT: explicit rejection of deprecated tool names
const ACTIVE_TOOLS = new Set(['search_v2'])
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (!ACTIVE_TOOLS.has(request.params.name)) {
throw new McpError(
ErrorCode.MethodNotFound,
`Tool '${request.params.name}' is not available. Use 'search_v2'.`
)
}
return await modernSearch(SearchV2Schema.parse(request.params.arguments))
})
Attack 4: Differential security controls across versions
When security middleware (rate limiting, SSRF checks, authentication) is applied at the tool handler level rather than the transport level, version-specific handlers can inadvertently bypass controls. The fix is to apply all security controls before version routing:
// PATTERN: security middleware applied at the transport layer, before tool dispatch
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
const server = new Server(/* config */)
// Wrap the request handler with a security middleware chain
function withSecurityControls(handler: RequestHandler): RequestHandler {
return async (request, extra) => {
// 1. Authentication — applies to ALL tools, all versions
const session = validateSession(extra) // throws if unauthenticated
// 2. Rate limiting — applies to ALL tools, all versions
await rateLimit(session.tenantId, request.params.name) // throws if over limit
// 3. Audit log — applies to ALL tools, all versions
await auditLog({ tenantId: session.tenantId, tool: request.params.name, args: REDACTED })
// Now dispatch to the version-specific handler
return handler(request, { ...extra, session })
}
}
server.setRequestHandler(CallToolRequestSchema, withSecurityControls(async (request, extra) => {
// All security controls already applied by the wrapper
if (!ACTIVE_TOOLS.has(request.params.name)) throw new McpError(ErrorCode.MethodNotFound, '...')
return await dispatchTool(request.params.name, request.params.arguments, extra.session)
}))
What SkillAudit checks
- Deprecated tool names still handled without rejection — WARN; tool reactivation by name
- Per-version validation logic (not shared) — WARN; schema drift risk when security rules are added
- Authentication or rate limiting applied per-tool instead of at transport layer — WARN; differential controls between versions
See also
- MCP server input sanitization — shared validation patterns using Zod
- MCP server rate limit bypass — rate limiting architecture for multi-version servers
- MCP server security checklist — pre-publish checklist including versioning hygiene
- Public audit corpus — access control and versioning findings
Check your versioned MCP server for downgrade and schema drift findings.
Run a free audit → How grading works →