laravel/mcp: Validate params shape on JSON-RPC notifications
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 JsonRpcExceptionfor 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
-32603internal-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
paramsshape inJsonRpcNotification::from(), matching the request path since #273. - Scalars,
null, and non-empty lists are rejected with-32602 Invalid paramsinstead of leaking aTypeErroras a-32603 Internal error. - Missing or empty
paramsremain 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.