MCP server protocol version downgrade security
The MCP initialize exchange requires the client to declare its protocolVersion and the server to respond with the version it will use. This negotiation mechanism is designed for compatibility — but it also creates a downgrade attack surface. A client that declares an older protocol version can force the server into a compatibility mode that lacks the security features introduced in later versions: OAuth 2.1 authorization, elicitation request validation, tool annotations, and Streamable HTTP session binding. Four SA-MCP-VER patterns cover the complete version negotiation attack surface.
MCP protocol versions follow a date-based scheme: 2024-11-05, 2025-03-26. Each version added security-relevant features. The 2025-03-26 spec introduced OAuth 2.1 authorization, the elicitation mechanism, tool annotations, and Streamable HTTP with session identity. A server that honors a client’s request to operate under an earlier version may skip these security controls entirely for that session. The four SA-MCP-VER patterns target how servers fail to protect against this class of negotiation manipulation.
SA-MCP-VER-001: client-supplied protocolVersion forces server into pre-security-feature compatibility mode
MCP servers that implement backward compatibility by selecting the client’s requested protocol version (rather than the server’s minimum supported version) disable all features introduced after that version for the duration of that session. A client claiming protocolVersion: "2024-11-05" causes a server to skip OAuth 2.1 token validation, elicitation schema validation, tool annotation checking, and Streamable HTTP session binding — all features added in 2025-03-26. The server operates in an unprotected compatibility mode because it trusts the client’s self-declared version.
// VULNERABLE: server echoes back whatever version the client claims
server.setRequestHandler('initialize', async (req, context) => {
const clientVersion = req.params.protocolVersion;
// Server does not validate or floor the client's claimed version
// Simply echoes back what the client said
context.sessionProtocolVersion = clientVersion;
return {
protocolVersion: clientVersion, // ← reflects client's choice
capabilities: { tools: {}, resources: {}, prompts: {} },
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// Now version-gated security checks are skipped:
async function handleToolCall(req, context) {
if (context.sessionProtocolVersion >= '2025-03-26') {
await validateOAuthToken(req, context); // ← skipped if client claims older version
validateToolAnnotations(req); // ← skipped
}
// ... tool logic runs without auth check ...
}
// ATTACK: client sets protocolVersion = '2024-11-05' to bypass OAuth validation
await client.request({
method: 'initialize',
params: {
protocolVersion: '2024-11-05', // ← claims old version
capabilities: {},
clientInfo: { name: 'attacker', version: '1.0.0' }
}
});
// Server now skips all 2025-03-26 security features for this session
// Client can call any tool without a valid OAuth token
// SECURE: server always uses its own minimum version — never trust client-declared version
const SERVER_MIN_VERSION = '2025-03-26';
const SERVER_MAX_VERSION = '2025-03-26';
server.setRequestHandler('initialize', async (req, context) => {
const clientVersion = req.params.protocolVersion;
// Reject versions below server minimum
if (clientVersion < SERVER_MIN_VERSION) {
throw new McpError(ErrorCode.InvalidRequest,
`Protocol version ${clientVersion} is below minimum supported version ${SERVER_MIN_VERSION}`
);
}
// Use server's own version — not the client's claimed version
const negotiatedVersion = SERVER_MAX_VERSION;
context.sessionProtocolVersion = negotiatedVersion;
return {
protocolVersion: negotiatedVersion, // ← server's version, not client's
capabilities: { tools: {}, resources: {}, prompts: {} },
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// All security checks now run unconditionally — no version branching needed
// DETECTION (SkillAudit):
// Does initialize handler echo protocolVersion from req.params?
const initHandler = findInitializeHandler();
if (echoesClientProtocolVersion(initHandler)) → CRITICAL // SA-MCP-VER-001
// Are any security checks wrapped in version comparisons?
const versionGates = findVersionGatedCodeBlocks();
if (versionGates.some(g => gatesSecurityCheck(g))) → HIGH // SA-MCP-VER-001
Version downgrade disables entire security feature classes, not individual checks: An attacker does not need to find a specific authentication bypass — they simply declare an old version and all authentication features introduced in later versions are disabled by the server’s own compatibility code. The server is a willing participant in its own security degradation.
initialize handler echoes client’s protocolVersion back and uses it to gate security features: attacker claims old version to bypass OAuth 2.1, elicitation validation, and tool annotation checks. Detection: protocolVersion: req.params.protocolVersion in initialize response; version-gated security code → CRITICAL.SA-MCP-VER-002: no minimum version floor allows degradation to pre-security-feature early drafts
MCP servers that accept any syntactically valid version string without enforcing a minimum floor are vulnerable to clients claiming extremely old or pre-release protocol versions. Early MCP draft versions predated OAuth 2.1 authentication, tool annotations, elicitation, and even some JSON-RPC framing conventions. A server with no floor accepts protocolVersion: "1.0", "2024-01-01", or even "0" and attempts to operate under that version’s rules — typically meaning no security features apply.
// VULNERABLE: any version accepted without a floor
const SUPPORTED_VERSIONS = ['2024-11-05', '2025-03-26']; // ← floor is implicitly the oldest
server.setRequestHandler('initialize', async (req, context) => {
const clientVersion = req.params.protocolVersion;
// No minimum check — accepts anything
if (!SUPPORTED_VERSIONS.includes(clientVersion)) {
// Falls through to return default (often oldest) version
return {
protocolVersion: SUPPORTED_VERSIONS[0], // '2024-11-05' — oldest version
capabilities: {},
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
}
// ...
});
// ALSO VULNERABLE: accepts without any list check
server.setRequestHandler('initialize', async (req, context) => {
// No version validation at all — any string is accepted
return {
protocolVersion: req.params.protocolVersion,
capabilities: {},
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// ATTACK: client sends a pre-feature version
await client.request({
method: 'initialize',
params: { protocolVersion: '2023-01-01', capabilities: {}, clientInfo: { name: 'x', version: '1' } }
});
// Server accepts; returns '2024-11-05' (oldest supported); OAuth/elicitation disabled
// ALSO ATTACK: version string as injection attempt
await client.request({
method: 'initialize',
params: { protocolVersion: "2024-11-05'; DROP TABLE sessions;--", capabilities: {} }
});
// If version string used in SQL without parameterization → SQL injection
// SECURE: strict allowlist; reject unknown versions with clear error; no fallback to oldest
const ALLOWED_VERSIONS = new Set(['2025-03-26']); // only current; add new versions explicitly
const SERVER_VERSION = '2025-03-26';
server.setRequestHandler('initialize', async (req, context) => {
const clientVersion = req.params.protocolVersion;
// Type validation first
if (typeof clientVersion !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(clientVersion)) {
throw new McpError(ErrorCode.InvalidParams,
'protocolVersion must be a date string in YYYY-MM-DD format'
);
}
// Strict allowlist — unknown versions rejected, not silently accepted
if (!ALLOWED_VERSIONS.has(clientVersion)) {
throw new McpError(ErrorCode.InvalidRequest,
`Unsupported protocol version. Supported: ${[...ALLOWED_VERSIONS].join(', ')}`
);
}
return {
protocolVersion: SERVER_VERSION, // always server's version
capabilities: {},
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// DETECTION (SkillAudit):
// Does initialize handler validate protocolVersion against an allowlist with a minimum floor?
const initHandler = findInitializeHandler();
if (!hasVersionAllowlist(initHandler)) → HIGH // SA-MCP-VER-002
if (!hasMinimumVersionFloor(initHandler)) → HIGH // SA-MCP-VER-002
// Is protocolVersion used in any database query without parameterization?
if (usesVersionInSqlWithoutParam()) → CRITICAL // SA-MCP-VER-002
No floor is the same as an implicit floor at the oldest known version: A server that accepts any version and falls back to its oldest supported behavior grants attackers a reliable path to the least-secure configuration. Security reviewers often overlook this because “the client just gets the old behavior” — which is exactly the attacker’s goal.
initialize handler: clients claiming pre-security-feature versions are accepted or silently downgraded to oldest supported version, disabling all security features introduced after that version. Detection: missing allowlist or floor check in initialize version validation → HIGH.SA-MCP-VER-003: unknown or future version strings cause unhandled exceptions returning stack traces
MCP servers that attempt to parse, compare, or route based on the version string without robust input validation throw unhandled exceptions when receiving version strings that are malformed, empty, extremely long, or in an unexpected format (e.g., "3.0.0", "", null, a 10,000-character string). These exceptions are often propagated to the client as verbose error messages containing source file paths, line numbers, and internal server state — reconnaissance information that accelerates subsequent attacks.
// VULNERABLE: version parsed without validation; unexpected formats cause exceptions
server.setRequestHandler('initialize', async (req, context) => {
const clientVersion = req.params.protocolVersion;
// Attempt to parse date components for version comparison
const [year, month, day] = clientVersion.split('-').map(Number);
// clientVersion = "" → split('-') = [''] → Number('') = 0 → year=0
// clientVersion = null → TypeError: Cannot read properties of null (reading 'split')
// clientVersion = "2025-03-26-extra" → split gives 4 parts; extra data silently ignored
const versionDate = new Date(year, month - 1, day);
// If year=0, month=NaN → Invalid Date
// If month-1 = NaN → TypeError in Date constructor in some environments
if (versionDate >= new Date('2025-03-26')) {
// 2025+ version handling
} else if (versionDate >= new Date('2024-11-05')) {
// 2024 version handling
} else {
// Unhandled: throws without catch → default error handler returns stack trace
throw new Error(`Unsupported version: ${clientVersion}`);
// Stack trace in response:
// Error: Unsupported version: 3.0.0
// at Object. (/app/src/handlers/initialize.js:23:11)
// at MCPServer._handleRequest (/app/node_modules/@modelcontextprotocol/sdk/src/server.js:189)
}
});
// ATTACK: probe with various malformed versions to leak server internals
const probes = ['', null, 3.0, 'x', '9999-99-99', '2025-03-26\x00injected', 'A'.repeat(10000)];
for (const v of probes) {
const err = await tryInitialize(v);
// Parse error messages for file paths, line numbers, module names
}
// SECURE: validate version string before any processing; return generic error on failure
server.setRequestHandler('initialize', async (req, context) => {
const raw = req.params.protocolVersion;
// Type and format validation — reject anything unexpected early
if (typeof raw !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'protocolVersion must be a string');
}
if (raw.length > 20) { // date strings are at most 10 chars
throw new McpError(ErrorCode.InvalidParams, 'protocolVersion too long');
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
throw new McpError(ErrorCode.InvalidParams, 'Invalid protocolVersion format');
}
// Now safe to process — no exception path from here
const SUPPORTED = new Set(['2025-03-26']);
if (!SUPPORTED.has(raw)) {
// Generic message — does not reveal supported list or internal structure
throw new McpError(ErrorCode.InvalidRequest, 'Unsupported protocolVersion');
}
return {
protocolVersion: '2025-03-26',
capabilities: {},
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// ALSO SECURE: wrap entire initialize handler in try/catch with generic fallback
server.setRequestHandler('initialize', async (req, context) => {
try {
// ... version handling ...
} catch (err) {
if (err instanceof McpError) throw err; // rethrow our own errors
logger.error('Unexpected error in initialize', { err: err.message }); // log internally
throw new McpError(ErrorCode.InternalError, 'Initialization failed'); // generic to client
}
});
// DETECTION (SkillAudit):
// Does initialize handler validate protocolVersion type before processing?
const initHandler = findInitializeHandler();
if (!hasTypeofStringCheck(initHandler)) → HIGH // SA-MCP-VER-003
if (!hasLengthCheck(initHandler)) → MEDIUM // SA-MCP-VER-003
if (!hasFormatRegexCheck(initHandler)) → HIGH // SA-MCP-VER-003
// Are exceptions in initialize propagated with e.message to the client?
if (propagatesExceptionMessageToClient(initHandler)) → HIGH // SA-MCP-VER-003
Version string fuzzing is a fast, zero-prerequisite reconnaissance technique: An attacker can probe version string handling with 20-30 inputs in under a second. Any exception that reaches the client reveals: the framework being used (from module path in stack trace), the source file structure (from file path), the line number of the version check (useful for finding the version list), and whether null/empty string triggers different behavior than malformed string (reveals control flow).
initialize handler lacks input validation on protocolVersion string: malformed, empty, null, or oversized version inputs cause unhandled exceptions that return stack traces and source paths to clients. Detection: missing type/format/length validation before version comparison; e.message propagation in catch blocks → HIGH.SA-MCP-VER-004: version rejection errors expose full server capability list as an enumeration oracle
MCP servers that include the full list of supported versions and capabilities in version rejection error messages allow attackers to enumerate the server’s feature set without a successful connection. By deliberately sending an unsupported version string in the initialize request, the attacker triggers a rejection response that tells them which protocol versions the server supports, which capabilities are available, and sometimes which optional features are enabled — a complete capability profile from a single unauthenticated request.
// VULNERABLE: version rejection error includes full supported version list and capabilities
server.setRequestHandler('initialize', async (req, context) => {
const SUPPORTED = {
'2025-03-26': { tools: { listChanged: true }, resources: { subscribe: true }, logging: {} },
'2024-11-05': { tools: {}, resources: {} }
};
if (!SUPPORTED[req.params.protocolVersion]) {
// Attacker learns: all supported versions AND all their capabilities
throw new McpError(ErrorCode.InvalidRequest,
`Version ${req.params.protocolVersion} not supported. ` +
`Supported versions: ${Object.keys(SUPPORTED).join(', ')}. ` +
`Latest capabilities: ${JSON.stringify(SUPPORTED['2025-03-26'])}`
);
// Error message reveals:
// - Server supports 2024-11-05 and 2025-03-26
// - tools.listChanged = true (tools list change notifications enabled)
// - resources.subscribe = true (resource subscriptions enabled — SA-MCP-RSB attack surface)
// - logging = {} (logging enabled — SA-MCP-LOG attack surface)
}
// ...
});
// ATTACK: single request with unsupported version reveals complete capability profile
const probe = await tryInitialize('1900-01-01');
// Error: Version 1900-01-01 not supported. Supported versions: 2024-11-05, 2025-03-26.
// Latest capabilities: {"tools":{"listChanged":true},"resources":{"subscribe":true},"logging":{}}
// Attacker now knows exactly which SA-MCP-* attack patterns to attempt
// SECURE: generic rejection message — no capability or version list disclosure
server.setRequestHandler('initialize', async (req, context) => {
const SUPPORTED = new Set(['2025-03-26']);
if (!SUPPORTED.has(req.params.protocolVersion)) {
// No version list, no capability disclosure
throw new McpError(ErrorCode.InvalidRequest,
'Unsupported protocol version. See https://skillaudit.dev for compatible clients.'
);
}
return {
protocolVersion: '2025-03-26',
capabilities: {
// Disclose ONLY capabilities needed for the connected client's declared capabilities
// Don't advertise capabilities the client didn't ask for
tools: req.params.capabilities?.tools ? { listChanged: true } : undefined,
resources: req.params.capabilities?.resources ? { subscribe: true } : undefined,
logging: req.params.capabilities?.logging ? {} : undefined,
},
serverInfo: { name: 'my-mcp-server', version: '1.0.0' }
};
});
// ALSO SECURE: rate limit initialize requests (even rejected ones) to slow down probing
const initRateLimit = new Map(); // IP/connectionId → count
function checkInitRateLimit(connectionId) {
const count = (initRateLimit.get(connectionId) ?? 0) + 1;
initRateLimit.set(connectionId, count);
if (count > 5) { // max 5 initialize attempts per connection
throw new McpError(ErrorCode.InvalidRequest, 'Too many initialization attempts');
}
}
// DETECTION (SkillAudit):
// Does version rejection error include list of supported versions?
const initHandler = findInitializeHandler();
if (rejectsWithVersionList(initHandler)) → MEDIUM // SA-MCP-VER-004
// Does version rejection error include capabilities object?
if (rejectsWithCapabilitiesList(initHandler)) → HIGH // SA-MCP-VER-004
// Is there a rate limit on initialize requests (including rejected ones)?
if (!hasInitRateLimit(initHandler)) → MEDIUM // SA-MCP-VER-004
Capability disclosure reduces attack complexity from enumeration to selection: Without capability information, an attacker must probe all SA-MCP-* patterns (200+) to find exploitable surface. With a capabilities list from a single rejected initialize request, the attacker selects only the relevant 3-5 patterns. A server advertising resources.subscribe: true in its error message is announcing SA-MCP-RSB as a valid attack target before the session even begins.
initialize handler including Object.keys(SUPPORTED) or capabilities JSON → MEDIUM.How SkillAudit detects SA-MCP-VER patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-VER-001 | Static: protocolVersion: req.params.protocolVersion in initialize response; security checks wrapped in version comparison gates | CRITICAL |
| SA-MCP-VER-002 | Static: missing allowlist or minimum version floor in initialize handler; silent fallback to oldest supported version on unknown input | HIGH |
| SA-MCP-VER-003 | Static: missing type, format, and length validation before version comparison; exception e.message propagated to client in catch block | HIGH |
| SA-MCP-VER-004 | Static: rejection error message includes supported version list or capabilities object disclosure | MEDIUM |
Run a SkillAudit scan on your MCP server’s version negotiation code: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-VER patterns and 200+ additional MCP security checks.