[Server] Carry trace context through a request (#452)

traceparent, tracestate and baggage reach handlers through RequestContext
and are echoed onto the notifications the request causes, so a span is not
broken by the response stream. Passed through verbatim: what they mean is
the tracing library's business, and no dependency is taken for it.
This commit is contained in:
Christopher Hertel 2026-08-19 00:20:32 +02:00 • committed by GitHub
commit 855011b8dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 141 additions and 1 deletions

View file

@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0
-----
* Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added.
* Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it".
* Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy.
* Add typed readers to `Mcp\Server\Stateless\InputContext`: `elicitResult()`, `samplingResult()` and `rootsResult()` deserialize a multi round-trip answer instead of handing back the raw array `response()` returns. A malformed answer reads as absent rather than throwing, so a handler asks again — which is what the spec says a server SHOULD do when the information it needs is still missing.

View file

@ -111,6 +111,23 @@ final class RequestContext
return $meta instanceof RequestMeta ? $meta->clientCapabilities : null;
}
/**
* The W3C trace context this request carried, if any.
*
* Values are passed through exactly as they arrived — validating or
* regenerating them is the tracing library's job, not the protocol's. The
* SDK echoes them onto the notifications a handler emits so a span stays
* joined across the stream.
*
* @return array<string, string> keyed by `traceparent`, `tracestate`, `baggage`
*/
public function getTraceContext(): array
{
$meta = $this->session->get(RequestMeta::class);
return $meta instanceof RequestMeta ? $meta->traceContext : [];
}
/**
* Seals handler context into the string an
* {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client.

View file

@ -35,11 +35,23 @@ final class RequestMeta
public const SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
public const SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId';
/**
* OpenTelemetry trace context, exempt from the prefix rule by name so it
* matches what the wider ecosystem already puts on the wire.
*
* @see https://www.w3.org/TR/trace-context/
*/
public const TRACE_KEYS = ['traceparent', 'tracestate', 'baggage'];
/**
* @param array<string, string> $traceContext W3C trace context carried through from the request
*/
public function __construct(
public readonly string $protocolVersion,
public readonly ClientCapabilities $clientCapabilities,
public readonly ?Implementation $clientInfo = null,
public readonly ?LoggingLevel $logLevel = null,
public readonly array $traceContext = [],
) {
}
@ -74,6 +86,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),
);
}
@ -85,4 +98,25 @@ final class RequestMeta
{
return \is_string($level) ? LoggingLevel::tryFrom($level) : null;
}
/**
* 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
*
* @return array<string, string>
*/
private static function parseTraceContext(array $meta): array
{
$context = [];
foreach (self::TRACE_KEYS as $key) {
if (\is_string($meta[$key] ?? null)) {
$context[$key] = $meta[$key];
}
}
return $context;
}
}

View file

@ -594,7 +594,7 @@ final class StatelessProtocol
{
try {
while ($run->valid()) {
yield $run->current()->jsonSerialize();
yield self::withTraceContext($run->current()->jsonSerialize(), $meta->traceContext);
$run->next();
}
@ -654,6 +654,31 @@ final class StatelessProtocol
);
}
/**
* Puts the request's trace context back onto a notification it caused, so a
* collector can join the two without the handler carrying it by hand.
*
* @param array<string, mixed> $frame
* @param array<string, string> $traceContext
*
* @return array<string, mixed>
*/
private static function withTraceContext(array $frame, array $traceContext): array
{
if ([] === $traceContext) {
return $frame;
}
$params = \is_array($frame['params'] ?? null) ? $frame['params'] : [];
$frameMeta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
// Anything the notification set itself wins: it knows its own span.
$params['_meta'] = [...$traceContext, ...$frameMeta];
$frame['params'] = $params;
return $frame;
}
/**
* The one place a handler's exception becomes an answer, so the streaming
* and non-streaming paths cannot disagree about which code it earns.

View file

@ -122,6 +122,18 @@ class StatelessProtocolTest extends TestCase
name: 'asks_by_url_after_progress',
description: 'Emits progress, then asks through a url-mode elicitation',
)
->addTool(
static function (RequestContext $context): string {
$pairs = [];
foreach ($context->getTraceContext() as $key => $value) {
$pairs[] = $key.'='.$value;
}
return implode(';', $pairs) ?: 'none';
},
name: 'probe_trace',
description: 'Reports the trace context it was called with',
)
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
->addResource(
static function (RequestContext $context): string|InputRequiredResult {
@ -150,9 +162,11 @@ class StatelessProtocolTest extends TestCase
*/
private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array
{
// Merged, not replaced: a test may add its own `_meta` members.
$params['_meta'] = [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
...($params['_meta'] ?? []),
];
return self::callWithHeaders($protocol, $method, $params, [
@ -409,6 +423,55 @@ class StatelessProtocolTest extends TestCase
$this->assertStringContainsString('InputRequiredResult', $body['error']['message']);
}
#[TestDox('a request\'s trace context reaches the handler')]
public function testTraceContextReachesTheHandler(): 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-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
'baggage' => 'tenant=acme',
],
],
['Mcp-Name' => 'probe_trace'],
);
$this->assertSame(
'traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01;baggage=tenant=acme',
$answer['body']['result']['content'][0]['text'],
);
}
#[TestDox('notifications caused by a traced request carry its trace context')]
public function testNotificationsCarryTheTraceContext(): void
{
$result = self::callStreaming(self::protocol(), 'progress_tool', [
'progressToken' => 'tok-1',
'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
]);
$frames = self::frames($result);
$this->assertSame(
'00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01',
$frames[0]['params']['_meta']['traceparent'],
);
}
#[TestDox('an untraced request adds no trace metadata')]
public function testUntracedRequestAddsNothing(): void
{
$frames = self::frames(self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']));
$this->assertArrayNotHasKey('traceparent', $frames[0]['params']['_meta'] ?? []);
}
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
public function testResourceNotFoundUsesInvalidParams(): void
{