[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:
parent
f6a995bc44
commit
855011b8dc
5 changed files with 141 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue