[Server][Client] Bridge W3C trace context between HTTP headers and _meta (SEP-414) (#473)
RequestMeta::parseTraceContext() now falls back to the native traceparent/ tracestate/baggage HTTP headers when _meta carries none, and HeaderFactory mirrors an application-set _meta trace context onto those same headers on outbound requests. _meta wins when both are present.
This commit is contained in:
parent
1e9520e731
commit
d59950f1ac
5 changed files with 134 additions and 10 deletions
|
|
@ -13,6 +13,7 @@ namespace Mcp\Client\Stateless;
|
|||
|
||||
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 @@ final class HeaderFactory
|
|||
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 @@ final class HeaderFactory
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Mcp\Exception\MissingRequestMetaException;
|
|||
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 @@ final class RequestMeta
|
|||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
|
||||
|
|
@ -86,7 +89,7 @@ final class RequestMeta
|
|||
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 @@ final class RequestMeta
|
|||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ final class StatelessProtocol
|
|||
}
|
||||
|
||||
try {
|
||||
$meta = RequestMeta::fromParams($params);
|
||||
$meta = RequestMeta::fromParams($params, $headers);
|
||||
} catch (MissingRequestMetaException $e) {
|
||||
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,45 @@ final class HeaderFactoryTest extends TestCase
|
|||
$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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -643,6 +643,53 @@ class StatelessProtocolTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue