*/ abstract class DualEraExampleTestCase extends TestCase { /** Answers whatever the server elicits, so a tool that asks can complete. */ protected const ANSWERS = []; private ?Process $server = null; private int $port; /** Absolute path to the example's server script. */ abstract protected static function server(): string; /** Port range base, so concurrent test classes do not collide. */ abstract protected static function portBase(): int; protected function setUp(): void { // PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on // PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the // extra workers exist to avoid: every request below hangs to the test // timeout instead of running. Skip until either PHP 8.1 is dropped or // this suite stops depending on `php -S` for multi-worker concurrency. if (\PHP_VERSION_ID < 80200) { $this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.'); } $this->port = static::portBase() + (getmypid() % 200); // More than one worker because the handshake era needs it: a tool that // asks the client mid-call holds its SSE response open while the client // POSTs the answer on a second connection. One worker deadlocks there — // a property of `php -S`, not of the server. $this->server = new Process( ['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()], env: ['PHP_CLI_SERVER_WORKERS' => '4'], ); $this->server->start(); $deadline = microtime(true) + 5; while (microtime(true) < $deadline) { if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { return; } usleep(50_000); } $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); } protected function tearDown(): void { $this->server?->stop(); } /** * @return iterable */ public static function provideEras(): iterable { yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; yield 'the modern era' => [ProtocolVersion::V2026_07_28]; } protected static function text(CallToolResult $result): string { $first = $result->content[0] ?? null; self::assertInstanceOf(TextContent::class, $first); return $first->text; } protected function connect(ProtocolVersion $era, bool $elicitation = false): Client { $builder = Client::builder() ->setClientInfo('dual-era-integration-client', '1.0.0') ->setProtocolVersion($era) ->setRequestTimeout(10); if ($elicitation) { $answers = static::ANSWERS; $builder->setCapabilities(new ClientCapabilities(elicitation: true)); $builder->addRequestHandler(new class($answers) implements RequestHandlerInterface { /** @param array $answers */ public function __construct(private readonly array $answers) { } public function supports(Request $request): bool { return $request instanceof ElicitRequest; } public function handle(Request $request): Response { return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers)); } }); } $client = $builder->build(); $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); return $client; } }