Skip to content
LaravelBlog

laravel/mcp: Reject requests with a null id

LaravelBlogBot
LaravelBlogBot

Contributed by ggrigon

Part of the mcp v1.0.2 release

If you are upgrading from v1.0.1 of laravel/mcp, pull request #365 fixes a subtle but painful bug in how the server dispatches JSON-RPC messages. A request carrying "id": null was being mistaken for a notification, so the server acknowledged it with an empty 202 Accepted, never executed the requested method, and left the client waiting for a result that would never arrive. After this change, such requests are rejected with a proper -32600 Invalid Request error, exactly as the MCP specification requires. Well-formed requests and genuine notifications behave exactly as before.

What changed

The fix is a one-line change to the dispatch logic in Server::handle():

// Before (v1.0.1)
$request = isset($jsonRequest['id'])
    ? JsonRpcRequest::from($jsonRequest)
    : JsonRpcNotification::from($jsonRequest);

// After
$request = is_array($jsonRequest) && array_key_exists('id', $jsonRequest)
    ? JsonRpcRequest::from($jsonRequest)
    : JsonRpcNotification::from($jsonRequest);

The problem was PHP semantics: isset() returns false not only when a key is missing, but also when the key exists and its value is null. That made {"jsonrpc":"2.0","id":null,"method":"tools/list"} indistinguishable from a notification (which has no id key at all), so it was routed to JsonRpcNotification::from() — and notifications get no response.

array_key_exists() correctly distinguishes "key present with a null value" from "key absent". The new is_array() guard matters because array_key_exists() throws a TypeError on a non-array payload (for example, JSON that decodes to a bare string or number), while isset() silently returned false in those cases. The guard keeps malformed payloads flowing into the existing error paths unchanged.

Once a null-id payload reaches JsonRpcRequest::from(), the existing id validation takes over and throws the same -32600 exception the server already produced for other invalid ids. A regression test covering the null-id case was added to tests/Unit/ServerTest.php.

Why it matters

The MCP specification is explicit about this: unlike base JSON-RPC, the id member of a request MUST NOT be null. So a null id is already forbidden traffic — the bug was in how the server responded to it.

The old failure mode was the worst kind: silent. The transport returned 202 Accepted, the JSON-RPC layer considered the job done (notifications legitimately get no response), and the client hung until it timed out. Nothing in the response body, and nothing in your server logs, explained why the tool never ran. If a buggy client, proxy, or middleware was stripping or nulling ids, you would see tools silently never executing with zero diagnostics.

Now the failure is fast, loud, and standards-shaped: a 400 response with a JSON-RPC error the client can parse and display.

Before and after

Here is the reproduction case from the pull request:

curl -i https://example.test/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":null,"method":"tools/list"}'

On v1.0.1 you got:

HTTP/1.1 202 Accepted

…with an empty body, and no tools/list result ever coming.

With the fix applied you get a 400 response containing:

{
    "jsonrpc": "2.0",
    "id": null,
    "error": {
        "code": -32600,
        "message": "Invalid Request: The [id] member must be a string, number."
    }
}

The id member is echoed back as null so the client can correlate the failure with the request it sent, and the message tells you precisely which constraint was violated.

Upgrade impact

The blast radius is deliberately tiny:

  • Requests with a string or integer id take exactly the same code path as before.
  • Notifications — payloads with no id key at all — are still accepted and answered with 202.
  • The only payloads whose behavior changes are the ones the specification already forbids.

If any of your integration tests or scripts relied on the old behavior (a null-id request returning 202 with no body), update them to expect the -32600 error instead. In practice, the more common "upgrade" is a non-event: clients that were previously hanging on a null id will now fail immediately with a descriptive error, which is precisely the improvement.

Note that this case is distinct from the float-id and scalar-params edge cases covered by #206 and #272 — those were already handled; this PR adds a dedicated test for the null-id case that had slipped through.

Takeaways

  • Requests with "id": null are now rejected with -32600 Invalid Request instead of being silently treated as notifications.
  • The root cause was isset() conflating "key is null" with "key is absent"; the fix uses array_key_exists() plus an is_array() guard.
  • Valid requests and notifications are completely unaffected — only spec-forbidden payloads change behavior.
  • Clients sending null ids now get a fast 400 with a clear error message instead of hanging forever.
  • The fix ships with a regression test in tests/Unit/ServerTest.php; update any custom tests that asserted the old 202 behavior.

Sources

More from this release

Related Articles