[Client] add client conformance test suite (#279)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Hertel 2026-04-26 12:33:21 +02:00 • committed by GitHub
commit abae36e888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 189 additions and 9 deletions

View file

@ -72,7 +72,8 @@ jobs:
- name: Tests
run: vendor/bin/phpunit --testsuite=inspector
conformance:
conformance-server:
name: conformance / server
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -122,6 +123,47 @@ jobs:
if: always()
run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
conformance-client:
name: conformance / client
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: "none"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install Composer
uses: "ramsey/composer-install@v4"
- name: Create log directory
run: mkdir -p tests/Conformance/logs
- name: Run client conformance tests
working-directory: ./tests/Conformance
run: npx @modelcontextprotocol/conformance client --command "php ${{ github.workspace }}/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml
- name: Show logs on failure
if: failure()
run: |
echo "=== Client Conformance Log ==="
cat tests/Conformance/logs/client-conformance.log 2>/dev/null || echo "No client conformance log found"
echo ""
echo "=== Test Results ==="
find tests/Conformance/results -name "checks.json" 2>/dev/null | head -3 | while read f; do
echo "--- $f ---"
cat "$f"
echo ""
done || echo "No results found"
qa:
runs-on: ubuntu-latest
steps:

View file

@ -1,4 +1,4 @@
.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests docs
.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
deps-stable:
composer update --prefer-stable
@ -21,13 +21,18 @@ unit-tests:
inspector-tests:
vendor/bin/phpunit --testsuite=inspector
conformance-tests:
conformance-tests: conformance-server conformance-client
conformance-server:
docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
@echo "Waiting for server to start..."
@sleep 5
cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ || true
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
conformance-client:
cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml || true
coverage:
XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage

View file

@ -78,9 +78,9 @@ class ClientCapabilities implements \JsonSerializable
* sampling?: object,
* elicitation?: object,
* experimental?: object,
* }
* }|\stdClass
*/
public function jsonSerialize(): array
public function jsonSerialize(): array|object
{
$data = [];
if ($this->roots || $this->rootsListChanged) {
@ -102,6 +102,6 @@ class ClientCapabilities implements \JsonSerializable
$data['experimental'] = (object) $this->experimental;
}
return $data;
return $data ?: new \stdClass();
}
}

View file

@ -65,7 +65,7 @@ final class CallToolRequest extends Request
{
return [
'name' => $this->name,
'arguments' => $this->arguments,
'arguments' => $this->arguments ?: new \stdClass(),
];
}
}

View file

@ -0,0 +1,108 @@
<?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.
*/
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Mcp\Client;
use Mcp\Client\Handler\Request\RequestHandlerInterface;
use Mcp\Client\Transport\HttpTransport;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ElicitAction;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Tests\Conformance\FileLogger;
$url = $argv[1] ?? null;
$scenario = getenv('MCP_CONFORMANCE_SCENARIO') ?: null;
if (!$url || !$scenario) {
fwrite(\STDERR, "Usage: MCP_CONFORMANCE_SCENARIO=<scenario> php client.php <server-url>\n");
exit(1);
}
@mkdir(__DIR__.'/logs', 0777, true);
$logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true);
$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url));
$builder = Client::builder()
->setClientInfo('mcp-conformance-test-client', '1.0.0')
->setInitTimeout(30)
->setRequestTimeout(60)
->setLogger($logger);
if ('elicitation-sep1034-client-defaults' === $scenario) {
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
$builder->addRequestHandler(new class($logger) implements RequestHandlerInterface {
public function __construct(private readonly Psr\Log\LoggerInterface $logger)
{
}
public function supports(Request $request): bool
{
return $request instanceof ElicitRequest;
}
public function handle(Request $request): Response
{
$this->logger->info('Received elicitation request, accepting with empty content');
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
}
});
}
$client = $builder->build();
$transport = new HttpTransport($url, logger: $logger);
try {
$client->connect($transport);
$logger->info('Connected to server');
$toolsResult = $client->listTools();
$logger->info(sprintf('Listed %d tools', count($toolsResult->tools)));
switch ($scenario) {
case 'initialize':
break;
case 'tools_call':
$toolName = $toolsResult->tools[0]->name ?? 'test-tool';
$client->callTool($toolName, []);
$logger->info(sprintf('Called tool: %s', $toolName));
break;
case 'elicitation-sep1034-client-defaults':
$toolName = $toolsResult->tools[0]->name ?? 'test_client_elicitation_defaults';
$client->callTool($toolName, []);
$logger->info(sprintf('Called tool: %s', $toolName));
break;
default:
$logger->warning(sprintf('Unknown scenario: %s', $scenario));
break;
}
$client->disconnect();
$logger->info('Disconnected');
exit(0);
} catch (Throwable $e) {
$logger->error(sprintf('Error: %s', $e->getMessage()));
fwrite(\STDERR, sprintf("Error: %s\n%s\n", $e->getMessage(), $e->getTraceAsString()));
try {
$client->disconnect();
} catch (Throwable $ignored) {
}
exit(1);
}

View file

@ -1,3 +1,28 @@
server:
- dns-rebinding-protection
client:
- elicitation-sep1034-client-defaults
- sse-retry
- auth/metadata-default
- auth/metadata-var1
- auth/metadata-var2
- auth/metadata-var3
- auth/basic-cimd
- auth/scope-from-www-authenticate
- auth/scope-from-scopes-supported
- auth/scope-omitted-when-undefined
- auth/scope-step-up
- auth/scope-retry-limit
- auth/token-endpoint-auth-basic
- auth/token-endpoint-auth-post
- auth/token-endpoint-auth-none
- auth/pre-registration
- auth/2025-03-26-oauth-metadata-backcompat
- auth/2025-03-26-oauth-endpoint-fallback
- auth/offline-access-scope
- auth/offline-access-not-supported
- auth/client-credentials-jwt
- auth/client-credentials-basic
- auth/cross-app-access-complete-flow

View file

@ -41,10 +41,10 @@ class InitializeHandlerTest extends TestCase
$session = $this->createMock(SessionInterface::class);
$session->expects($this->exactly(2))
->method('set')
->willReturnCallback(function (string $key, array $value): void {
->willReturnCallback(function (string $key, mixed $value): void {
match ($key) {
'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value),
'client_capabilities' => $this->assertSame([], $value),
'client_capabilities' => $this->assertEquals(new \stdClass(), $value),
default => $this->fail("Unexpected session key: {$key}"),
};
});