From e2f8f6d96093d993054ae288a347968bf9f105f8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 11 Apr 2026 17:01:48 +0200 Subject: [PATCH 1/3] [Client] add client conformance test suite Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pipeline.yaml | 40 +++++++ Makefile | 5 +- src/Schema/ClientCapabilities.php | 6 +- src/Schema/Request/CallToolRequest.php | 2 +- tests/Conformance/client.php | 108 ++++++++++++++++++ tests/Conformance/conformance-baseline.yml | 25 ++++ .../Handler/Request/InitializeHandlerTest.php | 4 +- 7 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 tests/Conformance/client.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index b1f1e27f..56f153f4 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -122,6 +122,46 @@ jobs: if: always() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down + client-conformance: + 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: diff --git a/Makefile b/Makefile index 5358de25..92e16c95 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 docs +.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests client-conformance-tests docs deps-stable: composer update --prefer-stable @@ -28,6 +28,9 @@ conformance-tests: cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ || true docker compose -f tests/Conformance/Fixtures/docker-compose.yml down +client-conformance-tests: + 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 diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index f6f277a0..bfe4e6ed 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -78,9 +78,9 @@ public static function fromArray(array $data): self * 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 @@ public function jsonSerialize(): array $data['experimental'] = (object) $this->experimental; } - return $data; + return $data ?: new \stdClass(); } } diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 49bcd0d8..0674066c 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -65,7 +65,7 @@ protected function getParams(): array { return [ 'name' => $this->name, - 'arguments' => $this->arguments, + 'arguments' => $this->arguments ?: new \stdClass(), ]; } } diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php new file mode 100644 index 00000000..5c2ee2c7 --- /dev/null +++ b/tests/Conformance/client.php @@ -0,0 +1,108 @@ + php client.php \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); +} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index de676e85..61f9783f 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -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 + diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index bdd79d42..c56eac4d 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -41,10 +41,10 @@ public function testHandleUsesConfigurationProtocolVersion(): void $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}"), }; }); From 4003c3b660f525833eb8b29eefba72c449d5a77b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 11 Apr 2026 17:04:28 +0200 Subject: [PATCH 2/3] rename conformance CI jobs for consistent grouping Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pipeline.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 56f153f4..957d6f91 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -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,7 +123,8 @@ jobs: if: always() run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down - client-conformance: + conformance-client: + name: conformance / client runs-on: ubuntu-latest steps: - name: Checkout From 25ddfbad80ea2c57c7945957d92051e2ff414f88 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 11 Apr 2026 17:09:35 +0200 Subject: [PATCH 3/3] rename Makefile targets to conformance-server and conformance-client Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 92e16c95..49f0d9ec 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 client-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,14 +21,16 @@ 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 -client-conformance-tests: +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: