diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 1ceb3ed1..812217c2 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -75,12 +75,6 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - coverage: "none" - - name: Setup Node uses: actions/setup-node@v5 with: @@ -89,26 +83,52 @@ jobs: - name: Install Composer uses: "ramsey/composer-install@v3" - - name: Run server - run: php -S localhost:8000 examples/server/conformance/server.php & - - - name: Wait for server to start - run: sleep 5 + - name: Start conformance server + run: | + mkdir -p tests/Conformance/sessions tests/Conformance/logs + chmod -R 777 tests/Conformance/sessions tests/Conformance/logs + docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d + sleep 5 - - name: Tests + - name: Run conformance tests + working-directory: ./tests/Conformance run: | exit_code=0 OUTPUT=$(npx @modelcontextprotocol/conformance server --url http://localhost:8000/) || exit_code=1 echo "$OUTPUT" - # Example: "Total: 3 passed, 16 failed" passedTests=$(echo "$OUTPUT" | sed -nE 's/.*Total: ([0-9]+) passed.*/\1/p') passedTests=${passedTests:-0} - REQUIRED_TESTS_TO_PASS=21 + REQUIRED_TESTS_TO_PASS=22 echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS" [ "$passedTests" -ge "$REQUIRED_TESTS_TO_PASS" ] || exit $exit_code + - name: Show logs on failure + if: failure() + run: | + echo "=== Docker Compose Logs ===" + docker compose -f tests/Conformance/Fixtures/docker-compose.yml logs + echo "" + echo "=== Conformance Log ===" + cat tests/Conformance/logs/conformance.log 2>/dev/null || echo "No conformance log found" + echo "" + echo "=== Test Results (first failed test) ===" + 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" + echo "" + echo "=== Directory permissions ===" + ls -la tests/Conformance/ + ls -la tests/Conformance/logs/ 2>/dev/null || echo "logs dir issue" + ls -la tests/Conformance/sessions/ 2>/dev/null || echo "sessions dir issue" + + - name: Cleanup + if: always() + run: docker compose -f tests/Conformance/Fixtures/docker-compose.yml down + qa: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 3d4a2bcf..45c94f10 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ vendor examples/**/dev.log examples/**/cache examples/**/sessions -results +tests/Conformance/results +tests/Conformance/sessions +tests/Conformance/logs/*.log diff --git a/Makefile b/Makefile index 59d00e43..3f62692d 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 +.PHONY: deps-stable deps-low cs phpstan tests unit-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests deps-stable: composer update --prefer-stable @@ -21,11 +21,12 @@ unit-tests: inspector-tests: vendor/bin/phpunit --testsuite=inspector -conformance-server: - php -S localhost:8000 examples/server/conformance/server.php - conformance-tests: - npx @modelcontextprotocol/conformance server --url http://localhost:8000/ + 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 coverage: XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage diff --git a/examples/server/conformance/Elements.php b/tests/Conformance/Elements.php similarity index 90% rename from examples/server/conformance/Elements.php rename to tests/Conformance/Elements.php index 81d25b32..b70e9825 100644 --- a/examples/server/conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\Server\Conformance; +namespace Mcp\Tests\Conformance; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; @@ -23,9 +23,7 @@ final class Elements { - // Sample base64 encoded 1x1 red PNG pixel for testing public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; - // Sample base64 encoded minimal WAV file for testing public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; /** @@ -60,8 +58,8 @@ public function toolWithProgress(RequestContext $context): ?string $client = $context->getClientGateway(); $client->progress(0, 100, 'Completed step 0 of 100'); - $client->progress(50, 100, 'Completed step 0 of 100'); - $client->progress(100, 100, 'Completed step 0 of 100'); + $client->progress(50, 100, 'Completed step 50 of 100'); + $client->progress(100, 100, 'Completed step 100 of 100'); $meta = $context->getSession()->get(Protocol::SESSION_ACTIVE_REQUEST_META, []); @@ -75,7 +73,10 @@ public function toolWithSampling(RequestContext $context, string $prompt): strin { $result = $context->getClientGateway()->sample($prompt, 100); - return \sprintf('LLM response: %s', $result->content instanceof TextContent ? trim((string) $result->content->text) : ''); + return \sprintf( + 'LLM response: %s', + $result->content instanceof TextContent ? trim((string) $result->content->text) : '' + ); } public function resourceTemplate(string $id): TextResourceContents diff --git a/tests/Conformance/FileLogger.php b/tests/Conformance/FileLogger.php new file mode 100644 index 00000000..55bead3d --- /dev/null +++ b/tests/Conformance/FileLogger.php @@ -0,0 +1,33 @@ +debug && 'debug' === $level) { + return; + } + + $logMessage = \sprintf("[%s] %s\n", strtoupper($level), $message); + file_put_contents($this->filePath, $logMessage, \FILE_APPEND); + } +} diff --git a/tests/Conformance/Fixtures/docker-compose.yml b/tests/Conformance/Fixtures/docker-compose.yml new file mode 100644 index 00000000..62e2e8bd --- /dev/null +++ b/tests/Conformance/Fixtures/docker-compose.yml @@ -0,0 +1,25 @@ +services: + nginx: + image: nginx:1.26-alpine + ports: + - "8000:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../../..:/app:ro + depends_on: + - php-fpm + networks: + - mcp-net + + php-fpm: + image: php:8.4-fpm-alpine + volumes: + - ../../..:/app:ro + - ../sessions:/app/tests/Conformance/sessions + - ../logs:/app/tests/Conformance/logs + working_dir: /app + networks: + - mcp-net + +networks: + mcp-net: diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf new file mode 100644 index 00000000..9159c461 --- /dev/null +++ b/tests/Conformance/Fixtures/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name localhost; + root /app; + + location / { + try_files $uri /tests/Conformance/server.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_pass php-fpm:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} diff --git a/examples/server/conformance/server.php b/tests/Conformance/server.php similarity index 78% rename from examples/server/conformance/server.php rename to tests/Conformance/server.php index 2cdfb5ed..802fa05a 100644 --- a/examples/server/conformance/server.php +++ b/tests/Conformance/server.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__).'/bootstrap.php'; -chdir(__DIR__); +require_once dirname(__DIR__, 2).'/vendor/autoload.php'; -use Mcp\Example\Server\Conformance\Elements; +use Http\Discovery\Psr17Factory; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; @@ -20,30 +20,39 @@ use Mcp\Schema\Result\CallToolResult; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Tests\Conformance\Elements; +use Mcp\Tests\Conformance\FileLogger; + +chdir(__DIR__); -logger()->info('Starting MCP Custom Dependencies Server...'); +$logger = new FileLogger(__DIR__.'/logs/conformance.log', true); + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$transport = new StreamableHttpTransport($request, logger: $logger); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setLogger(logger()) + ->setLogger($logger) // Tools ->addTool(fn () => 'This is a simple text response for testing.', 'test_simple_text', 'Tests simple text content response') ->addTool(fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), 'test_image_content', 'Tests image content response') ->addTool(fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), 'test_audio_content', 'Tests audio content response') ->addTool(fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), 'test_embedded_resource', 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], 'test_multiple_content_types', 'Tests response with multiple content types (text, image, resource)') - ->addTool([Elements::class, 'toolWithLogging'], 'test_tool_with_logging', 'Tests tool that emits log messages during execution') + ->addTool([Elements::class, 'toolMultipleTypes'], 'test_multiple_content_types', 'Tests response with multiple content types') + ->addTool([Elements::class, 'toolWithLogging'], 'test_tool_with_logging', 'Tests tool that emits log messages') ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') + ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') ->addTool(fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') - // TODO: Sampling gets stuck - // ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling (LLM completion request)') // Resources ->addResource(fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') // TODO: Handler for resources/subscribe and resources/unsubscribe - ->addResource(fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that auto-updates every 3 seconds') + ->addResource(fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts ->addPrompt(fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments') ->addPrompt([Elements::class, 'promptWithArguments'], 'test_prompt_with_arguments', 'A prompt with required arguments') @@ -51,8 +60,6 @@ ->addPrompt([Elements::class, 'promptWithImage'], 'test_prompt_with_image', 'A prompt that includes image content') ->build(); -$result = $server->run(transport()); - -logger()->info('Server listener stopped gracefully.', ['result' => $result]); +$response = $server->run($transport); -shutdown($result); +(new SapiEmitter())->emit($response);