Skip to content
LaravelBlog

laravel/mcp: Stop sending structured content twice in execute_tools results

LaravelBlogBot
LaravelBlogBot

Contributed by smotim

Part of the mcp v1.0.2 release

PR #358 stops execute_tools from delivering structured content twice. When a tool returned Response::structured(...), its data appeared in a ToolSearch result both as a JSON text block and as a structuredContent object — the model read the same payload twice, and every byte was charged twice against the output limit. After this change, the duplicated text mirror is dropped from catalog results, so structured tools behind ToolSearch get roughly double the usable capacity under the same max_output_bytes, while direct tools/call responses remain byte-for-byte unchanged.

The problem: the same payload, twice

Per the MCP spec, a tool result that carries structured content should also include a text representation, for clients that only read text. Response::structured() did exactly that: it kept the array as structuredContent and mirrored it as a JSON text block.

That is the right behavior for a direct tools/call. But ToolSearch's execute_tools meta-tool packs each tool result into one JSON document and returns that document as text, so both copies landed in the same envelope:

{"ok":true,"results":[{
  "name":"weather",
  "content":[{"type":"text","text":"{\"temperature\":22.5,\"humidity\":65}"}],
  "isError":false,
  "structuredContent":{"temperature":22.5,"humidity":65}
}]}

A production report behind this PR makes the cost concrete: a tool returning about 30 KB of JSON tripped OutputLimitExceeded at the default 64 KB mcp.tool_search.max_output_bytes limit, and the model worked around it by splitting one logical request into four separate calls.

How the fix works

Three small, surgical changes:

  1. Response::structured() now builds its text mirror as StructuredContentText, a new empty subclass of Text. The emitted content is identical — the point is that the mirror is now distinguishable from text a tool wrote on purpose.
  2. ToolInvoker accepts a mirrorStructuredContent boolean (defaulting to true). When it is false, responses whose content is a StructuredContentText are filtered out of the serialized content array.
  3. ToolSearch invokes catalog tools with mirrorStructuredContent: false, so the execute_tools envelope now carries the data once:
{"ok":true,"results":[{
  "name":"weather",
  "content":[],
  "isError":false,
  "structuredContent":{"temperature":22.5,"humidity":65}
}]}

What does not change

Direct tools/call behavior is untouched — the text mirror is still sent alongside structuredContent, as the spec intends.

Text you write yourself next to structured content also survives. A tool like this:

public function handle(): ResponseFactory
{
    return Response::make(Response::text('Found 2 results'))
        ->withStructuredContent(['count' => 2]);
}

still produces content: [['type' => 'text', 'text' => 'Found 2 results']] inside the execute_tools result, because a plain Text response is not a StructuredContentText mirror and therefore is not rejected. Streamed tools behave the same: notifications pass through, and only the automatic mirror is dropped.

Upgrade impact from v1.0.1

There is exactly one behavioral change to audit: entries in the execute_tools envelope for structured-content tools now serialize with an empty content array. If any of your code — a custom meta-tool, a client-side wrapper, or a test expectation — reads results[].content[0].text, switch it to read structuredContent instead:

// Before (v1.0.1)
$text = $result['content'][0]['text'];   // '{"count":2}' — a JSON string

// After
$data = $result['structuredContent'];    // ['count' => 2] — the array itself

Nothing in the package or its documentation relied on the old shape, and the contract stated in #317 — that execute_tools "preserves notifications and structured content" — still holds. There is no configuration to change either: the limit remains mcp.tool_search.max_output_bytes, it simply now measures the real payload rather than a duplicate of it.

How it is tested

The PR updates the existing execute_tools expectation, which previously asserted both copies of the data. Three new tests cover the edges: hand-written text alongside structured content is preserved in the result; structured content is counted once against the output limit (a 256-byte limit with ~120 bytes of structured data now passes); and streamed structured results drop the mirror while keeping notifications intact.

Takeaways

  • execute_tools results now carry structured content once, in structuredContent; mirror-only responses serialize as "content": [].
  • Direct tools/call responses still include the text mirror, per the MCP spec.
  • Hand-written text next to structured content is preserved — only the automatic mirror is dropped.
  • Effective capacity behind ToolSearch roughly doubles for structured tools under the same max_output_bytes.
  • If you previously read results[].content[0].text from the envelope, read results[].structuredContent instead.

Sources

More from this release

Related Articles