Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions .github/workflows/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ vendor
examples/**/dev.log
examples/**/cache
examples/**/sessions
results
tests/Conformance/results
tests/Conformance/sessions
tests/Conformance/logs/*.log
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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=';

/**
Expand Down Expand Up @@ -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, []);

Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/Conformance/FileLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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\Conformance;

use Psr\Log\AbstractLogger;

final class FileLogger extends AbstractLogger
{
public function __construct(
private readonly string $filePath,
private readonly bool $debug = false,
) {
}

public function log($level, mixed $message, array $context = []): void
{
if (!$this->debug && 'debug' === $level) {
return;
}

$logMessage = \sprintf("[%s] %s\n", strtoupper($level), $message);
file_put_contents($this->filePath, $logMessage, \FILE_APPEND);
}
}
25 changes: 25 additions & 0 deletions tests/Conformance/Fixtures/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
15 changes: 15 additions & 0 deletions tests/Conformance/Fixtures/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,57 @@
* 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;
use Mcp\Schema\Content\TextContent;
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')
->addPrompt([Elements::class, 'promptWithEmbeddedResource'], 'test_prompt_with_embedded_resource', 'A prompt that includes an embedded resource')
->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);