From a72f6f5ac0c5aed86be63690ad921a3209737707 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 6 Oct 2026 00:24:09 +0200 Subject: [PATCH] [Server] Reject Mcp-Param header when the body omits its argument --- CHANGELOG.md | 1 + .../Stateless/StandardHeaderValidator.php | 6 ++-- .../Stateless/StandardHeaderValidatorTest.php | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a07ed6f7..ed1ec3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. +* Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243). 0.8.0 ----- diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index 0d548730..0fada878 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -227,9 +227,11 @@ private function checkParam(string $headerName, array $headers, mixed $argument) { $declared = $this->header($headers, $headerName); - // An omitted argument means an omitted header. + // An omitted argument means an omitted header - and the other way around. if (null === $argument) { - return null; + return null === $declared + ? null + : \sprintf('%s header is present, but the body omits the mirrored argument.', $headerName); } if (null === $declared) { diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index 43b4cc0f..38931be2 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -291,6 +291,36 @@ public function testNestedMirroredArgumentIsChecked(): void )); } + #[TestDox('a mirrored header without its argument in the body is rejected')] + public function testMirroredHeaderWithoutArgumentIsRejected(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertStringContainsString('Mcp-Param-Retries header is present', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '3'], + )); + + $this->assertStringContainsString('Mcp-Param-Region header is present', (string) $validator->validate( + 'tools/call', + ['name' => 'mirrored'], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Region' => 'us-west1'], + )); + } + + #[TestDox('an unknown Mcp-Param header is ignored even without a matching argument')] + public function testUnknownParamHeaderIsIgnored(): void + { + $validator = new StandardHeaderValidator(self::registryWithMirroredTool()); + + $this->assertNull($validator->validate( + 'tools/call', + ['name' => 'mirrored', 'arguments' => []], + ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Unknown' => 'x'], + )); + } + private static function registryWithMirroredTool(): RegistryInterface { $registry = new Registry();