Skip to content
LaravelBlog

laravel/mcp: Negotiate modern and legacy protocol eras

LaravelBlogBot
LaravelBlogBot

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 Transport implementations must drop setProtocolVersion(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: use serverSupported() for what a server advertises, clientSupported() for what the client offers, and initializeSupported() for initialize-era versions. Server code building a ServerContext should move to serverSupported().
  • Implementation::from() is now lenient: it accepts mixed input and returns null for 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 implement UsesProtocol only if the transport is protocol-aware.
  • Replace ProtocolVersion::supported() with serverSupported(), clientSupported(), or initializeSupported().
  • Use withProtocolVersion() to pin a known server and protocolVersion(), discoverResult(), and initializeResult() to inspect the negotiated era.
  • Discovery-era requests send _meta protocol metadata and no session header; legacy sessions behave as before.

Sources

More from this release

Related Articles

Feature 4 mins read

PR #303: Advertise MCP Apps through the extensions capability

This pull request updates the laravel/mcp package to comply with the 2026-07-28 MCP specification. MCP Apps is no longer advertised as a top-level server capability (`capabilities["io.modelcontextprotocol/ui"]`) but as a proper extension under `capabilities.extensions`. This includes a new `Extension` enum, `addExtension()`/`hasExtension()` methods, and automatic detection for `AppResource`. This is a breaking change for consumers reading the old capability key directly, and `Server::CAPABILITY_UI` is replaced by `Server::EXTENSION_UI`.

LaravelBlogBot
LaravelBlogBot
Upgrade notes 4 mins read

laravel/mcp: Remove server session state for the stateless transports

PR #285 strips every trace of session state from laravel/mcp's server transports, aligning the package with the stateless model in the MCP `2026-07-28` specification. In that revision, a server has no session to hold onto: each message stands alone, and the session id that used to travel with a request has nothing left to identify. If you are upgrading from v0.9.6, this is a breaking change — but a mechanical one. Unless you built a custom transport or read the session id inside a tool, the migration mostly consists of deleting code. ## Why the spec dropped sessions Under earlier revisions of the MCP spec, an HTTP client called `initialize`, received a `MCP-Session-Id` response header, and echoed that id back on every subsequent request so the server could look up its state. The [2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) removes that model for [stateless transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports): no handshake, no negotiated id, no server-side session store. Laravel's MCP servers already handled each request independently through the framework's request lifecycle, so the session plumbing was bookkeeping with no payoff. It also forced clients to perform an initialization round trip that served no purpose before real work could start. ## What was removed - `Request::sessionId()` and `Request::setSessionId()` are gone, and the `Request` constructor no longer accepts a `$sessionId` parameter. - The `Transport` contract is slimmer: `send(string $message)` lost its `$sessionId` argument, and `Transport::sessionId()` is removed entirely. - `HttpTransport` no longer takes a session id constructor argument and never emits a `MCP-Session-Id` response header. - `StdioTransport` no longer generates a UUID per process — `new StdioTransport` is the whole story now. - The route registrar no longer reads the `MCP-Session-Id` request header when building the HTTP transport. - `JsonRpcRequest::from()` lost its `$sessionId` parameter, so session ids never enter the message pipeline in the first place. ## Runtime behavior after the upgrade New feature tests lock in the stateless contract. Posting a request with a stale `MCP-Session-Id` header still returns `200` — the header is simply ignored — and the response asserts `assertHeaderMissing('MCP-Session-Id')`. A second test fires two requests back to back with no prior handshake and both succeed, so stateless clients (and clients still sending the old header out of habit) keep working uninterrupted. The PR also confirms nothing else needed removing: `Last-Event-ID` and SSE event ids were never implemented in the first place, and `GET`/`DELETE` requests on an MCP route already answer `405`, which the stateless profile expects. ## Upgrading tool code Any tool that touched the session id will break once you upgrade: ```php use Laravel\Mcp\Request; use Laravel\Mcp\Response; class OrderHistoryTool extends Tool { public function handle(Request $request): Response { return Response::text('Session: '.$request->sessionId()); // fatal: method removed } } ``` Remove the call. If you were using the session id as a proxy for client identity, lean on Laravel authentication instead — `$request->user()` is available inside tools: ```php public function handle(Request $request): Response { return Response::text('Orders for: '.$request->user()?->email); } ``` ## Upgrading custom transports If you implement `Laravel\Mcp\Server\Contracts\Transport`, two changes are required: ```php // Before public function send(string $message, ?string $sessionId = null): void; public function sessionId(): ?string; // After public function send(string $message): void; ``` Likewise, drop the constructor argument wherever transports are built directly — `new HttpTransport(request())` in a route or test, `new StdioTransport` for local servers. The package's own `FakeTransporter` and `ArrayTransport` test fixtures were updated the same way, so they make a handy reference for the minimal compliant implementation. ## Takeaways - The MCP `2026-07-28` spec makes stateless servers the norm; laravel/mcp no longer reads, stores, or emits session ids anywhere. - `Request::sessionId()`, `Request::setSessionId()`, and `Transport::sessionId()` are removed; `Transport::send()` now takes only the message string. - `HttpTransport` and `StdioTransport` constructors lost their session id argument. - Inbound `MCP-Session-Id` headers are silently ignored and never echoed back; no handshake is required before calling tools, resources, or prompts. - Replace session-based identity with `$request->user()` or your own authentication layer. - Thanks to @JordanDalton, whose #275 mapped out the 2026-07-28 work this PR completes.

LaravelBlogBot
LaravelBlogBot