laravel/mcp: Reject legacy requests with an unsupported protocol version header
Contributed by ggrigon
Part of the mcp v1.0.2 release
Fix · Upgrade notes
Laravel MCP up to v1.0.1 let legacy MCP clients — the ones that don't use the io.modelcontextprotocol/* _meta keys — slip past protocol-version validation entirely. Pull request #367 closes that gap: a legacy request carrying an MCP-Protocol-Version header the server doesn't support is now answered with a 400 Bad Request and a structured JSON-RPC error, exactly as the MCP specification requires. Requests without the header, and requests advertising one of the supported revisions, keep flowing through unchanged. There is nothing to migrate in your application code; the change lives entirely inside the ValidateMcpHeaders middleware.
Background: the legacy fast path from #341
Header validation was reworked in #341. Modern clients route requests through _meta keys under the io.modelcontextprotocol/* namespace, and the middleware cross-checks the Mcp-Method and Mcp-Name headers against that metadata. Legacy clients never send those headers, so the middleware short-circuits: if a message is legacy, header validation is skipped and the request is passed straight through.
That's the right call for the method and name headers — legacy clients simply don't have them — but the blanket skip also dropped the MCP-Protocol-Version check, which legacy clients do send after the handshake. The net effect was that a legacy request with a nonsense version sailed through:
curl -i https://example.test/mcp \
-H 'MCP-Protocol-Version: 1900-01-01' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Before this PR that returned 200 with the tool list. Both the 2025-06-18 and 2025-11-25 revisions of the MCP spec say the server MUST respond with 400 Bad Request to an invalid or unsupported MCP-Protocol-Version, so this was a genuine compliance gap.
What the middleware checks now
The legacy path runs a dedicated check before the request continues. It reads MCP-Protocol-Version and:
- passes the request through when the header is missing or empty (legacy
initializecalls and truly old clients that omit it), - passes the request through when the version is in
ProtocolVersion::initializeSupported()— currently2025-11-25and2025-06-18, - rejects everything else with a
400and the same error shape non-legacy requests already receive:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2025-11-25", "2025-06-18"],
"requested": "1900-01-01"
}
}
}
The id is echoed from the request body, code -32022 is ErrorCode::UNSUPPORTED_PROTOCOL_VERSION, and the data payload tells the client both what it asked for and what the server supports — enough for a well-behaved client to renegotiate. Importantly, the method and name headers are still skipped for legacy clients; this PR narrows the legacy exemption to exactly where it belongs.
Why spec compliance matters here
A 200 for an unsupported protocol version is the worst kind of failure: silent. A client written against a different revision may misread responses, and operators get no signal that versions are mismatched until something breaks subtly. The spec's MUST-level requirement exists precisely to force that mismatch into the open at the transport boundary. This change brings the legacy path to parity with the modern one and makes incompatibilities loud, immediate, and self-describing. It was found by running a server against the spec's conformance expectations — a good reminder that header handling is where protocol drift likes to hide.
Upgrade impact
Upgrading from v1.0.1 requires no code or configuration changes, but the observable behavior shifts in one case:
- Legacy request with no
MCP-Protocol-Versionheader — unchanged, still processed. - Legacy request with
2025-11-25or2025-06-18— unchanged, still200. - Legacy request with any other version string — previously
200, now400with error code-32022.
If you front the server with a proxy or gateway that rewrites or injects headers, double-check what it actually sends. And if any of your tests assert a 200 for bogus version headers, they'll need updating — the package's own test suite now covers both the accepted (2025-11-25, 2025-06-18) and rejected (1900-01-01) cases. You can verify behavior quickly with two curls:
# 200 — supported revision
curl -i https://example.test/mcp -H 'MCP-Protocol-Version: 2025-06-18' \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 400 — unsupported revision
curl -i https://example.test/mcp -H 'MCP-Protocol-Version: 1900-01-01' \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Takeaways
- Legacy MCP requests are no longer exempt from
MCP-Protocol-Versionvalidation; an unsupported version now gets a spec-mandated400with JSON-RPC error code-32022. - A missing or empty header still passes, so
initializeand clients that omit the header keep working. - Supported versions remain
2025-11-25and2025-06-18; the error'sdata.supportedlist mirrorsProtocolVersion::initializeSupported(). - No public API, config, or migration changes — the only observable difference is that bogus version headers now fail loudly instead of being served.