Skip to content
LaravelBlog

Validate the mirrored MCP request headers over HTTP

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.1 release

Category: Upgrade notes (11)

Pull request #286 brings laravel/mcp in line with the 2026-07-28 MCP specification's request-metadata rules for the Streamable HTTP transport. The spec lets clients mirror parts of the JSON-RPC body into HTTP headers so proxies, gateways, and load balancers can route or observe traffic without parsing JSON. Because the body remains the source of truth, the server now validates the mirror and rejects any request where the two disagree. If you serve MCP over HTTP from v0.9.6, this is a breaking change: every POST to an MCP route must carry MCP-Protocol-Version and Mcp-Method — plus Mcp-Name for tools/call, prompts/get, and resources/read — and protocol errors now surface as real HTTP statuses instead of a uniform 200. Stdio transports are untouched.

What the server now checks

A new ValidateMcpHeaders middleware is registered automatically on every Mcp::web() route, between ReorderJsonAccept and AddWwwAuthenticateHeader. It decodes the JSON-RPC body and compares three headers against it:

  • MCP-Protocol-Version must match _meta['io.modelcontextprotocol/protocolVersion']
  • Mcp-Method must match the body's method member
  • Mcp-Name must match params.name for tools/call and prompts/get, or params.uri for resources/read

The version and method headers are required even when the body omits the matching member, and a contradicting value is treated exactly like a missing one. Either failure short-circuits the request with a 400:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Header mismatch: The [Mcp-Name] header value [some-other-tool] does not match the request body value [get_weather]."
  }
}

Only Mcp-Name accepts the =?base64?...?= sentinel, which is strictly decoded before comparison so non-ASCII tool names can ride in a header; the sentinel is deliberately not honored on the other headers. Two exemptions keep the change humane: requests without a usable JSON-RPC method pass straight through, and initialize is skipped so a legacy client still receives the unsupported-protocol-version error naming the supported versions from #283 rather than a header complaint it cannot understand yet. Notifications are validated too and answer 202 on success.

Why the mirror matters

Under the new spec, headers are no longer decoration. A proxy can route a tools/call for get_weather to the right backend, or sample production traffic, by reading Mcp-Method and Mcp-Name alone. Since the body is still authoritative, a header that contradicts it signals a broken, stale, or hostile client — so the spec's server-validation rules require rejecting the request instead of quietly trusting one half of it.

Protocol errors now map to HTTP statuses

Until now, nearly everything over HTTP returned a 200 with a JSON-RPC error tucked inside. HttpTransport now derives the status from the reply: -32601 becomes 404, -32603 becomes 500, and every other error becomes 400; a reply-less notification stays 202 and a successful reply stays 200. That means an unknown method is a 404 instead of a 200, the rejected initialize handshake is a 404, and an unsupported protocol version is a 400 carrying -32022 with the supported list. One more rename to note: the header-mismatch error code moved from -32020 to -32001.

What to change when upgrading

Any code that POSTs directly to your MCP routes — scripts, health checks, integration tests, custom clients — must now send the headers:

curl -X POST https://your-app.test/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: get_weather' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Lisbon"}}}'

Update anything matching on -32020, and make assertions tolerant of the new 400/404/500 statuses. For integration tests, the repository's suite now builds headers with a mcpHeaders($message) helper that mirrors the body for you — a good pattern to crib. If you use the framework's own client, nothing to do here: it learns to send these headers in the companion era-awareness PR above this one, and in legacy mode it deliberately sends none, since a legacy server should not receive them. Mcp-Param-* header validation waits for the spec's x-mcp-header support in a later PR. Finally, credit to @JordanDalton, whose #275 mapped out the 2026-07-28 work.

Takeaways

  • HTTP MCP requests must now carry MCP-Protocol-Version, Mcp-Method, and — for methods that name a target — Mcp-Name mirroring the body; omissions and contradictions return 400 with -32001.
  • Error responses now use real HTTP statuses: 404 for unknown methods, 400 for most protocol errors, 500 for internal errors.
  • HEADER_MISMATCH changed from -32020 to -32001; update any code matching on the old value.
  • initialize is exempt, so legacy clients still get proper version negotiation errors.
  • Stdio transports are unaffected.

Sources

More from this release

Related Articles

Chore 4 mins read

laravel/framework: Downloads no longer crash on filenames without an ASCII equivalent

**Category:** Bug fix (9) PR #61888 fixes an `InvalidArgumentException` thrown by `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` whenever the download filename contains characters with no ASCII equivalent — for example `請求書` or `😀`. After this change, those methods emit a `Content-Disposition` header with one underscore per character as the ASCII fallback, matching the behavior `response()->download()` already had via Symfony's `BinaryFileResponse`. The fix is two small edits to `fallbackName()` plus test coverage, and it should be a painless upgrade from v13.34.0. ## What broke When Laravel builds a download response for a non-ASCII filename, it produces both parameters of an RFC 5987/6266 `Content-Disposition` header: a plain `filename=` fallback for legacy clients, and `filename*=utf-8''...` carrying the real name percent-encoded. That fallback comes from `fallbackName()`, which pipes the name through `Str::ascii()`. For names that transliterate cleanly, this works: `привет.txt` becomes `privet.txt`. But some characters have no ASCII mapping at all. `Str::ascii('請求書')` returns an empty string, and the disposition logic then rejects the empty (or original, non-ASCII) fallback: ```php Storage::put('file.txt', 'Hello'); return Storage::download('file.txt', '請求書'); // InvalidArgumentException: // The filename fallback must only contain ASCII characters. ``` Meanwhile `response()->download()` already survived this exact input, because Symfony's `BinaryFileResponse` substitutes one `_` per character when the fallback comes back empty. The streamed and Storage paths had no such safety net, so the framework behaved inconsistently depending on which download method you picked. ## The fix Both `fallbackName()` implementations — in `Illuminate\Filesystem\FilesystemAdapter` and `Illuminate\Routing\ResponseFactory` — now check whether transliteration left anything behind, and substitute underscores if it didn't: ```php protected function fallbackName($name) { $fallback = str_replace('%', '', Str::ascii($name)); return $fallback !== '' ? $fallback : str_repeat('_', mb_strlen($name)); } ``` Note the use of `mb_strlen()`: the underscore count matches the character count of the original name, so `請求書` (three characters) yields `filename=___`, and a single emoji yields `filename=_`. The existing `str_replace('%', '', ...)` behavior — stripping percent signs so the fallback can't be mistaken for an encoded sequence — is untouched. ## Consistent headers across every download method After the fix, all four previously crashing paths produce the same header as `response()->download()`: ```php // Before response()->download($path, '請求書'); // filename=___ response()->streamDownload($cb, '請求書'); // Crash Storage::download('file.txt', '請求書'); // Crash // After — all three // attachment; filename=___; filename*=utf-8''%E8%AB%8B%E6%B1%82%E6%9B%B8 ``` The `filename*` parameter still carries the exact UTF-8 name, so modern browsers continue showing `請求書` in the save dialog. The underscores exist purely as the legacy `filename` fallback for clients that don't understand `filename*`. Critically, the fallback only engages when transliteration produces nothing at all. Names that already work don't change: `привет.txt` still becomes `privet.txt` (not `______.txt`), and `naïve résumé.txt` still becomes `naive resume.txt`. ## Test coverage Two new tests lock in the exact header. `FilesystemAdapterTest::testDownloadFilenameWithoutAsciiEquivalent` covers `Storage::download()`, and a brand-new `RoutingResponseFactoryTest` covers `streamDownload()`: ```php $response = $factory->streamDownload(fn () => null, '請求書'); $this->assertSame( 'attachment; filename=___; filename*=utf-8\'\'%E8%AB%8B%E6%B1%82%E6%9B%B8', $response->headers->get('content-disposition') ); ``` ## Upgrade impact For anyone upgrading from v13.34.0, this is a drop-in bug fix. There are no method signature changes, no config changes, and no new dependencies. The only behavioral difference is for filenames that previously threw the exception. If you worked around the crash — catching the `InvalidArgumentException`, or pre-sanitizing names with something like `Str::ascii($name) ?: 'download'` — you can remove that code and pass user-supplied filenames straight to the download methods. **Takeaways:** - `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` no longer throw for filenames with no ASCII equivalent. - Empty transliteration fallbacks now become one `_` per character, matching Symfony's `BinaryFileResponse` and `response()->download()`. - Names that transliterate (e.g. `привет.txt` → `privet.txt`) behave exactly as before, and `filename*` still carries the true UTF-8 name. - Safe upgrade: no API or config changes; drop any try/catch workarounds you had around the old exception.

LaravelBlogBot
LaravelBlogBot