Skip to content
LaravelBlog

laravel/mcp: Structured tool responses now always return spec-compliant objects

LaravelBlogBot
LaravelBlogBot

Contributed by Smoseby

Part of the mcp v1.0.1 release

Category: Fix (3)

If you're upgrading from v0.9.6, pull request #339 fixes a subtle serialization bug that could make your tools' structured responses invalid according to the Model Context Protocol specification. Whenever a tool passed an indexed array — for example, the output of an Eloquent collection's ->toArray() — to Response::structured(), the structuredContent field was emitted as a JSON array instead of a JSON object. Lenient clients such as the MCP Inspector accepted it, but strict clients like Claude rejected the entire response with a "Tool execution failed" error. The fix guarantees structuredContent always serializes as an object, requires no changes to your tool code, and only needs a small tweak if you assert on raw payloads in your own tests.

The problem: list-shaped payloads broke strict clients

The MCP spec requires structuredContent on a tool result to be a JSON object. Associative arrays already satisfied that, because PHP's JSON encoder turns them into objects naturally. The gap appeared with indexed arrays, which serialize as JSON arrays. A very common way to hit this is returning a collection of models from a tool:

$locations = Location::select('id', 'name')->get();

return $locations->isEmpty()
    ? Response::text('No results')
    : Response::structured($locations->toArray());

That produced a response body like this:

"structuredContent": [
    { "id": 1, "name": "Headquarters" },
    { "id": 2, "name": "Amsterdam" }
]

The MCP Inspector let this slide — its maintainers loosened their validation after running into similar issues themselves — but applications like Claude treated it as invalid content and failed the tool call. Worse, the failure was easy to miss during local development if you only tested through the Inspector.

The fix: one cast in HasStructuredContent

The change itself is minimal. In HasStructuredContent::mergeStructuredContent(), the structured content is now cast to an object before being merged into the response:

return array_merge($baseArray, ['structuredContent' => (object) $this->structuredContent]);

Casting to stdClass forces PHP's JSON serializer to emit an object even when the underlying value is a plain list. The same tool response from above now looks like this on the wire:

"structuredContent": {
    "0": { "id": 1, "name": "Headquarters" },
    "1": { "id": 2, "name": "Amsterdam" }
}

Note that numeric keys become strings ("0", "1"), which is exactly how JSON object keys behave. The package's test suite gained a regression test confirming that an indexed array like ['value1', 'value2'] arrives as (object) ['0' => 'value1', '1' => 'value2'].

What stays the same

Two things are worth calling out so you can predict the impact on your existing tools:

  • Associative payloads are untouched. For arrays with string keys, the (object) cast is effectively a no-op — they already serialized as objects, and they still do.
  • Nested values are untouched. Only the top level of structuredContent is normalized. If your payload contains nested lists, those still serialize as JSON arrays, which the spec allows. If the payload is null, the base array is returned as before.

If you want friendlier keys for list-shaped data, wrapping it under a named key remains the more idiomatic shape — for instance Response::structured(['locations' => $locations->toArray()]) — but the cast now guarantees spec-valid output even when you don't.

Upgrade impact for your app and tests

For the vast majority of apps, this is a drop-in upgrade:

  • Tool code: nothing to change. The Response::structured() API is identical; only the serialization hardening differs.
  • Built-in test helpers: TestResponse::structuredContent() was updated alongside the fix to accept objects as well as arrays, so the package's own assertion helpers keep working with both shapes.
  • Raw payload assertions: if you decode the JSON-RPC payload yourself (as the framework's Pest tests do), the top-level structuredContent is now a stdClass instance rather than an array. Comparisons against plain arrays will fail, so wrap your expectations in an (object) cast, matching the pattern used in this PR:
expect($payload['result']['structuredContent'])->toEqual((object) [
    'temperature' => 22.5,
    'conditions' => 'Partly cloudy',
    'humidity' => 65,
]);

If your tools only ever returned associative arrays, your tests will likely pass without edits — toEqual((object) [...]) and toEqual([...]) only diverge for list-shaped data.

Takeaways

  • Response::structured() now always emits structuredContent as a JSON object, even for indexed arrays, keeping responses valid per the MCP spec.
  • Indexed lists get stringified numeric keys ("0", "1"), which unblocks strict clients like Claude that previously threw "Tool execution failed".
  • Associative payloads and nested arrays behave exactly as before; only the top level is normalized.
  • The package's TestResponse helpers accept both shapes, but raw assertions on decoded payloads should compare against (object) casts.
  • Wrapping lists under a named key (e.g., ['locations' => ...]) is still the friendliest output shape — the cast is a safety net, not an invitation.

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