laravel/mcp: Negotiate modern and legacy protocol eras
Contributed by pushpak1300
Part of the mcp v1.0.1 release
Feature
Pull request #304 teaches the MCP client to negotiate between the two protocol eras instead of assuming the legacy one. Previously every connection started with the legacy initialize handshake, which locked the client to the pre-2026-07-28 flow and made it impossible to talk to modern servers that expect discovery and per-request protocol metadata. The client now probes the latest protocol first, falls back to the legacy handshake only when the endpoint signals it cannot do the modern flow, and—when the server reports its supported versions—selects the newest mutual one.
How negotiation works
The first connection attempt is a server/discover call offering the latest version (2026-07-28). A modern server answers with a discover result containing its supportedVersions; the client picks the newest overlap with its own list, or throws a ClientException naming both sets when there is none. Every request in this discovery era carries _meta with the protocol version, client capabilities, and client info—exactly the per-request metadata modern servers expect.
When the probe is rejected, the error code decides the next move. Codes identifying a modern server (header mismatch, missing required client capability, unsupported protocol version) trigger a retry using the supported list from the error payload. Codes identifying a legacy server (parse error, invalid request, method not found)—or a transport-level failure—cause the client to reconnect and run the legacy initialize handshake. If a modern rejection is followed by a failing legacy attempt, both messages are combined into one ClientException. OAuth exceptions always propagate untouched, so re-authentication flows keep working. Reconnects after an expired session reuse the remembered protocol, retrying the failed request.
A new introspection surface
Existing calls are unchanged—the example from the PR keeps working without choosing a version:
$client = Mcp::client('everything');
$client->tools();
$client->protocolVersion(); // negotiated version, e.g. ProtocolVersion::V2026_07_28
$client->discoverResult(); // DiscoverResult on modern servers, null on legacy
$client->initializeResult(); // InitializeResult on legacy servers, null on modern
$client->capabilities();
$client->serverInfo();
$client->instructions();
Note the era split: after negotiating with a discovery-era server, initializeResult() returns null and you should read discoverResult() instead (and vice versa). capabilities(), serverInfo(), and instructions() work for both eras and connect on demand.
Pinning a known version
When the target server is already known, you can skip probing entirely:
Mcp::client('everything')
->withProtocolVersion(ProtocolVersion::V2025_11_25)
->tools();
Pinning an initialize-era version uses the legacy handshake; pinning V2026_07_28 uses discovery. withProtocolVersion() throws immediately for versions the client does not support, and if the server later settles on a different version than the pinned one you get a mismatch ClientException. Pinning also resets any open connection, and the pinned version survives serialization, so queued or cached clients keep their choice.
Transport behavior
HttpTransport now implements the new UsesProtocol contract. In the discovery era it sends the protocol-version header and never a session id; session capture, session termination, and the MCP-Session-Id and post-initialize protocol-version headers remain confined to the legacy era, and switching eras resets session state. Non-2xx responses carrying a JSON-RPC error body are now surfaced as protocol errors instead of a generic HTTP status failure; 404s and server errors other than 501 fail with the status, while anything else resets and throws a TransportException. Stdio timeouts now disconnect and throw a TimeoutException (a TransportException, which extends ClientException, so existing catch blocks still work). Method and name header mirroring is deliberately deferred to a follow-up so it can be modeled at the method boundary rather than inside the transport.
Breaking changes for custom transports
- Custom
Transportimplementations must dropsetProtocolVersion(string $version). - Protocol-aware transports implement
UsesProtocol::useProtocol(ProtocolVersion $version)instead. - Protocol-agnostic transports (like stdio) need no protocol method at all.
ProtocolVersion::supported()is gone: useserverSupported()for what a server advertises,clientSupported()for what the client offers, andinitializeSupported()for initialize-era versions. Server code building aServerContextshould move toserverSupported().Implementation::from()is now lenient: it accepts mixed input and returnsnullfor malformed payloads instead of assuming a shape.
Serialized clients gain a protocolVersion key, but v0.9.6 payloads still hydrate correctly, so no cache invalidation is needed.
Takeaways
- Standard HTTP and stdio usage needs no changes: negotiation, fallback, and reconnects are automatic.
- Update custom transports: remove
setProtocolVersion()and implementUsesProtocolonly if the transport is protocol-aware. - Replace
ProtocolVersion::supported()withserverSupported(),clientSupported(), orinitializeSupported(). - Use
withProtocolVersion()to pin a known server andprotocolVersion(),discoverResult(), andinitializeResult()to inspect the negotiated era. - Discovery-era requests send
_metaprotocol metadata and no session header; legacy sessions behave as before.