MCP Server Security · API Versioning · Schema Evolution
MCP Server API Versioning and Backward Compatibility Security: Schema Evolution Attack Surfaces, Version Negotiation Exploits, and Forced-Upgrade Sunset Strategy
API versioning is almost always framed as a developer experience problem — how do you evolve a tool schema without breaking existing clients? The security problem is the inverse: backward compatibility creates a mechanism for bypassing the security controls you added in the new version. When you add a required tenantId field to a tool call for authorization, clients that claim the old schema version don't send it. If your server accepts the old version for compatibility, the authorization check is silently skipped. Version negotiation becomes an attack vector. Deprecated endpoints become unmonitored attack paths. The sunset policy is not just a deprecation communication exercise — it's a security operation with a hard deadline.
Why schema evolution creates security gaps
The fundamental problem is that MCP tool schemas serve two conflicting roles: they describe the structure of a request to clients so they know what to send, and they enforce the security invariants the server expects on every call. When those roles belong to the same versioned object, adding a security invariant to the schema means clients that haven't upgraded can't satisfy it — and maintaining backward compatibility with those clients means you can't enforce it.
This is different from a purely additive schema change like adding a new optional field. Adding an optional field is safe; every client either sends it or doesn't, and the server handles both cases. The dangerous pattern is a security-motivated additive change: a new field that is optional in the schema but required for correct security behavior. Classic examples in MCP server deployments:
- Authorization scope narrowing — you add a
targetResourceIdfield so the server can verify the caller has access to the specific resource being operated on. Old clients don't send it. The server falls back to permissive behavior. - CSRF token addition — you add a server-generated
csrfTokenfield to all state-mutating tool calls after discovering that server-side request forgery is possible through the old schema. Clients on the old schema don't send it. - Type narrowing for command arguments — you change a
commandfield fromstringto a string literal union to prevent command injection. The old schema accepts any string. A client that negotiates down to the old schema can send arbitrary values.
The pattern repeats: security improvement in the new schema is circumvented by clients that request the old schema version. If your version negotiation logic accepts the client's requested version without challenge, the downgrade is trivial.
1. The version negotiation attack
MCP clients and servers negotiate a schema version during the handshake phase of the connection. The client sends a list of supported versions (or a minimum and maximum version), and the server selects the highest version both support. The security vulnerability is in the "highest version both support" step: an attacker who can control the client's version announcement can request only the old schema version, forcing the server to use the schema that lacks the new security field.
Server ships schema v2 with a new required field callerAttestation — a server-generated HMAC the client must echo back, proving the request originated from a legitimate agent session.
Server maintains v1 for backward compatibility. The schema v1 does not have callerAttestation. The version negotiation handler accepts v1 and skips the attestation check for v1 callers.
Attacker modifies client configuration to announce supportedVersions: ["1"] in the handshake. The server selects v1 — the only mutually supported version — and uses the v1 schema and v1 security behavior for the session.
Attacker invokes tool calls without callerAttestation. The server does not require it for v1 clients. The server executes the tool call. The security control introduced in v2 is fully bypassed.
This is not a theoretical attack. Any MCP server that accepts multiple schema versions and applies version-conditional security logic is vulnerable to clients that deliberately negotiate down to the weakest version. If the negotiation is not authenticated or integrity-protected, the client controls which security posture the server applies to the session.
2. Secure version negotiation design
The core principle: the server, not the client, determines the minimum acceptable version. The negotiation should be a capability match, not a free choice for the client. The server should:
- Maintain a server-side
MIN_SUPPORTED_VERSIONthat advances forward as deprecated versions are sunset - Reject connections where the client's maximum announced version is below
MIN_SUPPORTED_VERSIONwith an explicit error code (not a downgrade to the old version) - Never use client-supplied version as the deciding factor for which security checks to apply
// VULNERABLE: server uses whichever version the client claims
function negotiateVersion(clientSupportedVersions: string[]): string {
const ourVersions = ['1', '2', '3'];
// Returns the highest version in the intersection
// If client claims ['1'], returns '1' — bypassing v2+ security
return findHighestCommonVersion(clientSupportedVersions, ourVersions);
}
// SECURE: server enforces a minimum floor
const MIN_SUPPORTED_VERSION = '2'; // v1 is sunset; no v1 sessions allowed
function negotiateVersion(clientSupportedVersions: string[]): string {
const eligible = clientSupportedVersions.filter(v => semverGte(v, MIN_SUPPORTED_VERSION));
if (eligible.length === 0) {
throw new McpVersionNegotiationError(
`Client maximum version ${Math.max(...clientSupportedVersions)} is below minimum supported version ${MIN_SUPPORTED_VERSION}. ` +
`Update your MCP client to support schema v${MIN_SUPPORTED_VERSION} or higher.`,
{ code: 'VERSION_TOO_OLD', minSupportedVersion: MIN_SUPPORTED_VERSION }
);
}
return findHighestCommonVersion(eligible, ourSupportedVersions);
}
The error response for VERSION_TOO_OLD must be machine-readable — the client should be able to detect it, display a clear upgrade prompt to the user, and not fall back to a compatibility mode. An error code and a minimum version in the error body gives client maintainers the information they need to upgrade.
3. Schema evolution patterns and their security risk
Not all schema changes carry the same security risk. The table below classifies common evolution patterns by whether they create a backward-compatibility security gap:
| Change type | Example | Backward-compat safe? | Security risk if kept backward-compat? |
|---|---|---|---|
| Add optional field, no security function | Add metadata.clientVersion for observability |
Yes | None |
| Add optional field with security function | Add optional auditCorrelationId for tracing |
Conditionally | Low — if missing, the audit trail has gaps but no security bypass |
| Add required field for authorization | Add required targetScopeId to scope resource access |
No | Critical — old clients send no scope; server must handle missing scope as deny or apply permissive default |
| Narrow type of existing field | Change command: string to command: "start" | "stop" | "restart" |
No | High — old clients can send arbitrary strings; the narrowing that prevents injection is not enforced |
| Remove field that bypassed validation | Remove skipValidation: boolean debug flag |
No | Critical — old clients can still send the field; server must actively reject it, not just ignore it |
| Rename field with security meaning | Rename user to authenticatedUserId |
No | High — old clients send user; if server accepts both names, attacker can use the old name to inject unsanitized values |
| Add validation rule to existing field | Add path traversal check to existing filePath field |
Yes (same field) | None if enforced regardless of schema version. Risk only if version-conditional. |
The most dangerous pattern: applying validation only to the schema version that introduced the field. If the path traversal check runs for v2 clients but not v1 clients (because v1 didn't have the check), old clients bypass it trivially by downgrading.
4. Version-conditional security logic is the root cause
The underlying vulnerability in all of these cases is the same: security logic that branches on the negotiated schema version. Every version-conditional branch is a potential bypass for clients that negotiate to the version where the branch is false.
// DANGEROUS pattern: version-conditional security check
async function handleToolCall(req: McpToolCallRequest, sessionVersion: string) {
if (sessionVersion >= '2') {
// Only v2+ clients send targetResourceId
await assertResourceAccess(req.params.targetResourceId, req.auth.userId);
}
// v1 clients: no resource check — full access to all resources
return executeToolCall(req.params);
}
// SECURE pattern: always enforce; use session version only for parsing, never for auth
async function handleToolCall(req: McpToolCallRequest, sessionVersion: string) {
// Parse the request according to the negotiated schema version
const params = parseToolCallParams(req.params, sessionVersion);
// Security enforcement is version-independent
// If targetResourceId is missing (v1 client), deny by default — not permissive fallback
if (!params.targetResourceId) {
throw new McpAuthorizationError(
'targetResourceId is required. Your MCP client schema is outdated. ' +
`Minimum supported version is ${MIN_SUPPORTED_VERSION}.`,
{ code: 'OUTDATED_SCHEMA' }
);
}
await assertResourceAccess(params.targetResourceId, req.auth.userId);
return executeToolCall(params);
}
The pattern difference is subtle but critical: the schema version controls how the request is parsed, but security enforcement runs unconditionally. If the parsed params don't have the security field, the server rejects the call — it does not fall back to a permissive default that was safe for v1.
5. Removing a field that was a bypass vector
Fields that were debug-only, admin-only, or that were found to bypass validation after the fact are special cases. When you remove a field from the schema, old clients can still send it. If your server ignores unknown fields (the permissive default in most validation libraries), the removed field silently continues to work for old clients.
// Schema v1 — DANGEROUS debug field
{
"name": "run_query",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"skipSqlValidation": { "type": "boolean" } // removed in v2; left as a bug
}
}
}
// Server v2 — INCORRECT removal: just deleted from schema, not from handler
async function handleRunQuery(params: any) {
// skipSqlValidation is no longer in the schema, but it's still read from params
if (!params.skipSqlValidation) {
validateSql(params.query); // bypassed by sending skipSqlValidation: true
}
return executeQuery(params.query);
}
The correct removal is to explicitly reject removed fields using strict schema validation (additionalProperties: false in JSON Schema, .strict() in Zod), and to delete the bypass branch from the handler code entirely — not just remove the field from the schema definition. On any version.
// CORRECT removal: strict schema + handler cleanup
const runQuerySchema = z.object({
query: z.string()
// skipSqlValidation is not here and .strict() makes it a validation error to send it
}).strict();
async function handleRunQuery(rawParams: unknown) {
const params = runQuerySchema.parse(rawParams); // throws on unknown fields
validateSql(params.query); // no version-conditional bypass
return executeQuery(params.query);
}
6. The deprecated endpoint attack surface
Every deprecated schema version that remains accessible on the server is an unmonitored attack path. Deprecated endpoints accumulate three security liabilities over time:
Monitoring decay
Operational teams stop watching metrics and alerts for deprecated routes. Anomalous traffic on /v1/tools/execute goes unnoticed while /v2/tools/execute traffic is actively monitored. An attacker who discovers the old endpoint can operate under the alert threshold indefinitely.
Patch skew
Security patches are applied to the current version. Deprecated versions receive them inconsistently. A vulnerability patched in v3 may remain unpatched in v1 and v2 because the patch requires schema changes that would break the old contract, and "we'll just sunset those endpoints soon" becomes permanent.
Auth drift
Auth middleware is layered on over time. The v3 router has the JWT validation, the rate limiter, the tenant isolation check, and the audit log middleware. The v1 router was written before half of those existed. Middleware added to the main router often doesn't get added to the version-specific handler paths.
7. Sunset strategy as a security operation
A sunset date is not just a deprecation notice to developers — it is a commitment to remove a class of security bypass vectors by a specific date. Framed that way, the planning process changes:
- Audit the delta — enumerate every security control that is present in the new schema but absent in the deprecated schema. This list is the risk register for the sunset period. As long as the deprecated schema is live, these controls are bypassable.
- Set a hard removal date, not a deprecation notice — "deprecated" is indefinite; "removed on 2026-09-01" is a contract. Add it to a public-facing changelog and enforce it in the version negotiation handler on the deadline.
- Return sunset headers on every response from deprecated versions — RFC 8594
Sunset: Fri, 01 Sep 2026 00:00:00 GMT+Deprecation: true. Machine-readable for client developers; also surfaces in monitoring. - Emit metrics on deprecated-version traffic — track unique callers (by auth token or IP) using deprecated versions per day. This is your upgrade adoption dashboard. If 20 callers used v1 last week and 18 used it this week, two upgraded — and two didn't. You know who to contact.
- Force-reject on sunset date — do not extend the deadline for "just a few more months." On the sunset date, update
MIN_SUPPORTED_VERSIONand let the version negotiation handler returnVERSION_TOO_OLD. Any callers who didn't upgrade will get a clear error with upgrade instructions, not silent degradation.
// Add Sunset and Deprecation headers to all v1 responses
// RFC 8594-compliant
app.use('/v1/*', (req, res, next) => {
res.set('Sunset', 'Fri, 01 Sep 2026 00:00:00 GMT');
res.set('Deprecation', 'true');
res.set('Link', '; rel="successor-version"');
res.set('Warning', '299 - "This API version is deprecated and will be removed 2026-09-01"');
next();
});
8. Protecting the version header from spoofing
In HTTP-transport MCP deployments, the schema version is typically negotiated in the initial handshake and then stored server-side in the session. If the session is stateless (JWT-based) and the version is stored in the token, an attacker who can forge or replay tokens can claim an old schema version.
The principle for stateless sessions: the schema version should be in the server-issued token, not in the client-supplied request headers or body. If the client sets the version in a header (X-MCP-Schema-Version: 1) and the server trusts it without verification, any authenticated client can claim any version on any request.
// VULNERABLE: version is a request header, trusted without verification
app.post('/tools/execute', (req, res) => {
const sessionVersion = req.headers['x-mcp-schema-version'] ?? 'latest';
// Attacker sends 'x-mcp-schema-version: 1' to use old schema on any session
handleToolCall(req.body, sessionVersion);
});
// SECURE: version is fixed at session creation time, stored in the session token
// JWT payload: { sub: userId, sessionSchemaVersion: '3', iat: ..., exp: ... }
app.post('/tools/execute', authenticate, (req, res) => {
const sessionVersion = req.session.schemaVersion; // from verified JWT, not header
// Client cannot override this value without forging the JWT
handleToolCall(req.body, sessionVersion);
});
9. Schema version in CI — preventing regression
Version negotiation and schema evolution bugs are hard to catch in code review because they require reasoning about what the old schema accepted and what the new schema adds. A CI contract test that replays old-schema requests against the current server is the most reliable defense against regression:
// contract-test-v1.ts — runs in CI against the current server build
// Sends v1-schema tool calls and asserts that the server rejects them
// with VERSION_TOO_OLD, not executes them with permissive fallback
describe('v1 schema version rejection (sunset enforcement)', () => {
it('rejects version negotiation for v1', async () => {
const response = await mcpClient.connect({
supportedVersions: ['1'], // simulate an unupgraded client
});
expect(response.error.code).toBe('VERSION_TOO_OLD');
expect(response.error.minSupportedVersion).toBe('2');
});
it('rejects tool calls missing fields added in v2', async () => {
// Even if somehow connected on v2, missing the required field
const response = await mcpClient.callTool('run_query', {
query: 'SELECT 1',
// targetResourceId missing — was optional in v1, required for security in v2
});
expect(response.error.code).toBe('OUTDATED_SCHEMA');
});
it('rejects tool calls with removed bypass field', async () => {
const response = await mcpClient.callTool('run_query', {
query: 'DROP TABLE users',
skipSqlValidation: true, // removed in v2; must be rejected, not ignored
});
expect(response.error).toBeDefined(); // strict() validation error
});
});
These tests catch three distinct failure modes: version negotiation not enforcing the minimum, security fields being optional when they should be required, and removed bypass fields being silently ignored. They should run against every PR that touches the schema, the version negotiation handler, or any middleware that applies version-conditional logic.
10. Multi-tenant versioning: per-tenant schema version pinning
In multi-tenant MCP deployments, enterprise customers often negotiate long-term version support agreements: "we need six months of migration time before we can upgrade." If your server accepts per-tenant minimum version overrides, you have created a mechanism for a tenant — or someone who has compromised a tenant's credentials — to pin to an old, less-secure schema version indefinitely. Avoid per-tenant version pins. The minimum version must be global and advancing. If a tenant needs migration time, give them a clear timeline with the same hard removal date everyone else has.
SkillAudit findings for API versioning
When SkillAudit scans an MCP server that exposes versioned schemas or version negotiation, it checks for these specific patterns:
if (version >= '2') { ... }). Old-version clients bypass all security controls added after the branch point. −22 pts
additionalProperties: true (default). Removed bypass fields (e.g., skipValidation) continue to be sent by old clients and are silently accepted. −20 pts
Sunset and Deprecation headers on deprecated-version responses (RFC 8594). Callers receive no machine-readable signal that they must upgrade. −12 pts
Deployment checklist
- Server defines
MIN_SUPPORTED_VERSIONas a configuration value; not hardcoded in the negotiation handler - Version negotiation rejects connections where client maximum version is below
MIN_SUPPORTED_VERSIONwithVERSION_TOO_OLDerror code + minimum version in error body - Security enforcement in tool call handlers is unconditional — no branches on negotiated schema version
- Missing security-mandatory fields (added in newer schema) result in
OUTDATED_SCHEMArejection, not permissive fallback - All schema validation uses strict mode (
additionalProperties: false/ Zod.strict()) - Removed bypass fields are deleted from handler code, not just removed from schema definition
- Session schema version is fixed at session creation in server-issued token; not overridable by client request header
- Deprecated-version responses include
Sunset,Deprecation,Link: successor-version, andWarningheaders - Metrics track deprecated-version traffic by unique caller, with alerting for unexpected new callers using deprecated versions
- Hard removal date is in the public changelog and enforced in the version negotiation handler on the deadline
- CI includes contract tests that replay old-schema requests and assert
VERSION_TOO_OLDor validation errors — not successful execution - No per-tenant minimum version overrides — minimum version is global and advancing
Summary
The version negotiation protocol is the most underestimated attack surface in MCP server security. Backward compatibility is a development convenience that creates a mechanism for bypassing every security control you add to newer schema versions. The defenses work together: a server-enforced minimum version that advances on a hard schedule closes the negotiation downgrade path; unconditional security enforcement (not version-conditional) closes the bypass-through-old-schema path; strict schema validation closes the removed-field bypass path; and session-bound schema version (in the server-issued token, not the client request) closes the header-spoofing path. None of these are complex to implement. The difficulty is recognizing that versioning is a security operation, not just a compatibility convenience, and treating the sunset date as a security deadline rather than a suggestion.
See MCP Server Message Queue Security for the analogous problem in async deployments, where at-least-once delivery semantics create replay vulnerabilities. See MCP server input validation and schema security for the base-layer schema enforcement patterns that apply regardless of versioning.
Run a free SkillAudit scan on your MCP server to detect version negotiation vulnerabilities, missing strict schema validation, deprecated endpoint exposure, and schema-evolution security gaps — before you publish to the Anthropic skills directory or share the GitHub URL. Audit your MCP server →