php-sdk/tests/Integration/StatelessClientTest.php
Christopher Hertel 7fe88677ad
[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
docs/stateless-lifecycle.md walks the revision end to end - per-request
_meta, server/discover, multi round-trip requests, caching and subscriptions
- and a runnable example on each side shows what that looks like in code.

CI matrixes both conformance roles over both revisions. --spec-version is
cumulative across the dated revisions, so 2025-11-25 covers the two before
it; 2026-07-28 is not cumulative with them and needs its own run against its
own baseline. The runner is pinned so a PR only goes red for reasons in the
PR, and conformance-weekly tracks the moving target instead - on both
revisions, since the draft scenarios ship only on the alpha dist-tag.

The Inspector cannot reach a modern-lifecycle server (it opens with
initialize), so the example is covered by integration tests instead: one
drives it with hand-built HTTP the way a conforming client would, one drives
it with this SDK's client, and one drives the single endpoint from both eras.
2026-08-19 03:24:17 +02:00

171 lines
5.9 KiB
PHP

<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Integration;
use Mcp\Client;
use Mcp\Client\Handler\Request\RequestHandlerInterface;
use Mcp\Client\Transport\HttpTransport;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\ElicitResult;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;
/**
* The SDK's client against the SDK's server, both on 2026-07-28.
*
* {@see StatelessLifecycleTest} drives the same example with hand-built HTTP,
* which proves the server answers a conforming client. This proves the other
* half: that the client *is* one — no handshake, an envelope on every request,
* headers the server's own validator accepts, and a multi round-trip call
* completed without the caller doing anything special.
*/
class StatelessClientTest extends TestCase
{
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
private Process $server;
private int $port;
protected function setUp(): void
{
$this->port = 8900 + (getmypid() % 300);
$this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]);
$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();
}
#[TestDox('connects without a handshake and learns who it is talking to')]
public function testConnectWithoutHandshake(): void
{
$client = $this->connect();
$this->assertTrue($client->isConnected());
$this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion());
$this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name);
$client->disconnect();
}
#[TestDox('lists and calls a tool, which the server accepts on the first try')]
public function testToolCall(): void
{
$client = $this->connect();
$tools = $client->listTools();
$this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools));
// A header the server disagreed with would come back as -32020, so a
// plain result is also the assertion that the mirroring was right.
$result = $client->callTool('get_weather', ['city' => 'Munich']);
$this->assertStringContainsString('Munich', self::text($result));
$client->disconnect();
}
#[TestDox('completes a multi round-trip call by answering the server itself')]
public function testMultiRoundTripIsTransparentToTheCaller(): void
{
$client = $this->connect(elicitation: true);
// One call from here; two on the wire. The server asks for a name, the
// client answers from its own handler and retries with the sealed
// `requestState`, and the caller only ever sees the finished result.
$result = $client->callTool('greet', []);
$this->assertStringContainsString('Hello, Ada!', self::text($result));
$client->disconnect();
}
#[TestDox('a capability the client never declared is refused up front, and costs only that call')]
public function testUndeclaredCapabilityIsRefused(): void
{
// No elicitation capability, and the envelope says so on every request,
// so the server refuses rather than asking for something that could
// never be answered.
$client = $this->connect();
try {
$client->callTool('greet', []);
$this->fail('Expected the server to refuse the undeclared capability.');
} catch (\Throwable $e) {
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
}
// The connection is stateless, so a failed call costs nothing.
$this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich'])));
$client->disconnect();
}
private static function text(CallToolResult $result): string
{
$first = $result->content[0] ?? null;
self::assertInstanceOf(TextContent::class, $first);
return $first->text;
}
private function connect(bool $elicitation = false): Client
{
$builder = Client::builder()
->setClientInfo('stateless-integration-client', '1.0.0')
->setProtocolVersion(ProtocolVersion::V2026_07_28)
->setRequestTimeout(10);
if ($elicitation) {
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
$builder->addRequestHandler(new class implements RequestHandlerInterface {
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, ['name' => 'Ada']));
}
});
}
$client = $builder->build();
$client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)));
return $client;
}
}