fix: use Docker PHP-FPM to support sampling in conformance tests (#219)
This commit is contained in:
parent
96ab01712b
commit
4c3e5e61c7
8 changed files with 144 additions and 40 deletions
48
.github/workflows/pipeline.yaml
vendored
48
.github/workflows/pipeline.yaml
vendored
|
|
@ -75,12 +75,6 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
coverage: "none"
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
|
|
@ -89,26 +83,52 @@ jobs:
|
||||||
- name: Install Composer
|
- name: Install Composer
|
||||||
uses: "ramsey/composer-install@v3"
|
uses: "ramsey/composer-install@v3"
|
||||||
|
|
||||||
- name: Run server
|
- name: Start conformance server
|
||||||
run: php -S localhost:8000 examples/server/conformance/server.php &
|
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: Wait for server to start
|
- name: Run conformance tests
|
||||||
run: sleep 5
|
working-directory: ./tests/Conformance
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: |
|
run: |
|
||||||
exit_code=0
|
exit_code=0
|
||||||
OUTPUT=$(npx @modelcontextprotocol/conformance server --url http://localhost:8000/) || exit_code=1
|
OUTPUT=$(npx @modelcontextprotocol/conformance server --url http://localhost:8000/) || exit_code=1
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
|
|
||||||
# Example: "Total: 3 passed, 16 failed"
|
|
||||||
passedTests=$(echo "$OUTPUT" | sed -nE 's/.*Total: ([0-9]+) passed.*/\1/p')
|
passedTests=$(echo "$OUTPUT" | sed -nE 's/.*Total: ([0-9]+) passed.*/\1/p')
|
||||||
passedTests=${passedTests:-0}
|
passedTests=${passedTests:-0}
|
||||||
|
|
||||||
REQUIRED_TESTS_TO_PASS=21
|
REQUIRED_TESTS_TO_PASS=22
|
||||||
echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS"
|
echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS"
|
||||||
[ "$passedTests" -ge "$REQUIRED_TESTS_TO_PASS" ] || exit $exit_code
|
[ "$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:
|
qa:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -6,4 +6,6 @@ vendor
|
||||||
examples/**/dev.log
|
examples/**/dev.log
|
||||||
examples/**/cache
|
examples/**/cache
|
||||||
examples/**/sessions
|
examples/**/sessions
|
||||||
results
|
tests/Conformance/results
|
||||||
|
tests/Conformance/sessions
|
||||||
|
tests/Conformance/logs/*.log
|
||||||
|
|
|
||||||
11
Makefile
11
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:
|
deps-stable:
|
||||||
composer update --prefer-stable
|
composer update --prefer-stable
|
||||||
|
|
@ -21,11 +21,12 @@ unit-tests:
|
||||||
inspector-tests:
|
inspector-tests:
|
||||||
vendor/bin/phpunit --testsuite=inspector
|
vendor/bin/phpunit --testsuite=inspector
|
||||||
|
|
||||||
conformance-server:
|
|
||||||
php -S localhost:8000 examples/server/conformance/server.php
|
|
||||||
|
|
||||||
conformance-tests:
|
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:
|
coverage:
|
||||||
XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage
|
XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage
|
||||||
|
|
|
||||||
131
tests/Conformance/Elements.php
Normal file
131
tests/Conformance/Elements.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?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 Mcp\Schema\Content\Content;
|
||||||
|
use Mcp\Schema\Content\EmbeddedResource;
|
||||||
|
use Mcp\Schema\Content\ImageContent;
|
||||||
|
use Mcp\Schema\Content\PromptMessage;
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Content\TextResourceContents;
|
||||||
|
use Mcp\Schema\Enum\Role;
|
||||||
|
use Mcp\Server\Protocol;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
|
|
||||||
|
final class Elements
|
||||||
|
{
|
||||||
|
public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||||
|
public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Content[]
|
||||||
|
*/
|
||||||
|
public function toolMultipleTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TextContent('Multiple content types test:'),
|
||||||
|
new ImageContent(self::TEST_IMAGE_BASE64, 'image/png'),
|
||||||
|
EmbeddedResource::fromText(
|
||||||
|
'test://mixed-content-resource',
|
||||||
|
'{ "test" = "data", "value" = 123 }',
|
||||||
|
'application/json',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toolWithLogging(RequestContext $context): string
|
||||||
|
{
|
||||||
|
$logger = $context->getClientLogger();
|
||||||
|
|
||||||
|
$logger->info('Tool execution started');
|
||||||
|
$logger->info('Tool processing data');
|
||||||
|
$logger->info('Tool execution completed');
|
||||||
|
|
||||||
|
return 'Tool with logging executed successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toolWithProgress(RequestContext $context): ?string
|
||||||
|
{
|
||||||
|
$client = $context->getClientGateway();
|
||||||
|
|
||||||
|
$client->progress(0, 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, []);
|
||||||
|
|
||||||
|
return $meta['progressToken'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $prompt The prompt to send to the LLM
|
||||||
|
*/
|
||||||
|
public function toolWithSampling(RequestContext $context, string $prompt): string
|
||||||
|
{
|
||||||
|
$result = $context->getClientGateway()->sample($prompt, 100);
|
||||||
|
|
||||||
|
return \sprintf(
|
||||||
|
'LLM response: %s',
|
||||||
|
$result->content instanceof TextContent ? trim((string) $result->content->text) : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resourceTemplate(string $id): TextResourceContents
|
||||||
|
{
|
||||||
|
return new TextResourceContents(
|
||||||
|
uri: 'test://template/{id}/data',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: json_encode([
|
||||||
|
'id' => $id,
|
||||||
|
'templateTest' => true,
|
||||||
|
'data' => \sprintf('Data for ID: %s', $id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $arg1 First test argument
|
||||||
|
* @param string $arg2 Second test argument
|
||||||
|
*
|
||||||
|
* @return PromptMessage[]
|
||||||
|
*/
|
||||||
|
public function promptWithArguments(string $arg1, string $arg2): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PromptMessage(Role::User, new TextContent(\sprintf('Prompt with arguments: arg1="%s", arg2="%s"', $arg1, $arg2))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $resourceUri URI of the resource to embed
|
||||||
|
*
|
||||||
|
* @return PromptMessage[]
|
||||||
|
*/
|
||||||
|
public function promptWithEmbeddedResource(string $resourceUri): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PromptMessage(Role::User, EmbeddedResource::fromText($resourceUri, 'Embedded resource content for testing.')),
|
||||||
|
new PromptMessage(Role::User, new TextContent('Please process the embedded resource above.')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PromptMessage[]
|
||||||
|
*/
|
||||||
|
public function promptWithImage(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PromptMessage(Role::User, new ImageContent(self::TEST_IMAGE_BASE64, 'image/png')),
|
||||||
|
new PromptMessage(Role::User, new TextContent('Please analyze the image above.')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/Conformance/FileLogger.php
Normal file
33
tests/Conformance/FileLogger.php
Normal file
|
|
@ -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
tests/Conformance/Fixtures/docker-compose.yml
Normal file
25
tests/Conformance/Fixtures/docker-compose.yml
Normal file
|
|
@ -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
tests/Conformance/Fixtures/nginx.conf
Normal file
15
tests/Conformance/Fixtures/nginx.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
tests/Conformance/server.php
Normal file
65
tests/Conformance/server.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?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 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 = 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)
|
||||||
|
// 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')
|
||||||
|
->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')
|
||||||
|
// 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 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();
|
||||||
|
|
||||||
|
$response = $server->run($transport);
|
||||||
|
|
||||||
|
(new SapiEmitter())->emit($response);
|
||||||
Loading…
Reference in a new issue