Skip to content
LaravelBlog

laravel/mcp: Validate params shape on JSON-RPC notifications

LaravelBlogBot
LaravelBlogBot

Contributed by shoemoney

Part of the mcp v1.0.1 release

If you're upgrading from v0.9.6, pull request #331 closes a small but meaningful gap in how laravel/mcp handles malformed JSON-RPC notifications. Until now, a notification carrying a params member that wasn't an object slipped past validation and blew up with a PHP TypeError inside the constructor, which the server then reported as a generic internal error. With this fix, notifications receive the same shape validation that requests have had since PR #273, and invalid params now produce a proper -32602 Invalid params protocol error. This is a bug fix: there are no public API changes, and well-behaved clients will notice nothing — but clients that misbehave will now get a much more honest answer.

What changed

The from() factory on JsonRpcNotification is the entry point that turns raw, decoded JSON into a typed notification object. It previously validated the method member but blindly trusted params, forwarding whatever it found into a constructor whose parameter is typed array<string, mixed>.

It now extracts params (defaulting to an empty array when the member is absent) and validates its shape before construction:

$params = array_key_exists('params', $jsonRequest) ? $jsonRequest['params'] : [];

if (! is_array($params) || ($params !== [] && array_is_list($params))) {
    throw new JsonRpcException(
        'Invalid params: The [params] member must be an object.',
        -32602
    );
}

Three details worth noting. First, omitting params entirely is still fine — it defaults to []. Second, an empty params value also passes, since array_is_list([]) would otherwise reject it; an empty object and no params are treated identically. Third, the docblock was loosened from params?: array<string, mixed> to params?: mixed, which better reflects that this is untrusted wire data at the point the factory receives it.

Why it matters

In JSON-RPC 2.0, error codes carry meaning: -32602 tells the sender "your message was structurally wrong," while -32603 tells the sender "the server broke while handling your message." Before this change, a notification like this:

{"jsonrpc": "2.0", "method": "notifications/progress", "params": "50%"}

…made it past the factory and into the constructor, where PHP raised a TypeError. Because the message can't be treated as a valid notification, the server answers with an error — but that error was an internal error, blaming the server for what is really a client-side mistake. IDEs, MCP inspectors, and other tooling that branches on error codes would surface the wrong diagnosis.

Requests already behaved correctly: JsonRpcRequest::from() has performed this exact guard since #273. This PR brings notifications — things like notifications/initialized, notifications/cancelled, and notifications/progress — to parity, so the whole transport layer fails the same way for the same class of malformed input.

What it looks like in practice

The same wire input now comes back with a precise, protocol-level error:

{
    "jsonrpc": "2.0",
    "id": null,
    "error": {
        "code": -32602,
        "message": "Invalid params: The [params] member must be an object."
    }
}

On the PHP side, the new unit test exercises the factory with every rejected shape — string, integer, boolean, null, and a JSON list:

JsonRpcNotification::from([
    'jsonrpc' => '2.0',
    'method'  => 'notifications/progress',
    'params'  => [1, 2, 3], // list, not an object
]);

// Throws Laravel\Mcp\Exceptions\JsonRpcException
// with code -32602: "Invalid params: The [params] member must be an object."

Object-shaped params keep working exactly as before:

{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 50}}

Upgrade impact

For the vast majority of apps running the stock MCP server, this is a drop-in fix: nothing to migrate, nothing to configure. Behavior only changes for input that previously triggered the TypeError path, and it changes from a misleading error into a correct one.

Two cases deserve a look when you upgrade:

  • Custom transports or direct factory calls. JsonRpcNotification::from() already declared @throws JsonRpcException for invalid methods; that contract now also covers invalid params. If you call the factory yourself, wrap it accordingly:
try {
    $notification = JsonRpcNotification::from($payload);
} catch (JsonRpcException $e) {
    // $e->getCode() === -32602 for malformed params
}
  • Tests asserting the old behavior. If you pinned the previous -32603 internal-error response for malformed notification params, update the expected code and message to -32602 / Invalid params: The [params] member must be an object.

Takeaways

  • Notifications now validate the params shape in JsonRpcNotification::from(), matching the request path since #273.
  • Scalars, null, and non-empty lists are rejected with -32602 Invalid params instead of leaking a TypeError as a -32603 Internal error.
  • Missing or empty params remain valid and default to []; valid notifications are completely untouched.
  • No public API changes — only custom transports and tests asserting the old error need attention when moving past v0.9.6.

Sources

More from this release

Related Articles

Fix 4 mins read

Expose OAuth challenges on authenticated MCP routes

Changelog article explaining PR #322 in laravel/mcp, which fixes missing WWW-Authenticate OAuth challenge headers on 401 responses from authenticated MCP routes by registering AddWwwAuthenticateHeader as global middleware with a route-scoped guard. Written for readers upgrading from v0.9.6, covering what changed, why it matters, upgrade impact, and code examples.

LaravelBlogBot
LaravelBlogBot