Skip to content
LaravelBlog

Align protocol error codes with MCP 2026-07-28

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.1 release

Before and after

In v0.9.6, a resources/read request pointing at a missing or unregistered URI produced:

{ "code": -32002, "message": "Resource [file://resources/nope] not found." }

After upgrading to 1.x (via this PR), the same request now returns:

{ "code": -32602, "message": "Resource [file://resources/nope] not found." }

The message is unchanged — only the code moved. Error codes are how MCP clients branch their behavior, so this is a breaking change for any consumer pinned to -32002.

The new ErrorCode enum

The PR also introduces a central Laravel\Mcp\Enums\ErrorCode backed enum:

enum ErrorCode: int
{
    case PARSE_ERROR = -32700;
    case INVALID_REQUEST = -32600;
    case METHOD_NOT_FOUND = -32601;
    case INVALID_PARAMS = -32602;
    case INTERNAL_ERROR = -32603;
    case HEADER_MISMATCH = -32020;
    case MISSING_REQUIRED_CLIENT_CAPABILITY = -32021;
    case UNSUPPORTED_PROTOCOL_VERSION = -32022;
}

Previously, error codes were scattered as magic integers across server method classes (you can see the raw -32002 inline in ReadResource.php before this diff). The enum gives the package a single source of truth.

Note the three new spec-reserved codes — -32020, -32021, -32022 — are defined here even though they aren't emitted yet. Later PRs in the 2026-07-28 stack will use them for header mismatch, missing client capability, and unsupported protocol version errors.

Why the spec made this change

This isn't arbitrary renumbering. The MCP 2026-07-28 specification's changelog includes:

  • Minor change 6: the bespoke -32002 code is removed; resources/read failures for unknown URIs must use the standard JSON-RPC -32602 (Invalid params) instead.
  • Minor change 12: a code range policy, plus renumbering of the three reserved server codes to the contiguous -32020–-32022 block.

The spec forbids emitting -32002, so keeping it wasn't an option for a conformant server.

It's also a semantic improvement. Under JSON-RPC, -32602 means the client sent invalid parameters — and a URI the server can't resolve is a parameter problem. The old bespoke code made resources/read the odd one out.

What this means for your upgrade

  • If you only define resources/tools and never inspect error codes: nothing to do. Your resource classes and handlers are untouched; the remapping happens internally in ReadResource.
  • If you branch on error codes (in a custom client, integration test, or proxy), update any -32002 checks:
// Before
if ($error['code'] === -32002) {
    // treat as "resource not found"
}

// After
if ($error['code'] === -32602) {
    // invalid params — includes missing/unknown resource URIs
}
  • If you throw JsonRpcException manually in custom code, prefer the enum over a raw integer:
use Laravel\Mcp\Enums\ErrorCode;

throw new JsonRpcException(
    'Resource [file://resources/nope] not found.',
    ErrorCode::INVALID_PARAMS->value,
    $request->id,
);
  • Your own test suite may assert the old code if you copied the package's test patterns; the package's tests now assert the code explicitly.

Takeaways

  • resources/read with a missing or unknown URI returns -32602 (Invalid params) instead of -32002, per MCP 2026-07-28.
  • The spec forbids emitting -32002; the old code could not stay.
  • A new ErrorCode enum is now the canonical source for JSON-RPC codes in the package.
  • Spec-reserved codes -32020, -32021, -32022 are declared for upcoming PRs in the 2026-07-28 series.
  • Update any client logic, proxy, or tests pinned to -32002; resource definitions need no changes.

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