* [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. * [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. * [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.
107 lines
3.8 KiB
PHP
107 lines
3.8 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\Builder as ClientBuilder;
|
|
use Mcp\Client\Handler\Request\ElicitationCallbackInterface;
|
|
use Mcp\Client\Handler\Request\ElicitationRequestHandler;
|
|
use Mcp\Schema\ClientCapabilities;
|
|
use Mcp\Schema\Content\TextContent;
|
|
use Mcp\Schema\Enum\ElicitAction;
|
|
use Mcp\Schema\Request\ElicitRequest;
|
|
use Mcp\Schema\Result\ElicitResult;
|
|
use PHPUnit\Framework\Attributes\TestDox;
|
|
|
|
/**
|
|
* Elicitation, driven all the way around the loop.
|
|
*
|
|
* 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('elicitation', $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('elicitation', $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 consults supportsElicitation(), which answers from the
|
|
// capabilities this client sent during the handshake.
|
|
$client = $this->connect('elicitation');
|
|
|
|
$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 rather than leaving it waiting.
|
|
$client = $this->connect(
|
|
'elicitation',
|
|
$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 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));
|
|
}
|
|
}
|