Dual-era MCP serving: legacy initialize clients work again
Contributed by pushpak1300
Part of the mcp v1.0.1 release
Category: Feature (2)
Pull request #341 makes the laravel/mcp server dual-era: a single endpoint now answers both the modern MCP 2026-07-28 flow and the classic initialize handshake. Since 1.0, clients that still open with initialize — claude.ai, Claude Desktop, ChatGPT connectors — were met with a -32601 method-not-found error and simply stopped. The MCP specification permits exactly this kind of backward compatibility, and this change implements it, fixing #338. For anyone upgrading from v0.9.6, this removes the most painful part of the 1.0 migration: your existing clients no longer have to be rewritten before you can ship the upgrade.
Why legacy clients broke in 1.0
In 1.0 the server supported a single protocol revision. The initialize handshake was removed in favor of server/discover, and every request had to carry the protocol version and client capabilities in params._meta, plus the Mcp-* HTTP headers. Clients that never migrated kept opening with initialize and were rejected with an error explaining that the handshake was removed in MCP 2026-07-28. The specification's versioning rules explicitly allow a server to answer both eras on one endpoint — "backward compatibility with initialization-based versions" — which is a far gentler path than a hard cutover. This PR adopts that approach: the server negotiates whichever era the client speaks.
How the server tells the eras apart
Detection happens per request, via the new JsonRpcRequest::isLegacy() method. A request is considered legacy when its params._meta contains neither io.modelcontextprotocol/protocolVersion nor io.modelcontextprotocol/clientCapabilities. Legacy requests skip the validateProtocolMeta() check and the ValidateMcpHeaders middleware — those clients never send Mcp-Method or Mcp-Name — and no MCP-Session-Id is issued, since the legacy specification treats sessions as optional. The moment a request carries either meta key, it is treated as a 2026-07-28 request and validated exactly as before: a header mismatch still returns HTTP 400 with JSON-RPC error -32020. There is no middle ground where a half-modern request can dodge validation.
The legacy handshake, end to end
A new Initialize handler joins the built-in method map next to server/discover. It reads protocolVersion from the request params and echoes it back when it is one of the supported legacy revisions — 2025-11-25 or 2025-06-18. Anything else, including 2025-03-26, 2024-11-05, a malformed value, or a missing one, is answered with the newest legacy revision, 2025-11-25. The result carries the server's capabilities, serverInfo, and instructions:
// A legacy client opens with the classic handshake: no _meta, no Mcp-* headers.
$response = $this->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 456,
'method' => 'initialize',
'params' => [
'protocolVersion' => '2025-06-18',
'capabilities' => [],
'clientInfo' => ['name' => 'claude-desktop', 'version' => '1.2.3'],
],
]);
$response->assertOk();
$response->json('result.protocolVersion'); // '2025-06-18'
{
"jsonrpc": "2.0",
"id": 456,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"serverInfo": { "name": "Laravel MCP Server" },
"instructions": "..."
}
}
After the client sends notifications/initialized, the rest of the session runs the legacy way too: a bare tools/list with empty params returns the tool list, with no _meta or headers attached.
ping is answered again
The 1.0 upgrade notes carried a section instructing you to update health checks because bare ping requests returned -32601; that note is gone. A new Ping handler returns an empty, successful result, so legacy clients and monitoring scripts that ping the endpoint keep working. Modern callers can ping as well, provided they pass their usual _meta and header checks.
What this means for your v0.9.6 upgrade
For applications coming from v0.9.6, the practical impact is that the 1.0 protocol migration is no longer a flag day. If your app only defines Tool, Resource, Prompt, and Server classes, no code changes are needed: clients from the v0.9.6 era reconnect through initialize, while modern clients such as Laravel\Mcp\Client and the MCP Inspector continue through server/discover unchanged. You can migrate clients to the modern era — _meta metadata, Mcp-* headers, session ids — one at a time, or not at all. Two footnotes: if you registered an initialize shim through addMethod() as a 1.0 workaround, your override still wins over the new built-in handler, so you can retire it when convenient; and any tooling that relied on the -32601 handshake rejection or the removed ping will see different responses now.
Takeaways
- One endpoint, two eras:
initialize-based clients and2026-07-28clients are both served; modern requests are validated exactly as in 1.0. - Era detection is
_meta-driven: noprotocolVersionorclientCapabilitiesmeta means legacy — skipping meta validation, header checks, and session issuance. initializeechoes2025-11-25or2025-06-18; anything else is answered with2025-11-25.pingreturns an empty result instead of-32601.addMethod()overrides forinitializeandpingstill take precedence over the built-in handlers.- Upgrading from v0.9.6: existing clients should connect with no client-side changes.