[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
|
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".
|
* 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 `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.
|
* 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;
|
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
|
* Seals handler context into the string an
|
||||||
* {@see \Mcp\Schema\Result\InputRequiredResult} carries to the client.
|
* {@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 SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
||||||
public const SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId';
|
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 function __construct(
|
||||||
public readonly string $protocolVersion,
|
public readonly string $protocolVersion,
|
||||||
public readonly ClientCapabilities $clientCapabilities,
|
public readonly ClientCapabilities $clientCapabilities,
|
||||||
public readonly ?Implementation $clientInfo = null,
|
public readonly ?Implementation $clientInfo = null,
|
||||||
public readonly ?LoggingLevel $logLevel = null,
|
public readonly ?LoggingLevel $logLevel = null,
|
||||||
|
public readonly array $traceContext = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +86,7 @@ final class RequestMeta
|
||||||
ClientCapabilities::fromArray((array) $capabilities),
|
ClientCapabilities::fromArray((array) $capabilities),
|
||||||
\is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null,
|
\is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null,
|
||||||
self::parseLogLevel($meta[self::LOG_LEVEL] ?? 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;
|
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 {
|
try {
|
||||||
while ($run->valid()) {
|
while ($run->valid()) {
|
||||||
yield $run->current()->jsonSerialize();
|
yield self::withTraceContext($run->current()->jsonSerialize(), $meta->traceContext);
|
||||||
|
|
||||||
$run->next();
|
$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
|
* The one place a handler's exception becomes an answer, so the streaming
|
||||||
* and non-streaming paths cannot disagree about which code it earns.
|
* 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',
|
name: 'asks_by_url_after_progress',
|
||||||
description: 'Emits progress, then asks through a url-mode elicitation',
|
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 fn (): string => 'body', 'test://static', 'static', 'A static resource')
|
||||||
->addResource(
|
->addResource(
|
||||||
static function (RequestContext $context): string|InputRequiredResult {
|
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
|
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'] = [
|
$params['_meta'] = [
|
||||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||||
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
|
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
|
||||||
|
...($params['_meta'] ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return self::callWithHeaders($protocol, $method, $params, [
|
return self::callWithHeaders($protocol, $method, $params, [
|
||||||
|
|
@ -409,6 +423,55 @@ class StatelessProtocolTest extends TestCase
|
||||||
$this->assertStringContainsString('InputRequiredResult', $body['error']['message']);
|
$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')]
|
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
|
||||||
public function testResourceNotFoundUsesInvalidParams(): void
|
public function testResourceNotFoundUsesInvalidParams(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue