Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/Client/Stateless/HeaderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)) {
Expand All @@ -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<string, mixed>|null $params
*
* @return array<string, string>
*/
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;
}
}
21 changes: 16 additions & 5 deletions src/Server/Stateless/RequestMeta.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,11 +57,13 @@ public function __construct(
}

/**
* @param array<string, mixed>|null $params the request's `params` member, if any
* @param array<string, mixed>|null $params the request's `params` member, if any
* @param array<string, string> $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;

Expand All @@ -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),
);
}

Expand All @@ -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<string, mixed> $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<string, mixed> $meta
* @param array<string, string> $headers
*
* @return array<string, string>
*/
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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Server/Stateless/StatelessProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
39 changes: 39 additions & 0 deletions tests/Unit/Client/Stateless/HeaderFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $payload
*
Expand Down
47 changes: 47 additions & 0 deletions tests/Unit/Server/Stateless/StatelessProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down