From 0017dcb896ea34baf1cd028a7ce70a92716cdd4c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 00:52:16 +0200 Subject: [PATCH 1/3] [Tests] Introduce client-server integration tests * Add an in-process loopback that connects a real client transport to a real server transport, draining both Fiber loops in one synchronous pass instead of polling. A stalled exchange fails where it happened rather than hanging. * Cover protocol version negotiation across both implementations, where the unit tests only ever drive one side against a canned counter-offer. * Cover the server-to-client round-trips end to end: elicitation, sampling, roots, plus progress and logging notifications. * Add the `integration` test suite and a `make integration-tests` target. --- Makefile | 5 +- phpunit.xml.dist | 3 + tests/Integration/ElicitationTest.php | 138 ++++++++++++++++++ tests/Integration/HandshakeTest.php | 90 ++++++++++++ tests/Integration/IntegrationTestCase.php | 62 ++++++++ .../Loopback/LoopbackClientTransport.php | 135 +++++++++++++++++ .../Loopback/LoopbackConnection.php | 125 ++++++++++++++++ .../Loopback/LoopbackServerTransport.php | 138 ++++++++++++++++++ tests/Integration/NotificationTest.php | 93 ++++++++++++ tests/Integration/RootsTest.php | 120 +++++++++++++++ tests/Integration/SamplingTest.php | 117 +++++++++++++++ 11 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/ElicitationTest.php create mode 100644 tests/Integration/HandshakeTest.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/Loopback/LoopbackClientTransport.php create mode 100644 tests/Integration/Loopback/LoopbackConnection.php create mode 100644 tests/Integration/Loopback/LoopbackServerTransport.php create mode 100644 tests/Integration/NotificationTest.php create mode 100644 tests/Integration/RootsTest.php create mode 100644 tests/Integration/SamplingTest.php diff --git a/Makefile b/Makefile index 667aa046..c886bc5e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs deps-stable: composer update --prefer-stable @@ -18,6 +18,9 @@ tests: unit-tests: vendor/bin/phpunit --testsuite=unit +integration-tests: + vendor/bin/phpunit --testsuite=integration + inspector-tests: vendor/bin/phpunit --testsuite=inspector diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 54c2a8e1..eb07b79a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ tests/Unit + + tests/Integration + examples/server/oauth-microsoft/tests diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php new file mode 100644 index 00000000..5e3b9c86 --- /dev/null +++ b/tests/Integration/ElicitationTest.php @@ -0,0 +1,138 @@ +connect( + $this->serverWithElicitingTool(), + $this->clientAnswering(new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])), + ); + + $result = $client->callTool('ask_name'); + + $this->assertFalse($result->isError); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('accept:Ada', $result->content[0]->text); + } + + #[TestDox('a declined elicitation reaches the tool as a decline, not an error')] + public function testDeclinedElicitation(): void + { + $client = $this->connect( + $this->serverWithElicitingTool(), + $this->clientAnswering(new ElicitResult(ElicitAction::Decline)), + ); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('decline:', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise elicitation is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + // The tool checks supportsElicitation() before asking, and that answer + // comes from the capabilities this client sent during the handshake. + $client = $this->connect($this->serverWithElicitingTool()); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('a client advertising elicitation without a handler fails the tool call')] + public function testAdvertisedCapabilityWithoutHandler(): void + { + // The client answers "method not found", which the gateway raises inside + // the tool as a ClientException. Nothing hangs: the refusal travels the + // same path a result would, and the tool decides what to do with it. + $client = $this->connect( + $this->serverWithElicitingTool(), + $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), + ); + + $result = $client->callTool('ask_name'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); + } + + private function serverWithElicitingTool(): ServerBuilder + { + return $this->serverBuilder()->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsElicitation()) { + return 'unsupported'; + } + + try { + $result = $gateway->elicit('What is your name?', new ElicitationSchema([ + 'name' => new StringSchemaDefinition(title: 'Name'), + ])); + } catch (ClientException $e) { + return $e->getMessage(); + } + + return \sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); + }, + name: 'ask_name', + description: 'Asks the client for a name.', + ); + } + + private function clientAnswering(ElicitResult $answer): ClientBuilder + { + $callback = new class($answer) implements ElicitationCallbackInterface { + public function __construct(private readonly ElicitResult $answer) + { + } + + public function __invoke(ElicitRequest $request): ElicitResult + { + return $this->answer; + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler(new ElicitationRequestHandler($callback)); + } +} diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php new file mode 100644 index 00000000..e593c741 --- /dev/null +++ b/tests/Integration/HandshakeTest.php @@ -0,0 +1,90 @@ +serverBuilder(); + if (null !== $serverVersion) { + $server->setProtocolVersion($serverVersion); + } + + $client = $this->clientBuilder(); + if (null !== $clientVersion) { + $client->setProtocolVersion($clientVersion); + } + + $connected = $this->connect($server, $client); + + $this->assertSame($expected, $connected->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideNegotiations(): iterable + { + $latest = ProtocolVersion::latestHandshake(); + + yield 'both unconfigured' => [null, null, $latest]; + + // A client asking for a revision the server supports gets that exact one + // back, whichever end of the supported range it sits at. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('client asks for %s', $version->value) => [$version, null, $version]; + } + + // A pinned server answers with its pin, and this client continues on it + // rather than insisting on what it asked for. + yield 'server pins an older revision' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26]; + yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; + yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; + + // Neither side can reach the modern era through `initialize`, so + // configuring it falls back to the handshake set on both ends. + yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; + yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; + yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; + } + + #[TestDox('the handshake carries the server identity to the client')] + public function testServerInfoIsExchanged(): void + { + $client = $this->connect($this->serverBuilder()->setInstructions('Be brief.')); + + $this->assertSame('integration-server', $client->getServerInfo()->name); + $this->assertSame('1.0.0', $client->getServerInfo()->version); + $this->assertSame('Be brief.', $client->getInstructions()); + $this->assertTrue($client->isConnected()); + } + + #[TestDox('the negotiated revision is unset before the handshake')] + public function testProtocolVersionIsNullBeforeConnecting(): void + { + $this->assertNull($this->clientBuilder()->build()->getProtocolVersion()); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 00000000..8887d78e --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,62 @@ + + */ +abstract class IntegrationTestCase extends TestCase +{ + protected function serverBuilder(): ServerBuilder + { + return Server::builder()->setServerInfo('integration-server', '1.0.0'); + } + + protected function clientBuilder(): ClientBuilder + { + return Client::builder()->setClientInfo('integration-client', '1.0.0'); + } + + /** + * Connect a client to a server over an in-process loopback. + * + * The returned client has completed the handshake, so the negotiated + * revision and the server info are already readable. + */ + protected function connect(?ServerBuilder $server = null, ?ClientBuilder $client = null): Client + { + $connection = new LoopbackConnection(); + + // run() wires the protocol to the transport and returns straight away: + // the loopback transport has no loop of its own to enter, because the + // connection drives it from the client's side instead. + ($server ?? $this->serverBuilder())->build()->run($connection->serverTransport()); + + $mcpClient = ($client ?? $this->clientBuilder())->build(); + $mcpClient->connect($connection->clientTransport()); + + return $mcpClient; + } +} diff --git a/tests/Integration/Loopback/LoopbackClientTransport.php b/tests/Integration/Loopback/LoopbackClientTransport.php new file mode 100644 index 00000000..f9f6feeb --- /dev/null +++ b/tests/Integration/Loopback/LoopbackClientTransport.php @@ -0,0 +1,135 @@ + + */ +final class LoopbackClientTransport extends BaseTransport +{ + /** @var (callable(float, ?float, ?string): void)|null */ + private $progressCallback; + + public function __construct( + private readonly LoopbackConnection $connection, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + } + + public function connect(): void + { + /** @var McpFiber $fiber */ + $fiber = new \Fiber(fn () => $this->handleInitialize()); + + $result = $this->run($fiber); + + if ($result instanceof Error) { + $this->close(); + + throw new ConnectionException('Initialization failed: '.$result->message); + } + + $this->logger->info('Loopback client connected and initialized'); + } + + public function send(string $data): void + { + $this->connection->toServer($data); + $this->connection->drain(); + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->progressCallback = $onProgress; + + try { + return $this->run($fiber); + } finally { + $this->progressCallback = null; + } + } + + /** + * Hand a server message to the protocol. + */ + public function receive(string $payload): void + { + $this->handleMessage($payload); + } + + public function close(): void + { + $this->handleClose('Loopback transport closed'); + } + + /** + * @param McpFiber $fiber + * + * @return Response>|Error + */ + private function run(\Fiber $fiber): Response|Error + { + $suspend = $fiber->start(); + + while (!$fiber->isTerminated()) { + $this->connection->drain(); + $this->flushProgress(); + + $response = $this->state?->consumeResponse($suspend['request_id']); + + if (null === $response) { + // The exchange settled without an answer, so waiting longer cannot + // help: either the server never produced one, or it is still + // suspended waiting on this client. Failing here keeps the stall + // attributable instead of hanging the suite. + throw new ConnectionException(\sprintf('Loopback exchange settled without a response to request %d.', $suspend['request_id'])); + } + + $suspend = $fiber->resume($response); + } + + $this->flushProgress(); + + return $fiber->getReturn(); + } + + /** + * Report progress notifications the protocol stored while the exchange ran. + */ + private function flushProgress(): void + { + if (null === $this->progressCallback || null === $this->state) { + return; + } + + foreach ($this->state->consumeProgressUpdates() as $update) { + ($this->progressCallback)($update['progress'], $update['total'], $update['message']); + } + } +} diff --git a/tests/Integration/Loopback/LoopbackConnection.php b/tests/Integration/Loopback/LoopbackConnection.php new file mode 100644 index 00000000..f3a888be --- /dev/null +++ b/tests/Integration/Loopback/LoopbackConnection.php @@ -0,0 +1,125 @@ + + */ +final class LoopbackConnection +{ + /** + * Upper bound on drain iterations before the exchange is considered stuck. + * + * Only a bug can reach it — a handler that answers its own message forever, + * say — and without it that bug would hang the suite instead of failing it. + */ + private const MAX_STEPS = 1000; + + private readonly LoopbackClientTransport $clientTransport; + private readonly LoopbackServerTransport $serverTransport; + + /** @var list */ + private array $toServer = []; + + /** @var list */ + private array $toClient = []; + + private bool $draining = false; + + public function __construct(?LoggerInterface $logger = null) + { + $this->clientTransport = new LoopbackClientTransport($this, $logger); + $this->serverTransport = new LoopbackServerTransport($this, $logger); + } + + public function clientTransport(): LoopbackClientTransport + { + return $this->clientTransport; + } + + public function serverTransport(): LoopbackServerTransport + { + return $this->serverTransport; + } + + public function toServer(string $payload): void + { + $this->toServer[] = $payload; + } + + public function toClient(string $payload): void + { + $this->toClient[] = $payload; + } + + /** + * Move messages between both sides until the exchange settles. + * + * Answering a message sends one back, so this keeps looping while anything + * moved in the previous pass rather than draining each queue once. + */ + public function drain(): void + { + // Answering a server request re-enters here through the client's send(). + // The exchange is already being drained by the caller below, which picks + // the queued message up on its next pass. + if ($this->draining) { + return; + } + + $this->draining = true; + + try { + $steps = 0; + + do { + if (++$steps > self::MAX_STEPS) { + throw new RuntimeException(\sprintf('Loopback exchange did not settle within %d steps.', self::MAX_STEPS)); + } + + $progressed = false; + + while (null !== ($payload = array_shift($this->toServer))) { + $this->serverTransport->deliver($payload); + $progressed = true; + } + + $progressed = $this->serverTransport->pump() || $progressed; + + while (null !== ($payload = array_shift($this->toClient))) { + $this->clientTransport->receive($payload); + $progressed = true; + } + } while ($progressed); + } finally { + $this->draining = false; + } + } +} diff --git a/tests/Integration/Loopback/LoopbackServerTransport.php b/tests/Integration/Loopback/LoopbackServerTransport.php new file mode 100644 index 00000000..75962a6d --- /dev/null +++ b/tests/Integration/Loopback/LoopbackServerTransport.php @@ -0,0 +1,138 @@ + + * + * @author Christopher Hertel + */ +final class LoopbackServerTransport extends BaseTransport +{ + public function __construct( + private readonly LoopbackConnection $connection, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + } + + /** + * The connection drives this transport, so there is no loop to run here. + */ + public function listen(): mixed + { + return null; + } + + public function send(string $data, array $context): void + { + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + + $this->connection->toClient($data); + } + + /** + * Hand a client message to the protocol. + */ + public function deliver(string $payload): void + { + $this->handleMessage($payload, $this->sessionId); + } + + /** + * Advance the session Fiber and flush whatever the protocol queued. + * + * @return bool whether anything moved, which is what tells the connection to keep draining + */ + public function pump(): bool + { + $progressed = $this->advanceFiber(); + + foreach ($this->getOutgoingMessages($this->sessionId) as $message) { + $this->connection->toClient($message['message']); + $progressed = true; + } + + return $progressed; + } + + private function advanceFiber(): bool + { + if (null === $this->sessionFiber) { + return false; + } + + if ($this->sessionFiber->isTerminated()) { + $this->finishFiber(); + + return true; + } + + if (!$this->sessionFiber->isSuspended()) { + return false; + } + + $pendingRequests = $this->getPendingRequests($this->sessionId); + + if ([] === $pendingRequests) { + $this->handleFiberYield($this->sessionFiber->resume(), $this->sessionId); + + return true; + } + + foreach ($pendingRequests as $pending) { + \assert(\is_int($pending['request_id'])); + + $response = $this->checkForResponse($pending['request_id'], $this->sessionId); + + if (null !== $response) { + $this->handleFiberYield($this->sessionFiber->resume($response), $this->sessionId); + + return true; + } + } + + // Still waiting on the client. Unlike the stdio transport there is no + // timeout to expire here: nothing runs concurrently, so a response that + // has not arrived by now is never going to. The connection settles and + // the client transport reports the stall instead. + return false; + } + + private function finishFiber(): void + { + $result = $this->sessionFiber?->getReturn(); + $this->sessionFiber = null; + + if (null === $result) { + return; + } + + try { + $this->connection->toClient(json_encode($result, \JSON_THROW_ON_ERROR)); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode the final Fiber result.', ['exception' => $e]); + } + } +} diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php new file mode 100644 index 00000000..4cd3552d --- /dev/null +++ b/tests/Integration/NotificationTest.php @@ -0,0 +1,93 @@ +connect($this->serverWithReportingTool()); + + $updates = []; + $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { + $updates[] = [$progress, $total, $message]; + }); + + $this->assertSame([[0.5, 1.0, 'halfway'], [1.0, 1.0, 'done']], $updates); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('progress is skipped when the caller asked for none')] + public function testProgressIsSkippedWithoutAToken(): void + { + // Without an onProgress callback the request carries no progress token, + // and the gateway drops the notification rather than sending one the + // client could not correlate. + $client = $this->connect($this->serverWithReportingTool()); + + $result = $client->callTool('work'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('finished', $result->content[0]->text); + } + + #[TestDox('log notifications reach a registered logging handler')] + public function testLoggingReachesTheClient(): void + { + $logged = []; + $client = $this->connect( + $this->serverWithReportingTool(), + $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( + static function (LoggingMessageNotification $notification) use (&$logged): void { + $logged[] = [$notification->level, $notification->data]; + }, + )), + ); + + $client->callTool('work'); + + $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); + } + + private function serverWithReportingTool(): ServerBuilder + { + return $this->serverBuilder()->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + $gateway->log(LoggingLevel::Info, 'starting work'); + $gateway->progress(0.5, 1.0, 'halfway'); + $gateway->progress(1.0, 1.0, 'done'); + + return 'finished'; + }, + name: 'work', + description: 'Reports progress and logs while working.', + ); + } +} diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php new file mode 100644 index 00000000..68ab6a36 --- /dev/null +++ b/tests/Integration/RootsTest.php @@ -0,0 +1,120 @@ +connect($this->serverWithRootsTool(), $this->clientExposing( + new Root('file:///workspace/app', 'App'), + new Root('file:///workspace/docs', 'Docs'), + )); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('file:///workspace/app (App), file:///workspace/docs (Docs)', $result->content[0]->text); + } + + #[TestDox('an empty root list is a valid answer, not a failure')] + public function testEmptyRootList(): void + { + $client = $this->connect($this->serverWithRootsTool(), $this->clientExposing()); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('', $result->content[0]->text); + } + + #[TestDox('a client that does not advertise roots is not asked')] + public function testCapabilityIsVisibleToTheServer(): void + { + $client = $this->connect($this->serverWithRootsTool()); + + $result = $client->callTool('inspect_roots'); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('unsupported', $result->content[0]->text); + } + + #[TestDox('the client can announce that its roots changed')] + public function testRootsListChangedNotification(): void + { + $client = $this->connect($this->serverWithRootsTool(), $this->clientExposing(new Root('file:///workspace'))); + + // A notification has no reply, so what this pins down is that sending one + // mid-session neither raises nor leaves the connection unusable. + $client->sendRootsListChanged(); + + $this->assertTrue($client->isConnected()); + $this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]); + } + + private function serverWithRootsTool(): ServerBuilder + { + return $this->serverBuilder()->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsRoots()) { + return 'unsupported'; + } + + $described = []; + foreach ($gateway->listRoots()->roots as $root) { + $described[] = \sprintf('%s (%s)', $root->uri, $root->name ?? '-'); + } + + return implode(', ', $described); + }, + name: 'inspect_roots', + description: 'Reports the workspace roots the client exposes.', + ); + } + + private function clientExposing(Root ...$roots): ClientBuilder + { + $callback = new class(array_values($roots)) implements RootsCallbackInterface { + /** @param list $roots */ + public function __construct(private readonly array $roots) + { + } + + public function __invoke(ListRootsRequest $request): ListRootsResult + { + return new ListRootsResult($this->roots); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true)) + ->addRequestHandler(new ListRootsRequestHandler($callback)); + } +} diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php new file mode 100644 index 00000000..3df61ed4 --- /dev/null +++ b/tests/Integration/SamplingTest.php @@ -0,0 +1,117 @@ +connect($this->serverWithSamplingTool(), $this->clientSampling()); + + $result = $client->callTool('summarize', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + + #[TestDox('the prompt the tool passed arrives at the client')] + public function testPromptReachesTheClient(): void + { + /** @var \ArrayObject $seen */ + $seen = new \ArrayObject(); + $client = $this->connect($this->serverWithSamplingTool(), $this->clientSampling($seen)); + + $client->callTool('summarize', ['text' => 'inspect me']); + + $this->assertCount(1, $seen); + $this->assertInstanceOf(TextContent::class, $seen[0]->messages[0]->content); + $this->assertSame('inspect me', $seen[0]->messages[0]->content->text); + $this->assertSame(64, $seen[0]->maxTokens); + } + + #[TestDox('a client that cannot sample refuses instead of stalling the tool')] + public function testClientWithoutSamplingRefuses(): void + { + // The gateway has no supportsSampling() to consult, so the tool finds out + // by asking: the client answers "method not found" and that surfaces as a + // ClientException rather than a Fiber waiting on a response forever. + $client = $this->connect($this->serverWithSamplingTool()); + + $result = $client->callTool('summarize', ['text' => 'anything']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); + } + + private function serverWithSamplingTool(): ServerBuilder + { + return $this->serverBuilder()->addTool( + static function (RequestContext $context, string $text): string { + try { + $result = $context->getClientGateway()->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + \assert($result->content instanceof TextContent); + + return \sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize', + description: 'Summarizes text by asking the client to sample.', + ); + } + + /** + * @param \ArrayObject|null $seen collects what the server asked for + */ + private function clientSampling(?\ArrayObject $seen = null): ClientBuilder + { + $callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface { + /** @param \ArrayObject $seen */ + public function __construct(private readonly \ArrayObject $seen) + { + } + + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + $this->seen[] = $request; + + $prompt = $request->messages[0]->content; + \assert($prompt instanceof TextContent); + + return new CreateSamplingMessageResult(Role::Assistant, new TextContent($prompt->text), 'test-model'); + } + }; + + return $this->clientBuilder() + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addRequestHandler(new SamplingRequestHandler($callback)); + } +} From 0413e9fd164c548e073e724e930dc19138fe73c2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 00:57:55 +0200 Subject: [PATCH 2/3] [Tests] Run the integration suite in CI The unit job pins `--testsuite=unit`, so the new suite would never have run on GitHub. It goes in that job rather than its own: the loopback leans on Fibers, and the matrix is what covers them down to the PHP 8.1 floor. --- .github/workflows/pipeline.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 9b76066b..45792d61 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -49,6 +49,9 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=unit + - name: Integration Tests + run: vendor/bin/phpunit --testsuite=integration + inspector: runs-on: ubuntu-latest steps: From 867d25b58f47c3044f7359f8141bea29e22ba456 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 02:13:00 +0200 Subject: [PATCH 3/3] [Tests] Run integration tests against a real server process Replaces the in-process loopback with the setup from examples/client: the client spawns a fixture server over the real StdioTransport, so the tests exercise the shipped transports and no longer nest both sides' Fibers. Moves the suite into its own CI job, matrixed over PHP 8.1-8.5. --- .github/workflows/pipeline.yaml | 21 ++- tests/Integration/ElicitationTest.php | 61 ++------ tests/Integration/Fixture/elicitation.php | 49 +++++++ tests/Integration/Fixture/handshake.php | 32 ++++ tests/Integration/Fixture/notification.php | 39 +++++ tests/Integration/Fixture/roots.php | 43 ++++++ tests/Integration/Fixture/sampling.php | 42 ++++++ tests/Integration/HandshakeTest.php | 27 ++-- tests/Integration/IntegrationTestCase.php | 67 ++++++--- .../Loopback/LoopbackClientTransport.php | 135 ----------------- .../Loopback/LoopbackConnection.php | 125 ---------------- .../Loopback/LoopbackServerTransport.php | 138 ------------------ tests/Integration/NotificationTest.php | 34 +---- tests/Integration/RootsTest.php | 38 +---- tests/Integration/SamplingTest.php | 37 ++--- 15 files changed, 320 insertions(+), 568 deletions(-) create mode 100644 tests/Integration/Fixture/elicitation.php create mode 100644 tests/Integration/Fixture/handshake.php create mode 100644 tests/Integration/Fixture/notification.php create mode 100644 tests/Integration/Fixture/roots.php create mode 100644 tests/Integration/Fixture/sampling.php delete mode 100644 tests/Integration/Loopback/LoopbackClientTransport.php delete mode 100644 tests/Integration/Loopback/LoopbackConnection.php delete mode 100644 tests/Integration/Loopback/LoopbackServerTransport.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 45792d61..f7396e60 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -49,7 +49,26 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=unit - - name: Integration Tests + integration: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v4" + + - name: Tests run: vendor/bin/phpunit --testsuite=integration inspector: diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php index 5e3b9c86..dc168479 100644 --- a/tests/Integration/ElicitationTest.php +++ b/tests/Integration/ElicitationTest.php @@ -14,33 +14,29 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\ElicitationCallbackInterface; use Mcp\Client\Handler\Request\ElicitationRequestHandler; -use Mcp\Exception\ClientException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Elicitation\ElicitationSchema; -use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\ElicitAction; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\ElicitResult; -use Mcp\Server\Builder as ServerBuilder; -use Mcp\Server\RequestContext; use PHPUnit\Framework\Attributes\TestDox; /** * Elicitation, driven all the way around the loop. * - * This is the round-trip that suspends both sides at once: the tool's Fiber - * waits on the client while the client's request Fiber waits on the tool. + * The round-trip that suspends both sides at once: the tool's Fiber waits on + * the client while the client's request Fiber waits on the tool. + * + * @see Fixture/elicitation.php for the server under test */ final class ElicitationTest extends IntegrationTestCase { #[TestDox('an accepted elicitation hands the content back to the tool')] public function testAcceptedElicitation(): void { - $client = $this->connect( - $this->serverWithElicitingTool(), - $this->clientAnswering(new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])), - ); + $client = $this->connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']), + )); $result = $client->callTool('ask_name'); @@ -52,10 +48,9 @@ public function testAcceptedElicitation(): void #[TestDox('a declined elicitation reaches the tool as a decline, not an error')] public function testDeclinedElicitation(): void { - $client = $this->connect( - $this->serverWithElicitingTool(), - $this->clientAnswering(new ElicitResult(ElicitAction::Decline)), - ); + $client = $this->connect('elicitation', $this->clientAnswering( + new ElicitResult(ElicitAction::Decline), + )); $result = $client->callTool('ask_name'); @@ -66,9 +61,9 @@ public function testDeclinedElicitation(): void #[TestDox('a client that does not advertise elicitation is not asked')] public function testCapabilityIsVisibleToTheServer(): void { - // The tool checks supportsElicitation() before asking, and that answer - // comes from the capabilities this client sent during the handshake. - $client = $this->connect($this->serverWithElicitingTool()); + // The tool consults supportsElicitation(), which answers from the + // capabilities this client sent during the handshake. + $client = $this->connect('elicitation'); $result = $client->callTool('ask_name'); @@ -80,10 +75,9 @@ public function testCapabilityIsVisibleToTheServer(): void public function testAdvertisedCapabilityWithoutHandler(): void { // The client answers "method not found", which the gateway raises inside - // the tool as a ClientException. Nothing hangs: the refusal travels the - // same path a result would, and the tool decides what to do with it. + // the tool as a ClientException rather than leaving it waiting. $client = $this->connect( - $this->serverWithElicitingTool(), + 'elicitation', $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), ); @@ -93,31 +87,6 @@ public function testAdvertisedCapabilityWithoutHandler(): void $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); } - private function serverWithElicitingTool(): ServerBuilder - { - return $this->serverBuilder()->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - if (!$gateway->supportsElicitation()) { - return 'unsupported'; - } - - try { - $result = $gateway->elicit('What is your name?', new ElicitationSchema([ - 'name' => new StringSchemaDefinition(title: 'Name'), - ])); - } catch (ClientException $e) { - return $e->getMessage(); - } - - return \sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); - }, - name: 'ask_name', - description: 'Asks the client for a name.', - ); - } - private function clientAnswering(ElicitResult $answer): ClientBuilder { $callback = new class($answer) implements ElicitationCallbackInterface { diff --git a/tests/Integration/Fixture/elicitation.php b/tests/Integration/Fixture/elicitation.php new file mode 100644 index 00000000..ad588a94 --- /dev/null +++ b/tests/Integration/Fixture/elicitation.php @@ -0,0 +1,49 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsElicitation()) { + return 'unsupported'; + } + + try { + $result = $gateway->elicit('What is your name?', new ElicitationSchema([ + 'name' => new StringSchemaDefinition(title: 'Name'), + ])); + } catch (ClientException $e) { + return $e->getMessage(); + } + + return sprintf('%s:%s', $result->action->value, $result->content['name'] ?? ''); + }, + name: 'ask_name', + description: 'Asks the client for a name.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php new file mode 100644 index 00000000..776f4513 --- /dev/null +++ b/tests/Integration/Fixture/handshake.php @@ -0,0 +1,32 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.'); + +if (is_string($pinned = getenv('MCP_INTEGRATION_PROTOCOL_VERSION')) && '' !== $pinned) { + $builder->setProtocolVersion(ProtocolVersion::from($pinned)); +} + +$builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/notification.php b/tests/Integration/Fixture/notification.php new file mode 100644 index 00000000..166e84c6 --- /dev/null +++ b/tests/Integration/Fixture/notification.php @@ -0,0 +1,39 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + $gateway->log(LoggingLevel::Info, 'starting work'); + $gateway->progress(0.5, 1.0, 'halfway'); + $gateway->progress(1.0, 1.0, 'done'); + + return 'finished'; + }, + name: 'work', + description: 'Reports progress and logs while working.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/roots.php b/tests/Integration/Fixture/roots.php new file mode 100644 index 00000000..a4b63824 --- /dev/null +++ b/tests/Integration/Fixture/roots.php @@ -0,0 +1,43 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + + if (!$gateway->supportsRoots()) { + return 'unsupported'; + } + + $described = []; + foreach ($gateway->listRoots()->roots as $root) { + $described[] = sprintf('%s (%s)', $root->uri, $root->name ?? '-'); + } + + return implode(', ', $described); + }, + name: 'inspect_roots', + description: 'Reports the workspace roots the client exposes.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php new file mode 100644 index 00000000..bb37bbfa --- /dev/null +++ b/tests/Integration/Fixture/sampling.php @@ -0,0 +1,42 @@ +setServerInfo('integration-server', '1.0.0') + ->addTool( + static function (RequestContext $context, string $text): string { + try { + $result = $context->getClientGateway()->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize', + description: 'Summarizes text by asking the client to sample.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index e593c741..5e0455ff 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -16,11 +16,9 @@ use PHPUnit\Framework\Attributes\TestDox; /** - * Protocol version negotiation, run across both implementations at once. + * What the two sides settle on before anything else can happen. * - * The unit tests either drive the client against a canned counter-offer or the - * server against a canned request. Neither can show that the version this - * server answers with is the version this client ends up on. + * @see Fixture/handshake.php for the server under test */ final class HandshakeTest extends IntegrationTestCase { @@ -28,17 +26,16 @@ final class HandshakeTest extends IntegrationTestCase #[DataProvider('provideNegotiations')] public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?ProtocolVersion $serverVersion, ProtocolVersion $expected): void { - $server = $this->serverBuilder(); - if (null !== $serverVersion) { - $server->setProtocolVersion($serverVersion); - } - $client = $this->clientBuilder(); if (null !== $clientVersion) { $client->setProtocolVersion($clientVersion); } - $connected = $this->connect($server, $client); + $connected = $this->connect( + 'handshake', + $client, + null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], + ); $this->assertSame($expected, $connected->getProtocolVersion()); } @@ -52,19 +49,17 @@ public static function provideNegotiations(): iterable yield 'both unconfigured' => [null, null, $latest]; - // A client asking for a revision the server supports gets that exact one - // back, whichever end of the supported range it sits at. + // Whichever end of the supported range it sits at. foreach (ProtocolVersion::handshakeVersions() as $version) { yield \sprintf('client asks for %s', $version->value) => [$version, null, $version]; } - // A pinned server answers with its pin, and this client continues on it - // rather than insisting on what it asked for. + // A pinned server answers with its pin, and the client continues on it. yield 'server pins an older revision' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26]; yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; - // Neither side can reach the modern era through `initialize`, so + // Neither side reaches the modern era through `initialize`, so // configuring it falls back to the handshake set on both ends. yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; @@ -74,7 +69,7 @@ public static function provideNegotiations(): iterable #[TestDox('the handshake carries the server identity to the client')] public function testServerInfoIsExchanged(): void { - $client = $this->connect($this->serverBuilder()->setInstructions('Be brief.')); + $client = $this->connect('handshake'); $this->assertSame('integration-server', $client->getServerInfo()->name); $this->assertSame('1.0.0', $client->getServerInfo()->version); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 8887d78e..aa0dd9c0 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -13,50 +13,71 @@ use Mcp\Client; use Mcp\Client\Builder as ClientBuilder; -use Mcp\Server; -use Mcp\Server\Builder as ServerBuilder; -use Mcp\Tests\Integration\Loopback\LoopbackConnection; +use Mcp\Client\Transport\StdioTransport; +use Mcp\Exception\ConnectionException; use PHPUnit\Framework\TestCase; /** - * Base for tests that run a real client against a real server. + * Base for tests that run a real client against a real server process. * * Every other test in the suite mocks one side of the conversation. These run - * both, so what they cover is the agreement between the two halves rather than - * either half against an expectation of the other. + * both, wired the way `examples/client` wires them, so what they cover is the + * agreement between the two halves. The servers live in {@see Fixture}, one + * script per scenario. * * @author Christopher Hertel */ abstract class IntegrationTestCase extends TestCase { - protected function serverBuilder(): ServerBuilder - { - return Server::builder()->setServerInfo('integration-server', '1.0.0'); - } + /** + * Both sides answer immediately, so anything reaching this is a deadlock. + * Far below the SDK's two-minute default, to fail rather than hang. + */ + private const TIMEOUT = 5; + + private ?Client $client = null; protected function clientBuilder(): ClientBuilder { - return Client::builder()->setClientInfo('integration-client', '1.0.0'); + return Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT); } /** - * Connect a client to a server over an in-process loopback. + * Spawn a fixture server and connect a client to it. * - * The returned client has completed the handshake, so the negotiated - * revision and the server info are already readable. + * The returned client has completed the handshake. + * + * @param string $fixture basename of a script in {@see Fixture} + * @param array $env added to the server process environment */ - protected function connect(?ServerBuilder $server = null, ?ClientBuilder $client = null): Client + protected function connect(string $fixture, ?ClientBuilder $client = null, array $env = []): Client { - $connection = new LoopbackConnection(); + $script = __DIR__.'/Fixture/'.$fixture.'.php'; + + $this->client = ($client ?? $this->clientBuilder())->build(); - // run() wires the protocol to the transport and returns straight away: - // the loopback transport has no loop of its own to enter, because the - // connection drives it from the client's side instead. - ($server ?? $this->serverBuilder())->build()->run($connection->serverTransport()); + try { + $this->client->connect(new StdioTransport( + command: \PHP_BINARY, + args: [$script], + // proc_open() replaces the environment rather than adding to it. + env: [] === $env ? null : array_merge(getenv(), $env), + )); + } catch (ConnectionException $e) { + // The transport discards the child's stderr, so a fixture dying on + // startup arrives here as a bare timeout. + $this->fail(\sprintf('Could not connect to fixture server "%s": %s. Run `%s %s` to see why.', $fixture, $e->getMessage(), \PHP_BINARY, $script)); + } - $mcpClient = ($client ?? $this->clientBuilder())->build(); - $mcpClient->connect($connection->clientTransport()); + return $this->client; + } - return $mcpClient; + protected function tearDown(): void + { + $this->client?->disconnect(); + $this->client = null; } } diff --git a/tests/Integration/Loopback/LoopbackClientTransport.php b/tests/Integration/Loopback/LoopbackClientTransport.php deleted file mode 100644 index f9f6feeb..00000000 --- a/tests/Integration/Loopback/LoopbackClientTransport.php +++ /dev/null @@ -1,135 +0,0 @@ - - */ -final class LoopbackClientTransport extends BaseTransport -{ - /** @var (callable(float, ?float, ?string): void)|null */ - private $progressCallback; - - public function __construct( - private readonly LoopbackConnection $connection, - ?LoggerInterface $logger = null, - ) { - parent::__construct($logger); - } - - public function connect(): void - { - /** @var McpFiber $fiber */ - $fiber = new \Fiber(fn () => $this->handleInitialize()); - - $result = $this->run($fiber); - - if ($result instanceof Error) { - $this->close(); - - throw new ConnectionException('Initialization failed: '.$result->message); - } - - $this->logger->info('Loopback client connected and initialized'); - } - - public function send(string $data): void - { - $this->connection->toServer($data); - $this->connection->drain(); - } - - public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error - { - $this->progressCallback = $onProgress; - - try { - return $this->run($fiber); - } finally { - $this->progressCallback = null; - } - } - - /** - * Hand a server message to the protocol. - */ - public function receive(string $payload): void - { - $this->handleMessage($payload); - } - - public function close(): void - { - $this->handleClose('Loopback transport closed'); - } - - /** - * @param McpFiber $fiber - * - * @return Response>|Error - */ - private function run(\Fiber $fiber): Response|Error - { - $suspend = $fiber->start(); - - while (!$fiber->isTerminated()) { - $this->connection->drain(); - $this->flushProgress(); - - $response = $this->state?->consumeResponse($suspend['request_id']); - - if (null === $response) { - // The exchange settled without an answer, so waiting longer cannot - // help: either the server never produced one, or it is still - // suspended waiting on this client. Failing here keeps the stall - // attributable instead of hanging the suite. - throw new ConnectionException(\sprintf('Loopback exchange settled without a response to request %d.', $suspend['request_id'])); - } - - $suspend = $fiber->resume($response); - } - - $this->flushProgress(); - - return $fiber->getReturn(); - } - - /** - * Report progress notifications the protocol stored while the exchange ran. - */ - private function flushProgress(): void - { - if (null === $this->progressCallback || null === $this->state) { - return; - } - - foreach ($this->state->consumeProgressUpdates() as $update) { - ($this->progressCallback)($update['progress'], $update['total'], $update['message']); - } - } -} diff --git a/tests/Integration/Loopback/LoopbackConnection.php b/tests/Integration/Loopback/LoopbackConnection.php deleted file mode 100644 index f3a888be..00000000 --- a/tests/Integration/Loopback/LoopbackConnection.php +++ /dev/null @@ -1,125 +0,0 @@ - - */ -final class LoopbackConnection -{ - /** - * Upper bound on drain iterations before the exchange is considered stuck. - * - * Only a bug can reach it — a handler that answers its own message forever, - * say — and without it that bug would hang the suite instead of failing it. - */ - private const MAX_STEPS = 1000; - - private readonly LoopbackClientTransport $clientTransport; - private readonly LoopbackServerTransport $serverTransport; - - /** @var list */ - private array $toServer = []; - - /** @var list */ - private array $toClient = []; - - private bool $draining = false; - - public function __construct(?LoggerInterface $logger = null) - { - $this->clientTransport = new LoopbackClientTransport($this, $logger); - $this->serverTransport = new LoopbackServerTransport($this, $logger); - } - - public function clientTransport(): LoopbackClientTransport - { - return $this->clientTransport; - } - - public function serverTransport(): LoopbackServerTransport - { - return $this->serverTransport; - } - - public function toServer(string $payload): void - { - $this->toServer[] = $payload; - } - - public function toClient(string $payload): void - { - $this->toClient[] = $payload; - } - - /** - * Move messages between both sides until the exchange settles. - * - * Answering a message sends one back, so this keeps looping while anything - * moved in the previous pass rather than draining each queue once. - */ - public function drain(): void - { - // Answering a server request re-enters here through the client's send(). - // The exchange is already being drained by the caller below, which picks - // the queued message up on its next pass. - if ($this->draining) { - return; - } - - $this->draining = true; - - try { - $steps = 0; - - do { - if (++$steps > self::MAX_STEPS) { - throw new RuntimeException(\sprintf('Loopback exchange did not settle within %d steps.', self::MAX_STEPS)); - } - - $progressed = false; - - while (null !== ($payload = array_shift($this->toServer))) { - $this->serverTransport->deliver($payload); - $progressed = true; - } - - $progressed = $this->serverTransport->pump() || $progressed; - - while (null !== ($payload = array_shift($this->toClient))) { - $this->clientTransport->receive($payload); - $progressed = true; - } - } while ($progressed); - } finally { - $this->draining = false; - } - } -} diff --git a/tests/Integration/Loopback/LoopbackServerTransport.php b/tests/Integration/Loopback/LoopbackServerTransport.php deleted file mode 100644 index 75962a6d..00000000 --- a/tests/Integration/Loopback/LoopbackServerTransport.php +++ /dev/null @@ -1,138 +0,0 @@ - - * - * @author Christopher Hertel - */ -final class LoopbackServerTransport extends BaseTransport -{ - public function __construct( - private readonly LoopbackConnection $connection, - ?LoggerInterface $logger = null, - ) { - parent::__construct($logger); - } - - /** - * The connection drives this transport, so there is no loop to run here. - */ - public function listen(): mixed - { - return null; - } - - public function send(string $data, array $context): void - { - if (isset($context['session_id'])) { - $this->sessionId = $context['session_id']; - } - - $this->connection->toClient($data); - } - - /** - * Hand a client message to the protocol. - */ - public function deliver(string $payload): void - { - $this->handleMessage($payload, $this->sessionId); - } - - /** - * Advance the session Fiber and flush whatever the protocol queued. - * - * @return bool whether anything moved, which is what tells the connection to keep draining - */ - public function pump(): bool - { - $progressed = $this->advanceFiber(); - - foreach ($this->getOutgoingMessages($this->sessionId) as $message) { - $this->connection->toClient($message['message']); - $progressed = true; - } - - return $progressed; - } - - private function advanceFiber(): bool - { - if (null === $this->sessionFiber) { - return false; - } - - if ($this->sessionFiber->isTerminated()) { - $this->finishFiber(); - - return true; - } - - if (!$this->sessionFiber->isSuspended()) { - return false; - } - - $pendingRequests = $this->getPendingRequests($this->sessionId); - - if ([] === $pendingRequests) { - $this->handleFiberYield($this->sessionFiber->resume(), $this->sessionId); - - return true; - } - - foreach ($pendingRequests as $pending) { - \assert(\is_int($pending['request_id'])); - - $response = $this->checkForResponse($pending['request_id'], $this->sessionId); - - if (null !== $response) { - $this->handleFiberYield($this->sessionFiber->resume($response), $this->sessionId); - - return true; - } - } - - // Still waiting on the client. Unlike the stdio transport there is no - // timeout to expire here: nothing runs concurrently, so a response that - // has not arrived by now is never going to. The connection settles and - // the client transport reports the stall instead. - return false; - } - - private function finishFiber(): void - { - $result = $this->sessionFiber?->getReturn(); - $this->sessionFiber = null; - - if (null === $result) { - return; - } - - try { - $this->connection->toClient(json_encode($result, \JSON_THROW_ON_ERROR)); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode the final Fiber result.', ['exception' => $e]); - } - } -} diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php index 4cd3552d..09487185 100644 --- a/tests/Integration/NotificationTest.php +++ b/tests/Integration/NotificationTest.php @@ -15,22 +15,22 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Notification\LoggingMessageNotification; -use Mcp\Server\Builder as ServerBuilder; -use Mcp\Server\RequestContext; use PHPUnit\Framework\Attributes\TestDox; /** * Notifications a server emits while a tool is still running. * - * These travel the same queue as a response but carry no id and expect no - * answer, so what they exercise is delivery ordering rather than correlation. + * These share the pipe with the response but carry no id, so what they exercise + * is delivery ordering rather than correlation. + * + * @see Fixture/notification.php for the server under test */ final class NotificationTest extends IntegrationTestCase { #[TestDox('progress notifications reach the callback passed to callTool()')] public function testProgressReachesTheCaller(): void { - $client = $this->connect($this->serverWithReportingTool()); + $client = $this->connect('notification'); $updates = []; $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { @@ -46,9 +46,8 @@ public function testProgressReachesTheCaller(): void public function testProgressIsSkippedWithoutAToken(): void { // Without an onProgress callback the request carries no progress token, - // and the gateway drops the notification rather than sending one the - // client could not correlate. - $client = $this->connect($this->serverWithReportingTool()); + // so the gateway drops the notification instead of sending it. + $client = $this->connect('notification'); $result = $client->callTool('work'); @@ -61,7 +60,7 @@ public function testLoggingReachesTheClient(): void { $logged = []; $client = $this->connect( - $this->serverWithReportingTool(), + 'notification', $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( static function (LoggingMessageNotification $notification) use (&$logged): void { $logged[] = [$notification->level, $notification->data]; @@ -73,21 +72,4 @@ static function (LoggingMessageNotification $notification) use (&$logged): void $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); } - - private function serverWithReportingTool(): ServerBuilder - { - return $this->serverBuilder()->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - $gateway->log(LoggingLevel::Info, 'starting work'); - $gateway->progress(0.5, 1.0, 'halfway'); - $gateway->progress(1.0, 1.0, 'done'); - - return 'finished'; - }, - name: 'work', - description: 'Reports progress and logs while working.', - ); - } } diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php index 68ab6a36..6dfd0650 100644 --- a/tests/Integration/RootsTest.php +++ b/tests/Integration/RootsTest.php @@ -19,19 +19,19 @@ use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\ListRootsResult; use Mcp\Schema\Root; -use Mcp\Server\Builder as ServerBuilder; -use Mcp\Server\RequestContext; use PHPUnit\Framework\Attributes\TestDox; /** * Roots: the server asking the client which workspace folders it may touch. + * + * @see Fixture/roots.php for the server under test */ final class RootsTest extends IntegrationTestCase { #[TestDox('the roots the client exposes reach the tool that asked')] public function testRootsReachTheTool(): void { - $client = $this->connect($this->serverWithRootsTool(), $this->clientExposing( + $client = $this->connect('roots', $this->clientExposing( new Root('file:///workspace/app', 'App'), new Root('file:///workspace/docs', 'Docs'), )); @@ -45,7 +45,7 @@ public function testRootsReachTheTool(): void #[TestDox('an empty root list is a valid answer, not a failure')] public function testEmptyRootList(): void { - $client = $this->connect($this->serverWithRootsTool(), $this->clientExposing()); + $client = $this->connect('roots', $this->clientExposing()); $result = $client->callTool('inspect_roots'); @@ -56,7 +56,7 @@ public function testEmptyRootList(): void #[TestDox('a client that does not advertise roots is not asked')] public function testCapabilityIsVisibleToTheServer(): void { - $client = $this->connect($this->serverWithRootsTool()); + $client = $this->connect('roots'); $result = $client->callTool('inspect_roots'); @@ -67,38 +67,16 @@ public function testCapabilityIsVisibleToTheServer(): void #[TestDox('the client can announce that its roots changed')] public function testRootsListChangedNotification(): void { - $client = $this->connect($this->serverWithRootsTool(), $this->clientExposing(new Root('file:///workspace'))); + $client = $this->connect('roots', $this->clientExposing(new Root('file:///workspace'))); - // A notification has no reply, so what this pins down is that sending one - // mid-session neither raises nor leaves the connection unusable. + // A notification has no reply, so what this pins down is that sending + // one mid-session leaves the connection usable. $client->sendRootsListChanged(); $this->assertTrue($client->isConnected()); $this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]); } - private function serverWithRootsTool(): ServerBuilder - { - return $this->serverBuilder()->addTool( - static function (RequestContext $context): string { - $gateway = $context->getClientGateway(); - - if (!$gateway->supportsRoots()) { - return 'unsupported'; - } - - $described = []; - foreach ($gateway->listRoots()->roots as $root) { - $described[] = \sprintf('%s (%s)', $root->uri, $root->name ?? '-'); - } - - return implode(', ', $described); - }, - name: 'inspect_roots', - description: 'Reports the workspace roots the client exposes.', - ); - } - private function clientExposing(Root ...$roots): ClientBuilder { $callback = new class(array_values($roots)) implements RootsCallbackInterface { diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index 3df61ed4..c581cf86 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -14,25 +14,24 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\SamplingCallbackInterface; use Mcp\Client\Handler\Request\SamplingRequestHandler; -use Mcp\Exception\ClientException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; -use Mcp\Server\Builder as ServerBuilder; -use Mcp\Server\RequestContext; use PHPUnit\Framework\Attributes\TestDox; /** * Sampling: the server borrowing the client's model mid-tool-call. + * + * @see Fixture/sampling.php for the server under test */ final class SamplingTest extends IntegrationTestCase { #[TestDox('the sampled completion reaches the tool that asked for it')] public function testSampledCompletionReachesTheTool(): void { - $client = $this->connect($this->serverWithSamplingTool(), $this->clientSampling()); + $client = $this->connect('sampling', $this->clientSampling()); $result = $client->callTool('summarize', ['text' => 'a long report']); @@ -43,9 +42,11 @@ public function testSampledCompletionReachesTheTool(): void #[TestDox('the prompt the tool passed arrives at the client')] public function testPromptReachesTheClient(): void { + // The client stays in this process, so what it was asked can be + // collected by reference even though the tool asking runs in another. /** @var \ArrayObject $seen */ $seen = new \ArrayObject(); - $client = $this->connect($this->serverWithSamplingTool(), $this->clientSampling($seen)); + $client = $this->connect('sampling', $this->clientSampling($seen)); $client->callTool('summarize', ['text' => 'inspect me']); @@ -58,10 +59,9 @@ public function testPromptReachesTheClient(): void #[TestDox('a client that cannot sample refuses instead of stalling the tool')] public function testClientWithoutSamplingRefuses(): void { - // The gateway has no supportsSampling() to consult, so the tool finds out - // by asking: the client answers "method not found" and that surfaces as a - // ClientException rather than a Fiber waiting on a response forever. - $client = $this->connect($this->serverWithSamplingTool()); + // The gateway has no supportsSampling() to consult, so the tool finds + // out by asking and the refusal surfaces as a ClientException. + $client = $this->connect('sampling'); $result = $client->callTool('summarize', ['text' => 'anything']); @@ -69,25 +69,6 @@ public function testClientWithoutSamplingRefuses(): void $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); } - private function serverWithSamplingTool(): ServerBuilder - { - return $this->serverBuilder()->addTool( - static function (RequestContext $context, string $text): string { - try { - $result = $context->getClientGateway()->sample($text, maxTokens: 64); - } catch (ClientException $e) { - return $e->getMessage(); - } - - \assert($result->content instanceof TextContent); - - return \sprintf('%s said: %s', $result->model, $result->content->text); - }, - name: 'summarize', - description: 'Summarizes text by asking the client to sample.', - ); - } - /** * @param \ArrayObject|null $seen collects what the server asked for */