Skip to content
LaravelBlog

laravel/mcp: Require the protocol metadata on every request

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.1 release

laravel/mcp now validates protocol metadata on every incoming request. Merged as PR #297, each JSON-RPC request must carry the MCP protocol version and the client's capabilities inside params._meta, matching the 2026-07-28 revision of the Model Context Protocol specification. That revision drops the assumption of a stateful initialize handshake: rather than negotiating once per session, every request is self-describing. If you drive the server with hand-rolled payloads — tests, scripts, proxies, or a custom client — this is a breaking change, and every request you send needs updating.

What changed

Server::handle() now calls a new validateProtocolMeta() check before it even looks up the requested method. Two members are mandatory inside _meta:

  • io.modelcontextprotocol/protocolVersion — must be a string, e.g. "2026-07-28".
  • io.modelcontextprotocol/clientCapabilities — must be a JSON object. An empty object is fine, but a sequential list such as ["elicitation"] is rejected.

The fully-qualified member names live in a new MetaKey enum, and JsonRpcRequest::meta() was tightened to only recognise object-shaped _meta values — a list-shaped _meta is treated as absent. Because validation runs ahead of the method router, a request without metadata returns an invalid-params error even when the method name is also unknown. Malformed values (a null or integer version, a string where capabilities should be) report the same "missing required member" message as truly absent ones.

Why the spec moved this way

Until now, an MCP server learned the negotiated protocol version during initialize and had to remember it for the rest of the session. The 2026-07-28 revision removes that coupling: with no handshake, every request carries its own protocol version and capabilities. That makes the server genuinely stateless — no session store, no sticky routing, no ambiguity behind load balancers or in serverless deployments — and lets version negotiation happen per request instead of once per connection.

clientInfo remains optional because the specification marks it as SHOULD, not MUST, so the server does not reject requests that omit it.

The two new error responses

Missing or malformed members return -32602:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params: The request [_meta] is missing the required [io.modelcontextprotocol/protocolVersion] member."
  }
}

A well-formed but unrecognized version returns -32022 with a machine-readable data payload so clients can discover what to retry with:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28"],
      "requested": "2025-11-25"
    }
  }
}

What a valid request looks like

Every method — including initialize and ping — now needs the metadata block:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "say-hi-tool",
    "arguments": { "name": "John Doe" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

Note that clientCapabilities is {} here — an empty object satisfies the requirement until you actually advertise capabilities.

Upgrade impact

  • Official MCP client SDKs updated for the 2026-07-28 revision already send this metadata; nothing changes for you.
  • Raw JSON-RPC callers — integration tests, curl scripts, queues, custom transports — must add the _meta block to every request, initialize included.
  • Version strings must match exactly. "2025-11-25" (or anything else) returns -32022; only "2026-07-28" is supported today.
  • PHP-side definitions are untouched. Tools, resources, and prompts need no changes; this is transport-level validation.

Still to come: Mcp-Method and the other mirrored header validation land in #286, and a follow-up PR adds resultType and server info on results. Thanks to @JordanDalton, whose #275 mapped out the 2026-07-28 work. Full details are in the spec changelog and the _meta documentation.

Takeaways

  • Every request now requires _meta with a string io.modelcontextprotocol/protocolVersion and an object-shaped io.modelcontextprotocol/clientCapabilities.
  • Missing or malformed metadata returns -32602; an unknown version returns -32022 with a supported list in data.
  • This is a BC break: update any hand-built JSON-RPC payloads, tests, or custom clients before upgrading.
  • Per-request metadata enables fully stateless operation under the 2026-07-28 spec; clientInfo stays optional.

Sources

More from this release

Related Articles

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