[Client] Speak the 2026-07-28 lifecycle (#456)
* [Client] Speak the 2026-07-28 lifecycle On that revision the client opens with server/discover instead of initialize, stamps each request's _meta with the protocol version, its own capabilities and client info, and sends the standard Mcp-Method / Mcp-Name / Mcp-Param-* headers an intermediary routes on. ToolCatalog is what knows, from the tool list, which arguments a call has to mirror. An input_required result is answered rather than surfaced: InputRequestResolver asks the host's elicitation, sampling and roots handlers and retries the same request carrying inputResponses and the requestState the server sent. McpHeader holds the header names and the =?base64?…?= sentinel, so the two sides no longer keep their own copies of the same rules. * Fix CI baseline drift and address Copilot review findings Drop stale expected-failure entries the new client fixture now passes. Copilot review: fix McpHeader round-trip for a wrapper-shaped literal, encode empty/numeric-keyed inputResponses as a JSON object, stop the incident example repeating side effects on MRTR retry, and reset ToolCatalog on reconnect instead of carrying the previous server's verdicts. * Skip DualEraExampleTestCase on PHP 8.1 PHP_CLI_SERVER_WORKERS does not reliably fork multiple php -S workers there (php/php-src#9400), reproducing the single-worker deadlock the extra workers exist to avoid.
This commit is contained in:
parent
cbceef06eb
commit
8c06dcecc6
1 changed files with 2443 additions and 248 deletions
105
tests/Integration/DualEraElicitationTest.php
Normal file
105
tests/Integration/DualEraElicitationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?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\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* The elicitation example, driven from both eras against one endpoint.
|
||||
*
|
||||
* This is the example where the two lifecycles genuinely part ways: the
|
||||
* handshake era can interrupt a call to ask the user something, and the modern
|
||||
* era cannot. Its handlers fork in one place — a private `ask()` — and these
|
||||
* tests are what say the fork works both ways round.
|
||||
*/
|
||||
final class DualEraElicitationTest extends DualEraExampleTestCase
|
||||
{
|
||||
protected const ANSWERS = [
|
||||
'confirm' => true,
|
||||
'party_size' => 4,
|
||||
'date' => '2026-09-01',
|
||||
'dietary' => 'vegan',
|
||||
'rating' => '5',
|
||||
'comments' => 'Excellent',
|
||||
];
|
||||
|
||||
protected static function server(): string
|
||||
{
|
||||
return __DIR__.'/../../examples/server/elicitation/server.php';
|
||||
}
|
||||
|
||||
protected static function portBase(): int
|
||||
{
|
||||
return 9500;
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a confirmation is collected on $_dataName')]
|
||||
public function testConfirmAction(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']);
|
||||
|
||||
$this->assertStringContainsString('Action confirmed', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a multi-field form is collected on $_dataName')]
|
||||
public function testBookRestaurant(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']);
|
||||
|
||||
$this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('feedback with an optional field is collected on $_dataName')]
|
||||
public function testCollectFeedback(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']);
|
||||
|
||||
$this->assertStringContainsString('Thank you for your feedback', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')]
|
||||
public function testWithoutTheCapability(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
// Both eras refuse an ask the client cannot answer. The handshake era
|
||||
// finds out from the capability the client declared at initialize, the
|
||||
// modern era from the envelope on this very request — and the example
|
||||
// says the same thing either way.
|
||||
try {
|
||||
$answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything']));
|
||||
$this->assertStringContainsString('does not support elicitation', $answer);
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue