diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php index 3640cf93..428e1c70 100644 --- a/src/Client/Stateless/HeaderFactory.php +++ b/src/Client/Stateless/HeaderFactory.php @@ -13,6 +13,7 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Wire\McpHeader; +use Mcp\Server\Stateless\RequestMeta; /** * The HTTP headers a modern-era client puts on every POST (SEP-2243, SEP-2575). @@ -41,18 +42,21 @@ public function __construct( public function forMessage(array $payload, ProtocolVersion $protocolVersion): array { $method = $payload['method'] ?? null; + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; // A response to a server-initiated request carries no method to mirror; - // the version header is unconditional and still applies. + // the version header is unconditional and still applies. Trace context + // still rides along if the application put it in `_meta` — the party + // that started the trace is not necessarily the one with a method to + // report. if (!\is_string($method)) { - return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value]; + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value, ...$this->traceHeaders($params)]; } - $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; - $headers = [ McpHeader::PROTOCOL_VERSION => $protocolVersion->value, McpHeader::METHOD => $method, + ...$this->traceHeaders($params), ]; if (null !== $name = McpHeader::nameFor($method, $params)) { @@ -72,4 +76,27 @@ public function forMessage(array $payload, ProtocolVersion $protocolVersion): ar return $headers; } + + /** + * Mirrors W3C trace context from `_meta` onto its native HTTP headers, so + * an application that only knows how to set `_meta` — the transport-agnostic + * surface — still gets header-based propagation for free on HTTP. + * + * @param array|null $params + * + * @return array + */ + private function traceHeaders(?array $params): array + { + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $headers = []; + foreach (RequestMeta::TRACE_KEYS as $key) { + if (\is_string($meta[$key] ?? null)) { + $headers[$key] = $meta[$key]; + } + } + + return $headers; + } } diff --git a/src/Server/Stateless/RequestMeta.php b/src/Server/Stateless/RequestMeta.php index f418db51..3744d92c 100644 --- a/src/Server/Stateless/RequestMeta.php +++ b/src/Server/Stateless/RequestMeta.php @@ -15,6 +15,7 @@ use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Implementation; +use Mcp\Server\Wire\InboundClassifier; /** * The per-request metadata that replaces the `initialize` handshake in the @@ -56,11 +57,13 @@ public function __construct( } /** - * @param array|null $params the request's `params` member, if any + * @param array|null $params the request's `params` member, if any + * @param array $headers request headers, case-insensitively matched; a + * transport without a header layer (stdio) passes none * * @throws MissingRequestMetaException when a structurally required member is absent or malformed */ - public static function fromParams(?array $params): self + public static function fromParams(?array $params, array $headers = []): self { $meta = $params['_meta'] ?? null; @@ -86,7 +89,7 @@ public static function fromParams(?array $params): self ClientCapabilities::fromArray((array) $capabilities), \is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null, self::parseLogLevel($meta[self::LOG_LEVEL] ?? null), - self::parseTraceContext($meta), + self::parseTraceContext($meta, $headers), ); } @@ -103,17 +106,25 @@ private static function parseLogLevel(mixed $level): ?LoggingLevel * Carried through opaquely: the values are the tracing ecosystem's to * interpret, and a malformed one is not this server's to reject. * - * @param array $meta + * `_meta` wins over the native W3C header of the same name when both are + * present — it is the more specific of the two, scoped to this one call + * rather than the whole HTTP request, and a caller that put it there did + * so deliberately. + * + * @param array $meta + * @param array $headers * * @return array */ - private static function parseTraceContext(array $meta): array + private static function parseTraceContext(array $meta, array $headers): array { $context = []; foreach (self::TRACE_KEYS as $key) { if (\is_string($meta[$key] ?? null)) { $context[$key] = $meta[$key]; + } elseif (null !== $header = InboundClassifier::header($headers, $key)) { + $context[$key] = $header; } } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 721c9a79..a179a217 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -197,7 +197,7 @@ public function handle(string $body, array $headers = []): StatelessResult } try { - $meta = RequestMeta::fromParams($params); + $meta = RequestMeta::fromParams($params, $headers); } catch (MissingRequestMetaException $e) { return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); } diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php index ea4a3a43..5f284315 100644 --- a/tests/Unit/Client/Stateless/HeaderFactoryTest.php +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -81,6 +81,45 @@ public function testResponseCarriesOnlyTheVersion(): void $this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers); } + #[TestDox('mirrors trace context an application put in `_meta` onto its native headers')] + public function testTraceContextMirrorsOntoHeaders(): void + { + $payload = [ + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', $headers['traceparent']); + $this->assertSame('acme=1', $headers['tracestate']); + } + + #[TestDox('a response with no method still mirrors its trace context')] + public function testTraceContextMirrorsOntoAResponse(): void + { + $payload = ['id' => 1, 'result' => [], 'params' => ['_meta' => ['traceparent' => 'tp-1']]]; + + $this->assertSame( + ['MCP-Protocol-Version' => '2026-07-28', 'traceparent' => 'tp-1'], + $this->headersFor($payload), + ); + } + + #[TestDox('an untraced request mirrors nothing')] + public function testNoTraceContextMirrorsNothing(): void + { + $headers = $this->headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertArrayNotHasKey('traceparent', $headers); + $this->assertArrayNotHasKey('tracestate', $headers); + } + /** * @param array $payload * diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index ba933d7d..62c7eb79 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -643,6 +643,53 @@ public function testTraceContextReachesTheHandler(): void ); } + #[TestDox('a native traceparent/tracestate header reaches the handler when `_meta` carries none')] + public function testHttpTraceHeadersReachTheHandler(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'probe_trace', 'arguments' => []], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + 'tracestate' => 'acme=1', + ], + ); + + $this->assertSame( + 'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;tracestate=acme=1', + $answer['body']['result']['content'][0]['text'], + ); + } + + #[TestDox('`_meta` trace context wins over a conflicting native header')] + public function testMetaTraceContextWinsOverHeader(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'probe_trace', + 'arguments' => [], + '_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], + ], + [ + 'Mcp-Name' => 'probe_trace', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', + ], + ); + + $this->assertSame( + 'traceparent=00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + $answer['body']['result']['content'][0]['text'], + ); + } + #[TestDox('notifications caused by a traced request carry its trace context')] public function testNotificationsCarryTheTraceContext(): void {