Skip to content
LaravelBlog

laravel/mcp: Mirror annotated tool parameters into client request headers

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.1 release

<!-- categories: 2, 11 -->

Pull request #300 teaches the laravel/mcp client to mirror selected tool arguments into HTTP request headers. When a server advertises a tool whose input schema annotates a property with x-mcp-header, the client now sends that argument's value as an Mcp-Param-* header alongside the JSON-RPC body. The goal is to make key arguments visible to the HTTP infrastructure in front of an MCP server — gateways, WAFs, per-tenant routers — which otherwise cannot act on values buried inside the request body. Along with the feature come defensive schema validation, a one-shot recovery path for stale header definitions, and a couple of contract changes you should know about before upgrading from v0.9.6.

Why headers matter

JSON-RPC wraps every argument inside the params of a POST body. Your reverse proxy can route on the URL and a handful of standard headers, but it cannot cheaply decide "this call targets the analytics tenant" or "this call must be rejected unless read-only" without parsing every body — work you generally don't want in your edge layer. By mirroring a few annotated arguments into ordinary request headers, that same infrastructure can enforce tenancy, region pinning, or safety policies before the request ever reaches the MCP server. The server declares which arguments are mirrorable; the client does the mirroring; nobody has to decode JSON at the edge.

What the client sends now

A server marks a mirrorable argument in the tool's input schema:

{
  "properties": {
    "database": { "type": "string", "x-mcp-header": "Database" },
    "options": {
      "type": "object",
      "properties": {
        "readOnly": { "type": "boolean", "x-mcp-header": "Read-Only" }
      }
    }
  }
}

Calling the tool through its Tool instance attaches the mirrored values as headers on the outgoing HTTP request:

$client->tools()->get('execute-sql')->call([
    'database' => 'analytics',
    'options' => ['readOnly' => true],
]);
// sends Mcp-Method: tools/call
//       Mcp-Name: execute-sql
//       Mcp-Param-Database: analytics
//       Mcp-Param-Read-Only: true

Only string, integer, and boolean properties can be mirrored — nested properties work, arrays and objects do not. Booleans are stringified as true/false, integers must fall within the safe 53-bit range, and values that can't live in a header (non-ASCII strings) are sent as base64 encoded-words. Absent or null arguments simply omit the header. Mirrored headers ride along only on the HTTP transport and only on the latest protocol era; the stdio transport accepts the new signature but ignores headers, and the legacy 2025-11-25 era sends none.

Invalid annotations are filtered, not fatal

Annotated schemas are parsed when the tool list is hydrated. A tool whose annotation can't be honored — an invalid header-name token, an unsupported property type, a case-insensitive duplicate header name, or an annotation placed somewhere the client can't statically reach, such as inside array items, a oneOf branch, or the schema root — is excluded from tools() entirely, with a Log::warning naming the tool and the reason. The rest of the listing hydrates normally. If a tool that used to appear vanishes after upgrading, check your logs first.

One automatic retry on header mismatch

Cached tool listings can go stale: if the server adds a mirrored parameter after you fetched the listing, your cached Tool doesn't know about it. When the server rejects a call with the header mismatch error code (-32020), the client now re-lists the tools and, if the refreshed definitions produce different headers, retries the call exactly once with the fresh values. Any other JSON-RPC error propagates immediately, and if the refreshed headers match what was already sent, the original error is rethrown — so there are no retry loops, at most one extra tools/list and tools/call.

Upgrading from v0.9.6

The signature of Client::callTool() changed from accepting a name to accepting Tool|string. String calls still work exactly as before, but they send no mirrored parameters — the client only knows the annotations by resolving the tool from a listing first:

// Still works, but no Mcp-Param-* headers are sent
$client->callTool('execute-sql', ['database' => 'analytics']);

// Mirrored headers are attached
$tool = $client->tools()->get('execute-sql');
$client->callTool($tool, ['database' => 'analytics']);
// or the shorthand:
$tool->call(['database' => 'analytics']);

Two more changes affect integrators directly:

  • Custom transports must update send(). The Transport contract is now send(string $message, array $headers = []). Existing implementations need the new parameter; the HTTP transport merges the headers into the request, stdio drops them.
  • mcp-* custom headers are reserved. Headers passed via withHeaders() that start with mcp- (case-insensitively) are now silently ignored, so callers can't spoof or override protocol-owned headers such as Mcp-Param-*. Move such values to a non-reserved name.

Note that the server-side half — declaring mirrored parameters and validating incoming headers — is intentionally not part of this release; this PR only covers the client sending the headers back.

Takeaways

  • Annotated tool arguments (x-mcp-header) are now mirrored into Mcp-Param-* request headers, letting edge infrastructure act on tool calls without parsing bodies.
  • Only string, integer, and boolean properties are mirrorable; invalid annotations exclude a tool from the listing with a warning log.
  • A -32020 header-mismatch response triggers a single refresh-and-retry with updated headers.
  • Prefer resolving tools from tools() and calling the Tool instance; name-only callTool() calls send no mirrored headers.
  • Custom Transport implementations must add the $headers parameter to send(), and mcp-* custom headers are now reserved.

Sources

More from this release

Related Articles