[Server] Stream a modern-era response when there is something to stream (#448)

tools/call answered with one JSON object, so a handler's progress and log
notifications had nowhere to go — and reached for a fiber that was not
there, coming back as -32603. Handlers now run in a fiber and the stream
opens after the first suspension, late enough that an error still gets the
status the spec fixes for it. logLevel finally gates the log messages it
was already being parsed for, and a server-initiated request is refused by
name rather than as a FiberError.
This commit is contained in:
Christopher Hertel 2026-08-18 23:22:45 +02:00 • committed by GitHub
commit dd3783a720
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 425 additions and 43 deletions

View file

@ -13,15 +13,6 @@
# make conformance-draft-server
server:
# --- Progress notifications on a response stream ---------------------------
# The one place the modern lifecycle is behind the handshake one, and a real
# gap rather than a fixture difference: this scenario passes at / and fails
# at /stateless. A tools/call answer is a single JSON response here, so
# notifications a handler emits mid-call have nowhere to go. Closing it means
# answering tools/call over a stream — the same seam MRTR's IncompleteResult
# chunks will need — rather than anything specific to progress.
- tools-call-with-progress:tools-call-with-progress
# --- Pre-existing, not lifecycle-specific ----------------------------------
# Neither is caused by (or fixable in) the modern lifecycle; both reproduce
# on the handshake endpoint. Listed so the draft run is honest.

View file

@ -11,12 +11,18 @@
namespace Mcp\Tests\Unit\Server\Stateless;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server;
use Mcp\Server\RequestContext;
use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Stateless\StatelessResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@ -46,6 +52,47 @@ class StatelessProtocolTest extends TestCase
name: 'probe_capabilities',
description: 'Reports which client capabilities the gateway can see',
)
->addTool(
static function (RequestContext $context): string {
$gateway = $context->getClientGateway();
$gateway->progress(0, 100, 'starting');
$gateway->progress(100, 100, 'done');
return 'progressed';
},
name: 'progress_tool',
description: 'Reports progress while it works',
)
->addTool(
static function (RequestContext $context): string {
$gateway = $context->getClientGateway();
foreach ([LoggingLevel::Debug, LoggingLevel::Info, LoggingLevel::Warning, LoggingLevel::Error] as $level) {
$gateway->log($level, $level->value.' message');
}
return 'logged';
},
name: 'logging_tool',
description: 'Emits one message at each of four levels',
)
->addTool(
static function (): never {
throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'needs sampling');
},
name: 'capability_tool',
description: 'Always reports a missing client capability',
)
->addTool(
static function (RequestContext $context): string {
// The pattern this revision replaced: kept as a fixture so
// the refusal has something to refuse.
$context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n']));
return 'unreachable';
},
name: 'elicits_directly',
description: 'Asks the client directly, which this revision forbids',
)
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
->buildStateless([ProtocolVersion::V2026_07_28]);
}
@ -158,6 +205,165 @@ class StatelessProtocolTest extends TestCase
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
}
/**
* Drains a streaming result into the frames it would write.
*
* @return list<array<string, mixed>>
*/
private static function frames(StatelessResult $result): array
{
$frames = [];
foreach (($result->frames)() as $frame) {
if (null !== $frame) {
$frames[] = $frame;
}
}
return $frames;
}
/**
* @param array<string, mixed> $meta
*/
private static function callStreaming(StatelessProtocol $protocol, string $tool, array $meta = []): StatelessResult
{
return $protocol->handle(json_encode([
'jsonrpc' => '2.0',
'id' => 7,
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => [],
'_meta' => [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
...$meta,
],
],
], \JSON_THROW_ON_ERROR), [
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
'Mcp-Method' => 'tools/call',
'Mcp-Name' => $tool,
'Accept' => 'application/json, text/event-stream',
]);
}
#[TestDox('progress notifications reach the client on the response stream')]
public function testProgressStreamsBeforeTheResponse(): void
{
$result = self::callStreaming(self::protocol(), 'progress_tool', ['progressToken' => 'tok-1']);
$this->assertTrue($result->isStream());
$frames = self::frames($result);
$this->assertCount(3, $frames);
$this->assertSame('notifications/progress', $frames[0]['method']);
$this->assertSame('tok-1', $frames[0]['params']['progressToken']);
$this->assertSame('notifications/progress', $frames[1]['method']);
$this->assertSame(7, $frames[2]['id']);
$this->assertSame('complete', $frames[2]['result']['resultType']);
}
#[TestDox('without a progress token the handler emits nothing and gets a plain response')]
public function testProgressWithoutATokenIsNotStreamed(): void
{
$result = self::callStreaming(self::protocol(), 'progress_tool');
$this->assertFalse($result->isStream());
$this->assertSame(200, $result->httpStatus);
}
#[TestDox('a client that will not read a stream gets its notifications dropped, not a stream')]
public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void
{
$result = self::protocol()->handle(json_encode([
'jsonrpc' => '2.0',
'id' => 7,
'method' => 'tools/call',
'params' => [
'name' => 'progress_tool',
'arguments' => [],
'_meta' => [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
'progressToken' => 'tok-1',
],
],
], \JSON_THROW_ON_ERROR), [
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
'Mcp-Method' => 'tools/call',
'Mcp-Name' => 'progress_tool',
'Accept' => 'application/json',
]);
$this->assertFalse($result->isStream());
$this->assertSame(200, $result->httpStatus);
}
#[TestDox('a request naming no log level receives no log notifications')]
public function testLoggingIsSilentWithoutARequestedLevel(): void
{
$result = self::callStreaming(self::protocol(), 'logging_tool');
$this->assertFalse($result->isStream());
}
#[TestDox('a request naming a log level receives the messages at or above it')]
public function testLoggingHonoursTheRequestedLevel(): void
{
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'warning']));
$levels = [];
foreach ($frames as $frame) {
if ('notifications/message' === ($frame['method'] ?? null)) {
$levels[] = $frame['params']['level'];
}
}
// The tool emits debug, info, warning and error.
$this->assertSame(['warning', 'error'], $levels);
}
#[TestDox('a lower requested level lets more through')]
public function testLoggingAtDebugLetsEverythingThrough(): void
{
$frames = self::frames(self::callStreaming(self::protocol(), 'logging_tool', [RequestMeta::LOG_LEVEL => 'debug']));
$levels = [];
foreach ($frames as $frame) {
if ('notifications/message' === ($frame['method'] ?? null)) {
$levels[] = $frame['params']['level'];
}
}
$this->assertSame(['debug', 'info', 'warning', 'error'], $levels);
}
#[TestDox('an error raised before any notification keeps its own status')]
public function testEarlyFailureIsStillAStatusCode(): void
{
$result = self::callStreaming(self::protocol(), 'capability_tool');
$this->assertFalse($result->isStream());
$this->assertSame(400, $result->httpStatus);
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']);
}
#[TestDox('a server-initiated request is refused with the pattern that replaced it')]
public function testServerInitiatedRequestIsRefused(): void
{
$result = self::callStreaming(self::protocol(), 'elicits_directly');
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
$this->assertSame(500, $result->httpStatus);
$this->assertStringContainsString('InputRequiredResult', $body['error']['message']);
}
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
public function testResourceNotFoundUsesInvalidParams(): void
{