Registry Architecture Refactoring - Enhanced Separation of Concerns (#46)

* refactor: Separate Registry concerns following SOLID principles [WIP]

- Extract ReferenceProvider and ReferenceRegistryInterface interfaces
- Create DefaultToolExecutor with ReferenceHandlerInterface
- Remove execution responsibility from Registry class
- Enable custom handler and executor implementations

* refactor: implement SOLID principles with separated execution concerns

* Create DefaultResourceReader, DefaultPromptGetter
* Refactor JsonRpc Handler and RequestHandlers to use dedicated executors
* Update ServerBuilder to support custom executors via dependency injection

* chore: remove extra comments

* feat: introduce DispatchableRegistry for enhanced tool and resource management

* refactor: use proper interfaces

* refactor: implement HandlerInterface for improved abstraction and flexibility

* test: add unit tests for DispatchableRegistry and Registry classes

* refactor: cover with unit tests Resource reader, Prompt getter and Tool executor

* cs fix

* phpstan fix

* test: add unit tests for CallToolHandler, GetPromptHandler, PingHandler, and ReadResourceHandler

* refactor: revert Handler

* chore: remove style guide for tests

* refactor: remove "Default" prefix from classes

* refactor: remove DispatchableRegistry

* refactor: use package specific exception classes

* refactor: remove DispatchableRegistry

* refactor: add logger support to PromptGetter and ResourceReader classes

* Update src/Capability/Prompt/PromptGetter.php

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>

* refactor: Use FQN for Resource class to avoid cs-fixer misinterpreting it as `resource`

* refactor: rename ToolExecutor to ToolCaller and related classes for clarity and consistency

* refactor: add missed docblock

* refactor: rename ToolExecutor references to ToolCaller

* refactor: rename ToolCallerTest property and variable references

* cs fix

* ignore some phpstan errors

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Pavel Buchnev 2025-09-14 22:44:18 +04:00 • committed by GitHub
commit 99bf56eef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 4656 additions and 334 deletions

View file

@ -0,0 +1,638 @@
<?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\Capability\Prompt;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\PromptGetter;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
use Mcp\Exception\RegistryException;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Prompt;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class PromptGetterTest extends TestCase
{
private PromptGetter $promptGetter;
private ReferenceProviderInterface|MockObject $referenceProvider;
private ReferenceHandlerInterface|MockObject $referenceHandler;
protected function setUp(): void
{
$this->referenceProvider = $this->createMock(ReferenceProviderInterface::class);
$this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class);
$this->promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
);
}
public function testGetExecutesPromptSuccessfully(): void
{
$request = new GetPromptRequest('test_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('test_prompt');
$promptReference = new PromptReference($prompt, fn () => 'test result');
$handlerResult = [
'role' => 'user',
'content' => 'Generated prompt content',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('test_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertInstanceOf(PromptMessage::class, $result->messages[0]);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertInstanceOf(TextContent::class, $result->messages[0]->content);
$this->assertEquals('Generated prompt content', $result->messages[0]->content->text);
}
public function testGetWithEmptyArguments(): void
{
$request = new GetPromptRequest('empty_args_prompt', []);
$prompt = $this->createValidPrompt('empty_args_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Empty args content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('empty_args_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn([
'role' => 'user',
'content' => 'Empty args content',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetWithComplexArguments(): void
{
$arguments = [
'string_param' => 'value',
'int_param' => 42,
'bool_param' => true,
'array_param' => ['nested' => 'data'],
'null_param' => null,
];
$request = new GetPromptRequest('complex_prompt', $arguments);
$prompt = $this->createValidPrompt('complex_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Complex content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('complex_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, $arguments)
->willReturn([
'role' => 'assistant',
'content' => 'Complex response',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void
{
$request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('nonexistent_prompt')
->willReturn(null);
$this->referenceHandler
->expects($this->never())
->method('handle');
$this->expectException(PromptNotFoundException::class);
$this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".');
$this->promptGetter->get($request);
}
public function testGetThrowsRegistryExceptionWhenHandlerFails(): void
{
$request = new GetPromptRequest('failing_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('failing_prompt');
$promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed'));
$handlerException = RegistryException::internalError('Handler failed');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('failing_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willThrowException($handlerException);
$this->expectException(PromptGetException::class);
$this->promptGetter->get($request);
}
public function testGetHandlesJsonExceptionDuringFormatting(): void
{
$request = new GetPromptRequest('json_error_prompt', []);
$prompt = $this->createValidPrompt('json_error_prompt');
// Create a mock PromptReference that will throw JsonException during formatResult
$promptReference = $this->createMock(PromptReference::class);
$promptReference->expects($this->once())
->method('formatResult')
->willThrowException(new \JsonException('JSON encoding failed'));
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('json_error_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn('some result');
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('JSON encoding failed');
$this->promptGetter->get($request);
}
public function testGetHandlesArrayOfMessages(): void
{
$request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']);
$prompt = $this->createValidPrompt('multi_message_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Multiple messages');
$handlerResult = [
[
'role' => 'user',
'content' => 'First message',
],
[
'role' => 'assistant',
'content' => 'Second message',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('multi_message_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['context' => 'test'])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('First message', $result->messages[0]->content->text);
$this->assertEquals(Role::Assistant, $result->messages[1]->role);
$this->assertEquals('Second message', $result->messages[1]->content->text);
}
public function testGetHandlesPromptMessageObjects(): void
{
$request = new GetPromptRequest('prompt_message_prompt', []);
$prompt = $this->createValidPrompt('prompt_message_prompt');
$promptMessage = new PromptMessage(
Role::User,
new TextContent('Direct prompt message')
);
$promptReference = new PromptReference($prompt, fn () => $promptMessage);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('prompt_message_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($promptMessage);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertSame($promptMessage, $result->messages[0]);
}
public function testGetHandlesUserAssistantStructure(): void
{
$request = new GetPromptRequest('user_assistant_prompt', []);
$prompt = $this->createValidPrompt('user_assistant_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Conversation content');
$handlerResult = [
'user' => 'What is the weather?',
'assistant' => 'I can help you check the weather.',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('user_assistant_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('What is the weather?', $result->messages[0]->content->text);
$this->assertEquals(Role::Assistant, $result->messages[1]->role);
$this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text);
}
public function testGetHandlesEmptyArrayResult(): void
{
$request = new GetPromptRequest('empty_array_prompt', []);
$prompt = $this->createValidPrompt('empty_array_prompt');
$promptReference = new PromptReference($prompt, fn () => []);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('empty_array_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn([]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(0, $result->messages);
}
public function testGetWithTypedContentStructure(): void
{
$request = new GetPromptRequest('typed_content_prompt', []);
$prompt = $this->createValidPrompt('typed_content_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Typed content');
$handlerResult = [
'role' => 'user',
'content' => [
'type' => 'text',
'text' => 'Typed text content',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('typed_content_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('Typed text content', $result->messages[0]->content->text);
}
public function testGetWithPromptReferenceHavingCompletionProviders(): void
{
$request = new GetPromptRequest('completion_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('completion_prompt');
$completionProviders = ['param' => EnumCompletionProvider::class];
$promptReference = new PromptReference(
$prompt,
fn () => 'Completion content',
false,
$completionProviders
);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('completion_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willReturn([
'role' => 'user',
'content' => 'Completion content',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetHandlesMixedMessageArray(): void
{
$request = new GetPromptRequest('mixed_prompt', []);
$prompt = $this->createValidPrompt('mixed_prompt');
$promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message'));
$promptReference = new PromptReference($prompt, fn () => 'Mixed content');
$handlerResult = [
$promptMessage,
[
'role' => 'user',
'content' => 'Regular message',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('mixed_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertSame($promptMessage, $result->messages[0]);
$this->assertEquals(Role::User, $result->messages[1]->role);
$this->assertEquals('Regular message', $result->messages[1]->content->text);
}
public function testGetReflectsFormattedMessagesCorrectly(): void
{
$request = new GetPromptRequest('format_test_prompt', []);
$prompt = $this->createValidPrompt('format_test_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Format test');
// Test that the formatted result from PromptReference.formatResult is properly returned
$handlerResult = [
'role' => 'user',
'content' => 'Test formatting',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('format_test_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertEquals('Test formatting', $result->messages[0]->content->text);
$this->assertEquals(Role::User, $result->messages[0]->role);
}
/**
* Test that invalid handler results throw RuntimeException from PromptReference.formatResult().
*/
public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void
{
$request = new GetPromptRequest('invalid_prompt', []);
$prompt = $this->createValidPrompt('invalid_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Invalid content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('invalid_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn('This is not a valid prompt format');
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that null result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void
{
$request = new GetPromptRequest('null_prompt', []);
$prompt = $this->createValidPrompt('null_prompt');
$promptReference = new PromptReference($prompt, fn () => null);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('null_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(null);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that scalar result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void
{
$request = new GetPromptRequest('scalar_prompt', []);
$prompt = $this->createValidPrompt('scalar_prompt');
$promptReference = new PromptReference($prompt, fn () => 42);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('scalar_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(42);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that boolean result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void
{
$request = new GetPromptRequest('boolean_prompt', []);
$prompt = $this->createValidPrompt('boolean_prompt');
$promptReference = new PromptReference($prompt, fn () => true);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('boolean_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(true);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that object result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void
{
$request = new GetPromptRequest('object_prompt', []);
$prompt = $this->createValidPrompt('object_prompt');
$objectResult = new \stdClass();
$objectResult->property = 'value';
$promptReference = new PromptReference($prompt, fn () => $objectResult);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('object_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($objectResult);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
public function testConstructorWithDefaultLogger(): void
{
$promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
);
$this->assertInstanceOf(PromptGetter::class, $promptGetter);
}
public function testConstructorWithCustomLogger(): void
{
$logger = $this->createMock(LoggerInterface::class);
$promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
$logger,
);
$this->assertInstanceOf(PromptGetter::class, $promptGetter);
}
private function createValidPrompt(string $name): Prompt
{
return new Prompt(
name: $name,
description: "Test prompt: {$name}",
arguments: null,
);
}
}

View file

@ -0,0 +1,313 @@
<?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\Capability\Registry;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ResourceReference;
use Mcp\Capability\Registry\ResourceTemplateReference;
use Mcp\Capability\Registry\ToolReference;
use Mcp\Schema\Prompt;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;
use PHPUnit\Framework\TestCase;
class RegistryProviderTest extends TestCase
{
private Registry $registry;
protected function setUp(): void
{
$this->registry = new Registry();
}
public function testGetToolReturnsRegisteredTool(): void
{
$tool = $this->createValidTool('test_tool');
$handler = fn () => 'result';
$this->registry->registerTool($tool, $handler);
$toolRef = $this->registry->getTool('test_tool');
$this->assertInstanceOf(ToolReference::class, $toolRef);
$this->assertEquals($tool->name, $toolRef->tool->name);
$this->assertEquals($handler, $toolRef->handler);
$this->assertFalse($toolRef->isManual);
}
public function testGetToolReturnsNullForUnregisteredTool(): void
{
$toolRef = $this->registry->getTool('non_existent_tool');
$this->assertNull($toolRef);
}
public function testGetResourceReturnsRegisteredResource(): void
{
$resource = $this->createValidResource('test://resource');
$handler = fn () => 'content';
$this->registry->registerResource($resource, $handler);
$resourceRef = $this->registry->getResource('test://resource');
$this->assertInstanceOf(ResourceReference::class, $resourceRef);
$this->assertEquals($resource->uri, $resourceRef->schema->uri);
$this->assertEquals($handler, $resourceRef->handler);
$this->assertFalse($resourceRef->isManual);
}
public function testGetResourceReturnsNullForUnregisteredResource(): void
{
$resourceRef = $this->registry->getResource('test://non_existent');
$this->assertNull($resourceRef);
}
public function testGetResourceMatchesResourceTemplate(): void
{
$template = $this->createValidResourceTemplate('test://{id}');
$handler = fn (string $id) => "content for {$id}";
$this->registry->registerResourceTemplate($template, $handler);
$resourceRef = $this->registry->getResource('test://123');
$this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef);
$this->assertEquals($template->uriTemplate, $resourceRef->resourceTemplate->uriTemplate);
$this->assertEquals($handler, $resourceRef->handler);
}
public function testGetResourceWithIncludeTemplatesFalse(): void
{
$template = $this->createValidResourceTemplate('test://{id}');
$handler = fn (string $id) => "content for {$id}";
$this->registry->registerResourceTemplate($template, $handler);
$resourceRef = $this->registry->getResource('test://123', false);
$this->assertNull($resourceRef);
}
public function testGetResourcePrefersDirectResourceOverTemplate(): void
{
$resource = $this->createValidResource('test://123');
$resourceHandler = fn () => 'direct resource';
$template = $this->createValidResourceTemplate('test://{id}');
$templateHandler = fn (string $id) => "template for {$id}";
$this->registry->registerResource($resource, $resourceHandler);
$this->registry->registerResourceTemplate($template, $templateHandler);
$resourceRef = $this->registry->getResource('test://123');
$this->assertInstanceOf(ResourceReference::class, $resourceRef);
$this->assertEquals($resource->uri, $resourceRef->schema->uri);
}
public function testGetResourceTemplateReturnsRegisteredTemplate(): void
{
$template = $this->createValidResourceTemplate('test://{id}');
$handler = fn (string $id) => "content for {$id}";
$this->registry->registerResourceTemplate($template, $handler);
$templateRef = $this->registry->getResourceTemplate('test://{id}');
$this->assertInstanceOf(ResourceTemplateReference::class, $templateRef);
$this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate);
$this->assertEquals($handler, $templateRef->handler);
$this->assertFalse($templateRef->isManual);
}
public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void
{
$templateRef = $this->registry->getResourceTemplate('test://{non_existent}');
$this->assertNull($templateRef);
}
public function testGetPromptReturnsRegisteredPrompt(): void
{
$prompt = $this->createValidPrompt('test_prompt');
$handler = fn () => ['role' => 'user', 'content' => 'test message'];
$this->registry->registerPrompt($prompt, $handler);
$promptRef = $this->registry->getPrompt('test_prompt');
$this->assertInstanceOf(PromptReference::class, $promptRef);
$this->assertEquals($prompt->name, $promptRef->prompt->name);
$this->assertEquals($handler, $promptRef->handler);
$this->assertFalse($promptRef->isManual);
}
public function testGetPromptReturnsNullForUnregisteredPrompt(): void
{
$promptRef = $this->registry->getPrompt('non_existent_prompt');
$this->assertNull($promptRef);
}
public function testGetToolsReturnsAllRegisteredTools(): void
{
$tool1 = $this->createValidTool('tool1');
$tool2 = $this->createValidTool('tool2');
$this->registry->registerTool($tool1, fn () => 'result1');
$this->registry->registerTool($tool2, fn () => 'result2');
$tools = $this->registry->getTools();
$this->assertCount(2, $tools);
$this->assertArrayHasKey('tool1', $tools);
$this->assertArrayHasKey('tool2', $tools);
$this->assertInstanceOf(Tool::class, $tools['tool1']);
$this->assertInstanceOf(Tool::class, $tools['tool2']);
}
public function testGetResourcesReturnsAllRegisteredResources(): void
{
$resource1 = $this->createValidResource('test://resource1');
$resource2 = $this->createValidResource('test://resource2');
$this->registry->registerResource($resource1, fn () => 'content1');
$this->registry->registerResource($resource2, fn () => 'content2');
$resources = $this->registry->getResources();
$this->assertCount(2, $resources);
$this->assertArrayHasKey('test://resource1', $resources);
$this->assertArrayHasKey('test://resource2', $resources);
$this->assertInstanceOf(Resource::class, $resources['test://resource1']);
$this->assertInstanceOf(Resource::class, $resources['test://resource2']);
}
public function testGetPromptsReturnsAllRegisteredPrompts(): void
{
$prompt1 = $this->createValidPrompt('prompt1');
$prompt2 = $this->createValidPrompt('prompt2');
$this->registry->registerPrompt($prompt1, fn () => []);
$this->registry->registerPrompt($prompt2, fn () => []);
$prompts = $this->registry->getPrompts();
$this->assertCount(2, $prompts);
$this->assertArrayHasKey('prompt1', $prompts);
$this->assertArrayHasKey('prompt2', $prompts);
$this->assertInstanceOf(Prompt::class, $prompts['prompt1']);
$this->assertInstanceOf(Prompt::class, $prompts['prompt2']);
}
public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void
{
$template1 = $this->createValidResourceTemplate('test1://{id}');
$template2 = $this->createValidResourceTemplate('test2://{category}');
$this->registry->registerResourceTemplate($template1, fn () => 'content1');
$this->registry->registerResourceTemplate($template2, fn () => 'content2');
$templates = $this->registry->getResourceTemplates();
$this->assertCount(2, $templates);
$this->assertArrayHasKey('test1://{id}', $templates);
$this->assertArrayHasKey('test2://{category}', $templates);
$this->assertInstanceOf(ResourceTemplate::class, $templates['test1://{id}']);
$this->assertInstanceOf(ResourceTemplate::class, $templates['test2://{category}']);
}
public function testHasElementsReturnsFalseForEmptyRegistry(): void
{
$this->assertFalse($this->registry->hasElements());
}
public function testHasElementsReturnsTrueWhenToolIsRegistered(): void
{
$tool = $this->createValidTool('test_tool');
$this->registry->registerTool($tool, fn () => 'result');
$this->assertTrue($this->registry->hasElements());
}
public function testHasElementsReturnsTrueWhenResourceIsRegistered(): void
{
$resource = $this->createValidResource('test://resource');
$this->registry->registerResource($resource, fn () => 'content');
$this->assertTrue($this->registry->hasElements());
}
public function testHasElementsReturnsTrueWhenPromptIsRegistered(): void
{
$prompt = $this->createValidPrompt('test_prompt');
$this->registry->registerPrompt($prompt, fn () => []);
$this->assertTrue($this->registry->hasElements());
}
public function testHasElementsReturnsTrueWhenResourceTemplateIsRegistered(): void
{
$template = $this->createValidResourceTemplate('test://{id}');
$this->registry->registerResourceTemplate($template, fn () => 'content');
$this->assertTrue($this->registry->hasElements());
}
public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void
{
$specificTemplate = $this->createValidResourceTemplate('test://users/{userId}/profile');
$genericTemplate = $this->createValidResourceTemplate('test://users/{userId}');
$this->registry->registerResourceTemplate($genericTemplate, fn () => 'generic');
$this->registry->registerResourceTemplate($specificTemplate, fn () => 'specific');
// Should match the more specific template first
$resourceRef = $this->registry->getResource('test://users/123/profile');
$this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef);
$this->assertEquals('test://users/{userId}/profile', $resourceRef->resourceTemplate->uriTemplate);
}
private function createValidTool(string $name): Tool
{
return new Tool(
name: $name,
inputSchema: [
'type' => 'object',
'properties' => [
'param' => ['type' => 'string'],
],
'required' => null,
],
description: "Test tool: {$name}",
annotations: null
);
}
private function createValidResource(string $uri): Resource
{
return new Resource(
uri: $uri,
name: 'test_resource',
description: 'Test resource',
mimeType: 'text/plain'
);
}
private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate
{
return new ResourceTemplate(
uriTemplate: $uriTemplate,
name: 'test_template',
description: 'Test resource template',
mimeType: 'text/plain'
);
}
private function createValidPrompt(string $name): Prompt
{
return new Prompt(
name: $name,
description: "Test prompt: {$name}",
arguments: []
);
}
}

View file

@ -0,0 +1,354 @@
<?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\Capability\Registry;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Registry;
use Mcp\Schema\Prompt;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\Tool;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class RegistryTest extends TestCase
{
private Registry $registry;
private LoggerInterface|MockObject $logger;
protected function setUp(): void
{
$this->logger = $this->createMock(LoggerInterface::class);
$this->registry = new Registry(null, $this->logger);
}
public function testConstructorWithDefaults(): void
{
$registry = new Registry();
$capabilities = $registry->getCapabilities();
$this->assertInstanceOf(ServerCapabilities::class, $capabilities);
$this->assertFalse($capabilities->toolsListChanged);
$this->assertFalse($capabilities->resourcesListChanged);
$this->assertFalse($capabilities->promptsListChanged);
}
public function testGetCapabilitiesWhenEmpty(): void
{
$this->logger
->expects($this->once())
->method('info')
->with('No capabilities registered on server.');
$capabilities = $this->registry->getCapabilities();
$this->assertFalse($capabilities->tools);
$this->assertFalse($capabilities->resources);
$this->assertFalse($capabilities->prompts);
}
public function testGetCapabilitiesWhenPopulated(): void
{
$tool = $this->createValidTool('test_tool');
$resource = $this->createValidResource('test://resource');
$prompt = $this->createValidPrompt('test_prompt');
$template = $this->createValidResourceTemplate('test://{id}');
$this->registry->registerTool($tool, fn () => 'result');
$this->registry->registerResource($resource, fn () => 'content');
$this->registry->registerPrompt($prompt, fn () => []);
$this->registry->registerResourceTemplate($template, fn () => 'template');
$capabilities = $this->registry->getCapabilities();
$this->assertTrue($capabilities->tools);
$this->assertTrue($capabilities->resources);
$this->assertTrue($capabilities->prompts);
$this->assertTrue($capabilities->completions);
$this->assertFalse($capabilities->resourcesSubscribe);
$this->assertFalse($capabilities->logging);
}
public function testRegisterToolWithManualFlag(): void
{
$tool = $this->createValidTool('test_tool');
$handler = fn () => 'result';
$this->registry->registerTool($tool, $handler, true);
$toolRef = $this->registry->getTool('test_tool');
$this->assertTrue($toolRef->isManual);
}
public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void
{
$manualTool = $this->createValidTool('test_tool');
$discoveredTool = $this->createValidTool('test_tool');
$this->registry->registerTool($manualTool, fn () => 'manual', true);
$this->logger
->expects($this->once())
->method('debug')
->with("Ignoring discovered tool 'test_tool' as it conflicts with a manually registered one.");
$this->registry->registerTool($discoveredTool, fn () => 'discovered', false);
$toolRef = $this->registry->getTool('test_tool');
$this->assertTrue($toolRef->isManual);
}
public function testRegisterToolOverridesDiscoveredWithManual(): void
{
$discoveredTool = $this->createValidTool('test_tool');
$manualTool = $this->createValidTool('test_tool');
$this->registry->registerTool($discoveredTool, fn () => 'discovered', false);
$this->registry->registerTool($manualTool, fn () => 'manual', true);
$toolRef = $this->registry->getTool('test_tool');
$this->assertTrue($toolRef->isManual);
}
public function testRegisterResourceWithManualFlag(): void
{
$resource = $this->createValidResource('test://resource');
$handler = fn () => 'content';
$this->registry->registerResource($resource, $handler, true);
$resourceRef = $this->registry->getResource('test://resource');
$this->assertTrue($resourceRef->isManual);
}
public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void
{
$manualResource = $this->createValidResource('test://resource');
$discoveredResource = $this->createValidResource('test://resource');
$this->registry->registerResource($manualResource, fn () => 'manual', true);
$this->logger
->expects($this->once())
->method('debug')
->with("Ignoring discovered resource 'test://resource' as it conflicts with a manually registered one.");
$this->registry->registerResource($discoveredResource, fn () => 'discovered', false);
$resourceRef = $this->registry->getResource('test://resource');
$this->assertTrue($resourceRef->isManual);
}
public function testRegisterResourceTemplateWithCompletionProviders(): void
{
$template = $this->createValidResourceTemplate('test://{id}');
$completionProviders = ['id' => EnumCompletionProvider::class];
$this->registry->registerResourceTemplate($template, fn () => 'content', $completionProviders);
$templateRef = $this->registry->getResourceTemplate('test://{id}');
$this->assertEquals($completionProviders, $templateRef->completionProviders);
}
public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): void
{
$manualTemplate = $this->createValidResourceTemplate('test://{id}');
$discoveredTemplate = $this->createValidResourceTemplate('test://{id}');
$this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true);
$this->logger
->expects($this->once())
->method('debug')
->with("Ignoring discovered template 'test://{id}' as it conflicts with a manually registered one.");
$this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false);
$templateRef = $this->registry->getResourceTemplate('test://{id}');
$this->assertTrue($templateRef->isManual);
}
public function testRegisterPromptWithCompletionProviders(): void
{
$prompt = $this->createValidPrompt('test_prompt');
$completionProviders = ['param' => EnumCompletionProvider::class];
$this->registry->registerPrompt($prompt, fn () => [], $completionProviders);
$promptRef = $this->registry->getPrompt('test_prompt');
$this->assertEquals($completionProviders, $promptRef->completionProviders);
}
public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void
{
$manualPrompt = $this->createValidPrompt('test_prompt');
$discoveredPrompt = $this->createValidPrompt('test_prompt');
$this->registry->registerPrompt($manualPrompt, fn () => 'manual', [], true);
$this->logger
->expects($this->once())
->method('debug')
->with("Ignoring discovered prompt 'test_prompt' as it conflicts with a manually registered one.");
$this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false);
$promptRef = $this->registry->getPrompt('test_prompt');
$this->assertTrue($promptRef->isManual);
}
public function testClearRemovesOnlyDiscoveredElements(): void
{
$manualTool = $this->createValidTool('manual_tool');
$discoveredTool = $this->createValidTool('discovered_tool');
$manualResource = $this->createValidResource('test://manual');
$discoveredResource = $this->createValidResource('test://discovered');
$manualPrompt = $this->createValidPrompt('manual_prompt');
$discoveredPrompt = $this->createValidPrompt('discovered_prompt');
$manualTemplate = $this->createValidResourceTemplate('manual://{id}');
$discoveredTemplate = $this->createValidResourceTemplate('discovered://{id}');
$this->registry->registerTool($manualTool, fn () => 'manual', true);
$this->registry->registerTool($discoveredTool, fn () => 'discovered', false);
$this->registry->registerResource($manualResource, fn () => 'manual', true);
$this->registry->registerResource($discoveredResource, fn () => 'discovered', false);
$this->registry->registerPrompt($manualPrompt, fn () => [], [], true);
$this->registry->registerPrompt($discoveredPrompt, fn () => [], [], false);
$this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true);
$this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false);
$this->logger
->expects($this->once())
->method('debug')
->with('Removed 4 discovered elements from internal registry.');
$this->registry->clear();
$this->assertNotNull($this->registry->getTool('manual_tool'));
$this->assertNull($this->registry->getTool('discovered_tool'));
$this->assertNotNull($this->registry->getResource('test://manual'));
$this->assertNull(
$this->registry->getResource('test://discovered', false),
); // Don't include templates to avoid debug log
$this->assertNotNull($this->registry->getPrompt('manual_prompt'));
$this->assertNull($this->registry->getPrompt('discovered_prompt'));
$this->assertNotNull($this->registry->getResourceTemplate('manual://{id}'));
$this->assertNull($this->registry->getResourceTemplate('discovered://{id}'));
}
public function testClearLogsNothingWhenNoDiscoveredElements(): void
{
$manualTool = $this->createValidTool('manual_tool');
$this->registry->registerTool($manualTool, fn () => 'manual', true);
$this->logger
->expects($this->never())
->method('debug');
$this->registry->clear();
$this->assertNotNull($this->registry->getTool('manual_tool'));
}
public function testRegisterToolHandlesStringHandler(): void
{
$tool = $this->createValidTool('test_tool');
$handler = 'TestClass::testMethod';
$this->registry->registerTool($tool, $handler);
$toolRef = $this->registry->getTool('test_tool');
$this->assertEquals($handler, $toolRef->handler);
}
public function testRegisterToolHandlesArrayHandler(): void
{
$tool = $this->createValidTool('test_tool');
$handler = ['TestClass', 'testMethod'];
$this->registry->registerTool($tool, $handler);
$toolRef = $this->registry->getTool('test_tool');
$this->assertEquals($handler, $toolRef->handler);
}
public function testRegisterResourceHandlesCallableHandler(): void
{
$resource = $this->createValidResource('test://resource');
$handler = fn () => 'content';
$this->registry->registerResource($resource, $handler);
$resourceRef = $this->registry->getResource('test://resource');
$this->assertEquals($handler, $resourceRef->handler);
}
public function testMultipleRegistrationsOfSameElementWithSameType(): void
{
$tool1 = $this->createValidTool('test_tool');
$tool2 = $this->createValidTool('test_tool');
$this->registry->registerTool($tool1, fn () => 'first', false);
$this->registry->registerTool($tool2, fn () => 'second', false);
// Second registration should override the first
$toolRef = $this->registry->getTool('test_tool');
$this->assertEquals('second', ($toolRef->handler)());
}
private function createValidTool(string $name): Tool
{
return new Tool(
name: $name,
inputSchema: [
'type' => 'object',
'properties' => [
'param' => ['type' => 'string'],
],
'required' => null,
],
description: "Test tool: {$name}",
annotations: null,
);
}
private function createValidResource(string $uri): Resource
{
return new Resource(
uri: $uri,
name: 'test_resource',
description: 'Test resource',
mimeType: 'text/plain',
);
}
private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate
{
return new ResourceTemplate(
uriTemplate: $uriTemplate,
name: 'test_template',
description: 'Test resource template',
mimeType: 'text/plain',
);
}
private function createValidPrompt(string $name): Prompt
{
return new Prompt(
name: $name,
description: "Test prompt: {$name}",
arguments: [],
);
}
}

View file

@ -0,0 +1,522 @@
<?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\Capability\Resource;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ResourceReference;
use Mcp\Capability\Registry\ResourceTemplateReference;
use Mcp\Capability\Resource\ResourceReader;
use Mcp\Exception\RegistryException;
use Mcp\Exception\ResourceNotFoundException;
use Mcp\Exception\ResourceReadException;
use Mcp\Schema\Content\BlobResourceContents;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Result\ReadResourceResult;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ResourceReaderTest extends TestCase
{
private ResourceReader $resourceReader;
private ReferenceProviderInterface|MockObject $referenceProvider;
private ReferenceHandlerInterface|MockObject $referenceHandler;
protected function setUp(): void
{
$this->referenceProvider = $this->createMock(ReferenceProviderInterface::class);
$this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class);
$this->resourceReader = new ResourceReader(
$this->referenceProvider,
$this->referenceHandler,
);
}
public function testReadResourceSuccessfullyWithStringResult(): void
{
$request = new ReadResourceRequest('file://test.txt');
$resource = $this->createValidResource('file://test.txt', 'test', 'text/plain');
$resourceReference = new ResourceReference($resource, fn () => 'test content');
$handlerResult = 'test content';
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('file://test.txt')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'file://test.txt'])
->willReturn($handlerResult);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertEquals('test content', $result->contents[0]->text);
$this->assertEquals('file://test.txt', $result->contents[0]->uri);
$this->assertEquals('text/plain', $result->contents[0]->mimeType);
}
public function testReadResourceSuccessfullyWithArrayResult(): void
{
$request = new ReadResourceRequest('api://data');
$resource = $this->createValidResource('api://data', 'data', 'application/json');
$resourceReference = new ResourceReference($resource, fn () => ['key' => 'value', 'count' => 42]);
$handlerResult = ['key' => 'value', 'count' => 42];
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('api://data')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'api://data'])
->willReturn($handlerResult);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertJsonStringEqualsJsonString(
json_encode($handlerResult, \JSON_PRETTY_PRINT),
$result->contents[0]->text,
);
$this->assertEquals('api://data', $result->contents[0]->uri);
$this->assertEquals('application/json', $result->contents[0]->mimeType);
}
public function testReadResourceSuccessfullyWithBlobResult(): void
{
$request = new ReadResourceRequest('file://image.png');
$resource = $this->createValidResource('file://image.png', 'image', 'image/png');
$handlerResult = [
'blob' => base64_encode('binary data'),
'mimeType' => 'image/png',
];
$resourceReference = new ResourceReference($resource, fn () => $handlerResult);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('file://image.png')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'file://image.png'])
->willReturn($handlerResult);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(BlobResourceContents::class, $result->contents[0]);
$this->assertEquals(base64_encode('binary data'), $result->contents[0]->blob);
$this->assertEquals('file://image.png', $result->contents[0]->uri);
$this->assertEquals('image/png', $result->contents[0]->mimeType);
}
public function testReadResourceSuccessfullyWithResourceContentResult(): void
{
$request = new ReadResourceRequest('custom://resource');
$resource = $this->createValidResource('custom://resource', 'resource', 'text/plain');
$textContent = new TextResourceContents('custom://resource', 'text/plain', 'direct content');
$resourceReference = new ResourceReference($resource, fn () => $textContent);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('custom://resource')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'custom://resource'])
->willReturn($textContent);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertSame($textContent, $result->contents[0]);
}
public function testReadResourceSuccessfullyWithMultipleContentResults(): void
{
$request = new ReadResourceRequest('multi://content');
$resource = $this->createValidResource('multi://content', 'content', 'application/json');
$content1 = new TextResourceContents('multi://content', 'text/plain', 'first content');
$content2 = new TextResourceContents('multi://content', 'text/plain', 'second content');
$handlerResult = [$content1, $content2];
$resourceReference = new ResourceReference($resource, fn () => $handlerResult);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('multi://content')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'multi://content'])
->willReturn($handlerResult);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(2, $result->contents);
$this->assertSame($content1, $result->contents[0]);
$this->assertSame($content2, $result->contents[1]);
}
public function testReadResourceTemplate(): void
{
$request = new ReadResourceRequest('users://123');
$resourceTemplate = $this->createValidResourceTemplate('users://{id}', 'user_template');
$templateReference = new ResourceTemplateReference(
$resourceTemplate,
fn () => ['id' => 123, 'name' => 'Test User'],
);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('users://123')
->willReturn($templateReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($templateReference, ['uri' => 'users://123'])
->willReturn(['id' => 123, 'name' => 'Test User']);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertJsonStringEqualsJsonString(
json_encode(['id' => 123, 'name' => 'Test User'], \JSON_PRETTY_PRINT),
$result->contents[0]->text,
);
}
public function testReadResourceThrowsExceptionWhenResourceNotFound(): void
{
$request = new ReadResourceRequest('nonexistent://resource');
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('nonexistent://resource')
->willReturn(null);
$this->referenceHandler
->expects($this->never())
->method('handle');
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".');
$this->resourceReader->read($request);
}
public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void
{
$request = new ReadResourceRequest('failing://resource');
$resource = $this->createValidResource('failing://resource', 'failing', 'text/plain');
$resourceReference = new ResourceReference($resource, fn () => throw new \RuntimeException('Handler failed'));
$handlerException = new RegistryException('Handler execution failed');
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('failing://resource')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'failing://resource'])
->willThrowException($handlerException);
$this->expectException(ResourceReadException::class);
$this->expectExceptionMessage('Handler execution failed');
$this->resourceReader->read($request);
}
public function testReadResourcePassesCorrectArgumentsToHandler(): void
{
$request = new ReadResourceRequest('test://resource');
$resource = $this->createValidResource('test://resource', 'test', 'text/plain');
$resourceReference = new ResourceReference($resource, fn () => 'test');
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('test://resource')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with(
$this->identicalTo($resourceReference),
$this->equalTo(['uri' => 'test://resource']),
)
->willReturn('test');
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
}
public function testReadResourceWithEmptyStringResult(): void
{
$request = new ReadResourceRequest('empty://resource');
$resource = $this->createValidResource('empty://resource', 'empty', 'text/plain');
$resourceReference = new ResourceReference($resource, fn () => '');
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('empty://resource')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'empty://resource'])
->willReturn('');
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertEquals('', $result->contents[0]->text);
}
public function testReadResourceWithEmptyArrayResult(): void
{
$request = new ReadResourceRequest('empty://array');
$resource = $this->createValidResource('empty://array', 'array', 'application/json');
$resourceReference = new ResourceReference($resource, fn () => []);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('empty://array')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'empty://array'])
->willReturn([]);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertEquals('[]', $result->contents[0]->text);
$this->assertEquals('application/json', $result->contents[0]->mimeType);
}
public function testReadResourceWithNullResult(): void
{
$request = new ReadResourceRequest('null://resource');
$resource = $this->createValidResource('null://resource', 'null', 'text/plain');
$resourceReference = new ResourceReference($resource, fn () => null);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('null://resource')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'null://resource'])
->willReturn(null);
// The formatResult method in ResourceReference should handle null values
$this->expectException(\RuntimeException::class);
$this->resourceReader->read($request);
}
public function testReadResourceWithDifferentMimeTypes(): void
{
$request = new ReadResourceRequest('xml://data');
$resource = $this->createValidResource('xml://data', 'data', 'application/xml');
$resourceReference = new ResourceReference($resource, fn () => '<xml><data>value</data></xml>');
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('xml://data')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'xml://data'])
->willReturn('<xml><data>value</data></xml>');
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
// The MIME type should be guessed from content since formatResult handles the conversion
$this->assertEquals('<xml><data>value</data></xml>', $result->contents[0]->text);
}
public function testReadResourceWithJsonMimeTypeAndArrayResult(): void
{
$request = new ReadResourceRequest('api://json');
$resource = $this->createValidResource('api://json', 'json', 'application/json');
$resourceReference = new ResourceReference($resource, fn () => ['formatted' => true, 'data' => [1, 2, 3]]);
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('api://json')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'api://json'])
->willReturn(['formatted' => true, 'data' => [1, 2, 3]]);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertCount(1, $result->contents);
$this->assertInstanceOf(TextResourceContents::class, $result->contents[0]);
$this->assertEquals('application/json', $result->contents[0]->mimeType);
$this->assertJsonStringEqualsJsonString(
json_encode(['formatted' => true, 'data' => [1, 2, 3]], \JSON_PRETTY_PRINT),
$result->contents[0]->text,
);
}
public function testReadResourceCallsFormatResultOnReference(): void
{
$request = new ReadResourceRequest('format://test');
$resource = $this->createValidResource('format://test', 'format', 'text/plain');
// Create a mock ResourceReference to verify formatResult is called
$resourceReference = $this
->getMockBuilder(ResourceReference::class)
->setConstructorArgs([$resource, fn () => 'test', false])
->onlyMethods(['formatResult'])
->getMock();
$handlerResult = 'test result';
$formattedResult = [new TextResourceContents('format://test', 'text/plain', 'formatted content')];
$this->referenceProvider
->expects($this->once())
->method('getResource')
->with('format://test')
->willReturn($resourceReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($resourceReference, ['uri' => 'format://test'])
->willReturn($handlerResult);
$resourceReference
->expects($this->once())
->method('formatResult')
->with($handlerResult, 'format://test')
->willReturn($formattedResult);
$result = $this->resourceReader->read($request);
$this->assertInstanceOf(ReadResourceResult::class, $result);
$this->assertSame($formattedResult, $result->contents);
}
public function testConstructorWithDefaultLogger(): void
{
$resourceReader = new ResourceReader(
$this->referenceProvider,
$this->referenceHandler,
);
$this->assertInstanceOf(ResourceReader::class, $resourceReader);
}
public function testConstructorWithCustomLogger(): void
{
$logger = $this->createMock(LoggerInterface::class);
$resourceReader = new ResourceReader(
$this->referenceProvider,
$this->referenceHandler,
$logger,
);
$this->assertInstanceOf(ResourceReader::class, $resourceReader);
}
private function createValidResource(string $uri, string $name, ?string $mimeType = null): Resource
{
return new Resource(
uri: $uri,
name: $name,
description: "Test resource: {$name}",
mimeType: $mimeType,
size: null,
annotations: null,
);
}
private function createValidResourceTemplate(
string $uriTemplate,
string $name,
?string $mimeType = null,
): ResourceTemplate {
return new ResourceTemplate(
uriTemplate: $uriTemplate,
name: $name,
description: "Test resource template: {$name}",
mimeType: $mimeType,
annotations: null,
);
}
}

View file

@ -0,0 +1,628 @@
<?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\Capability\Tool;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ToolReference;
use Mcp\Capability\Tool\ToolCaller;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Tool;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ToolCallerTest extends TestCase
{
private ToolCaller $toolCaller;
private ReferenceProviderInterface|MockObject $referenceProvider;
private ReferenceHandlerInterface|MockObject $referenceHandler;
private LoggerInterface|MockObject $logger;
protected function setUp(): void
{
$this->referenceProvider = $this->createMock(ReferenceProviderInterface::class);
$this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->toolCaller = new ToolCaller(
$this->referenceProvider,
$this->referenceHandler,
$this->logger,
);
}
public function testCallExecutesToolSuccessfully(): void
{
$request = new CallToolRequest('test_tool', ['param' => 'value']);
$tool = $this->createValidTool('test_tool');
$toolReference = new ToolReference($tool, fn () => 'test result');
$handlerResult = 'test result';
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('test_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, ['param' => 'value'])
->willReturn($handlerResult);
$this->logger
->expects($this->exactly(2))
->method('debug')
->with(
$this->logicalOr(
$this->equalTo('Executing tool'),
$this->equalTo('Tool executed successfully')
),
$this->logicalOr(
$this->equalTo(['name' => 'test_tool', 'arguments' => ['param' => 'value']]),
$this->equalTo(['name' => 'test_tool', 'result_type' => 'string'])
)
);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertEquals('test result', $result->content[0]->text);
$this->assertFalse($result->isError);
}
public function testCallWithEmptyArguments(): void
{
$request = new CallToolRequest('test_tool', []);
$tool = $this->createValidTool('test_tool');
$toolReference = new ToolReference($tool, fn () => 'result');
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('test_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn('result');
$this->logger
->expects($this->exactly(2))
->method('debug');
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
}
public function testCallWithComplexArguments(): void
{
$arguments = [
'string_param' => 'value',
'int_param' => 42,
'bool_param' => true,
'array_param' => ['nested' => 'data'],
'null_param' => null,
];
$request = new CallToolRequest('complex_tool', $arguments);
$tool = $this->createValidTool('complex_tool');
$toolReference = new ToolReference($tool, fn () => ['processed' => true]);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('complex_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, $arguments)
->willReturn(['processed' => true]);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
}
public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void
{
$request = new CallToolRequest('nonexistent_tool', ['param' => 'value']);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('nonexistent_tool')
->willReturn(null);
$this->referenceHandler
->expects($this->never())
->method('handle');
$this->logger
->expects($this->once())
->method('debug')
->with('Executing tool', ['name' => 'nonexistent_tool', 'arguments' => ['param' => 'value']]);
$this->logger
->expects($this->once())
->method('warning')
->with('Tool not found', ['name' => 'nonexistent_tool']);
$this->expectException(ToolNotFoundException::class);
$this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".');
$this->toolCaller->call($request);
}
public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void
{
$request = new CallToolRequest('failing_tool', ['param' => 'value']);
$tool = $this->createValidTool('failing_tool');
$toolReference = new ToolReference($tool, fn () => throw new \RuntimeException('Handler failed'));
$handlerException = new \RuntimeException('Handler failed');
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('failing_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, ['param' => 'value'])
->willThrowException($handlerException);
$this->logger
->expects($this->once())
->method('debug')
->with('Executing tool', ['name' => 'failing_tool', 'arguments' => ['param' => 'value']]);
$this->logger
->expects($this->once())
->method('error')
->with(
'Tool execution failed',
$this->callback(function ($context) {
return 'failing_tool' === $context['name']
&& 'Handler failed' === $context['exception']
&& isset($context['trace']);
})
);
$this->expectException(ToolCallException::class);
$this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".');
$thrownException = null;
try {
$this->toolCaller->call($request);
} catch (ToolCallException $e) {
$thrownException = $e;
throw $e;
} finally {
if ($thrownException) {
$this->assertSame($request, $thrownException->request);
$this->assertSame($handlerException, $thrownException->getPrevious());
}
}
}
public function testCallHandlesNullResult(): void
{
$request = new CallToolRequest('null_tool', []);
$tool = $this->createValidTool('null_tool');
$toolReference = new ToolReference($tool, fn () => null);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('null_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn(null);
$this->logger
->expects($this->exactly(2))
->method('debug');
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertEquals('(null)', $result->content[0]->text);
}
public function testCallHandlesBooleanResults(): void
{
$request = new CallToolRequest('bool_tool', []);
$tool = $this->createValidTool('bool_tool');
$toolReference = new ToolReference($tool, fn () => true);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('bool_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn(true);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertEquals('true', $result->content[0]->text);
}
public function testCallHandlesArrayResults(): void
{
$request = new CallToolRequest('array_tool', []);
$tool = $this->createValidTool('array_tool');
$toolReference = new ToolReference($tool, fn () => ['key' => 'value', 'number' => 42]);
$arrayResult = ['key' => 'value', 'number' => 42];
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('array_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn($arrayResult);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertJsonStringEqualsJsonString(
json_encode($arrayResult, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE),
$result->content[0]->text
);
}
public function testCallHandlesContentObjectResults(): void
{
$request = new CallToolRequest('content_tool', []);
$tool = $this->createValidTool('content_tool');
$toolReference = new ToolReference($tool, fn () => new TextContent('Direct content'));
$contentResult = new TextContent('Direct content');
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('content_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn($contentResult);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertSame($contentResult, $result->content[0]);
}
public function testCallHandlesArrayOfContentResults(): void
{
$request = new CallToolRequest('content_array_tool', []);
$tool = $this->createValidTool('content_array_tool');
$toolReference = new ToolReference($tool, fn () => [
new TextContent('First content'),
new TextContent('Second content'),
]);
$contentArray = [
new TextContent('First content'),
new TextContent('Second content'),
];
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('content_array_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn($contentArray);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(2, $result->content);
$this->assertSame($contentArray[0], $result->content[0]);
$this->assertSame($contentArray[1], $result->content[1]);
}
public function testCallWithDifferentExceptionTypes(): void
{
$request = new CallToolRequest('error_tool', []);
$tool = $this->createValidTool('error_tool');
$toolReference = new ToolReference($tool, fn () => throw new \InvalidArgumentException('Invalid input'));
$handlerException = new \InvalidArgumentException('Invalid input');
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('error_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willThrowException($handlerException);
$this->logger
->expects($this->once())
->method('error')
->with(
'Tool execution failed',
$this->callback(function ($context) {
return 'error_tool' === $context['name']
&& 'Invalid input' === $context['exception']
&& isset($context['trace']);
})
);
$this->expectException(ToolCallException::class);
$this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".');
$this->toolCaller->call($request);
}
public function testCallLogsResultTypeCorrectlyForString(): void
{
$request = new CallToolRequest('string_tool', []);
$tool = $this->createValidTool('string_tool');
$toolReference = new ToolReference($tool, fn () => 'string result');
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('string_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn('string result');
$this->logger
->expects($this->exactly(2))
->method('debug');
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
}
public function testCallLogsResultTypeCorrectlyForInteger(): void
{
$request = new CallToolRequest('int_tool', []);
$tool = $this->createValidTool('int_tool');
$toolReference = new ToolReference($tool, fn () => 42);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('int_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn(42);
$this->logger
->expects($this->exactly(2))
->method('debug');
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
}
public function testCallLogsResultTypeCorrectlyForArray(): void
{
$request = new CallToolRequest('array_tool', []);
$tool = $this->createValidTool('array_tool');
$toolReference = new ToolReference($tool, fn () => ['test']);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('array_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn(['test']);
$this->logger
->expects($this->exactly(2))
->method('debug');
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
}
public function testConstructorWithDefaultLogger(): void
{
$executor = new ToolCaller($this->referenceProvider, $this->referenceHandler);
// Verify it's constructed without throwing exceptions
$this->assertInstanceOf(ToolCaller::class, $executor);
}
public function testCallHandlesEmptyArrayResult(): void
{
$request = new CallToolRequest('empty_array_tool', []);
$tool = $this->createValidTool('empty_array_tool');
$toolReference = new ToolReference($tool, fn () => []);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('empty_array_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn([]);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertEquals('[]', $result->content[0]->text);
}
public function testCallHandlesMixedContentAndNonContentArray(): void
{
$request = new CallToolRequest('mixed_tool', []);
$tool = $this->createValidTool('mixed_tool');
$mixedResult = [
new TextContent('First content'),
'plain string',
42,
new TextContent('Second content'),
];
$toolReference = new ToolReference($tool, fn () => $mixedResult);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('mixed_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn($mixedResult);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
// The ToolReference.formatResult should handle this mixed array
$this->assertGreaterThan(1, \count($result->content));
}
public function testCallHandlesStdClassResult(): void
{
$request = new CallToolRequest('object_tool', []);
$tool = $this->createValidTool('object_tool');
$objectResult = new \stdClass();
$objectResult->property = 'value';
$toolReference = new ToolReference($tool, fn () => $objectResult);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('object_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn($objectResult);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertStringContainsString('"property": "value"', $result->content[0]->text);
}
public function testCallHandlesBooleanFalseResult(): void
{
$request = new CallToolRequest('false_tool', []);
$tool = $this->createValidTool('false_tool');
$toolReference = new ToolReference($tool, fn () => false);
$this->referenceProvider
->expects($this->once())
->method('getTool')
->with('false_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($toolReference, [])
->willReturn(false);
$result = $this->toolCaller->call($request);
$this->assertInstanceOf(CallToolResult::class, $result);
$this->assertCount(1, $result->content);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertEquals('false', $result->content[0]->text);
}
private function createValidTool(string $name): Tool
{
return new Tool(
name: $name,
inputSchema: [
'type' => 'object',
'properties' => [
'param' => ['type' => 'string'],
],
'required' => null,
],
description: "Test tool: {$name}",
annotations: null,
);
}
}

View file

@ -0,0 +1,406 @@
<?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\Schema;
use Mcp\Schema\ServerCapabilities;
use PHPUnit\Framework\TestCase;
class ServerCapabilitiesTest extends TestCase
{
public function testConstructorWithDefaults(): void
{
$capabilities = new ServerCapabilities();
$this->assertTrue($capabilities->tools);
$this->assertFalse($capabilities->toolsListChanged);
$this->assertTrue($capabilities->resources);
$this->assertFalse($capabilities->resourcesSubscribe);
$this->assertFalse($capabilities->resourcesListChanged);
$this->assertTrue($capabilities->prompts);
$this->assertFalse($capabilities->promptsListChanged);
$this->assertFalse($capabilities->logging);
$this->assertFalse($capabilities->completions);
$this->assertNull($capabilities->experimental);
}
public function testConstructorWithAllParameters(): void
{
$experimental = ['feature1' => true, 'feature2' => 'enabled'];
$capabilities = new ServerCapabilities(
tools: false,
toolsListChanged: true,
resources: false,
resourcesSubscribe: true,
resourcesListChanged: true,
prompts: false,
promptsListChanged: true,
logging: true,
completions: true,
experimental: $experimental
);
$this->assertFalse($capabilities->tools);
$this->assertTrue($capabilities->toolsListChanged);
$this->assertFalse($capabilities->resources);
$this->assertTrue($capabilities->resourcesSubscribe);
$this->assertTrue($capabilities->resourcesListChanged);
$this->assertFalse($capabilities->prompts);
$this->assertTrue($capabilities->promptsListChanged);
$this->assertTrue($capabilities->logging);
$this->assertTrue($capabilities->completions);
$this->assertEquals($experimental, $capabilities->experimental);
}
public function testConstructorWithNullValues(): void
{
$capabilities = new ServerCapabilities(
tools: null,
toolsListChanged: null,
resources: null,
resourcesSubscribe: null,
resourcesListChanged: null,
prompts: null,
promptsListChanged: null,
logging: null,
completions: null,
experimental: null
);
$this->assertNull($capabilities->tools);
$this->assertNull($capabilities->toolsListChanged);
$this->assertNull($capabilities->resources);
$this->assertNull($capabilities->resourcesSubscribe);
$this->assertNull($capabilities->resourcesListChanged);
$this->assertNull($capabilities->prompts);
$this->assertNull($capabilities->promptsListChanged);
$this->assertNull($capabilities->logging);
$this->assertNull($capabilities->completions);
$this->assertNull($capabilities->experimental);
}
public function testFromArrayWithEmptyArray(): void
{
$capabilities = ServerCapabilities::fromArray([]);
$this->assertFalse($capabilities->logging);
$this->assertFalse($capabilities->completions);
$this->assertFalse($capabilities->tools);
$this->assertFalse($capabilities->prompts);
$this->assertFalse($capabilities->resources);
$this->assertNull($capabilities->toolsListChanged);
$this->assertNull($capabilities->promptsListChanged);
$this->assertNull($capabilities->resourcesSubscribe);
$this->assertNull($capabilities->resourcesListChanged);
$this->assertNull($capabilities->experimental);
}
public function testFromArrayWithBasicCapabilities(): void
{
$data = [
'tools' => new \stdClass(),
'resources' => new \stdClass(),
'prompts' => new \stdClass(),
'logging' => new \stdClass(),
'completions' => new \stdClass(),
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->tools);
$this->assertTrue($capabilities->resources);
$this->assertTrue($capabilities->prompts);
$this->assertTrue($capabilities->logging);
$this->assertTrue($capabilities->completions);
$this->assertNull($capabilities->toolsListChanged);
$this->assertNull($capabilities->promptsListChanged);
$this->assertNull($capabilities->resourcesSubscribe);
$this->assertNull($capabilities->resourcesListChanged);
}
public function testFromArrayWithPromptsArrayListChanged(): void
{
$data = [
'prompts' => ['listChanged' => true],
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->prompts);
$this->assertTrue($capabilities->promptsListChanged);
}
public function testFromArrayWithPromptsObjectListChanged(): void
{
$prompts = new \stdClass();
$prompts->listChanged = true;
$data = [
'prompts' => $prompts,
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->prompts);
$this->assertTrue($capabilities->promptsListChanged);
}
public function testFromArrayWithResourcesArraySubscribeAndListChanged(): void
{
$data = [
'resources' => [
'subscribe' => true,
'listChanged' => false,
],
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->resources);
$this->assertTrue($capabilities->resourcesSubscribe);
$this->assertFalse($capabilities->resourcesListChanged);
}
public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void
{
$resources = new \stdClass();
$resources->subscribe = false;
$resources->listChanged = true;
$data = [
'resources' => $resources,
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->resources);
$this->assertFalse($capabilities->resourcesSubscribe);
$this->assertTrue($capabilities->resourcesListChanged);
}
public function testFromArrayWithToolsArrayListChanged(): void
{
$data = [
'tools' => ['listChanged' => false],
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->tools);
$this->assertFalse($capabilities->toolsListChanged);
}
public function testFromArrayWithToolsObjectListChanged(): void
{
$tools = new \stdClass();
$tools->listChanged = true;
$data = [
'tools' => $tools,
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->tools);
$this->assertTrue($capabilities->toolsListChanged);
}
public function testFromArrayWithExperimental(): void
{
$experimental = ['feature1' => true, 'feature2' => 'test'];
$data = [
'experimental' => $experimental,
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertEquals($experimental, $capabilities->experimental);
}
public function testFromArrayWithComplexData(): void
{
$data = [
'tools' => ['listChanged' => true],
'resources' => [
'subscribe' => true,
'listChanged' => false,
],
'prompts' => ['listChanged' => true],
'logging' => new \stdClass(),
'completions' => new \stdClass(),
'experimental' => ['customFeature' => 'enabled'],
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->tools);
$this->assertTrue($capabilities->toolsListChanged);
$this->assertTrue($capabilities->resources);
$this->assertTrue($capabilities->resourcesSubscribe);
$this->assertFalse($capabilities->resourcesListChanged);
$this->assertTrue($capabilities->prompts);
$this->assertTrue($capabilities->promptsListChanged);
$this->assertTrue($capabilities->logging);
$this->assertTrue($capabilities->completions);
$this->assertEquals(['customFeature' => 'enabled'], $capabilities->experimental);
}
public function testJsonSerializeWithDefaults(): void
{
$capabilities = new ServerCapabilities();
$json = $capabilities->jsonSerialize();
$expected = [
'tools' => new \stdClass(),
'resources' => new \stdClass(),
'prompts' => new \stdClass(),
];
$this->assertEquals($expected, $json);
}
public function testJsonSerializeWithAllFeaturesEnabled(): void
{
$experimental = ['feature1' => true];
$capabilities = new ServerCapabilities(
tools: true,
toolsListChanged: true,
resources: true,
resourcesSubscribe: true,
resourcesListChanged: true,
prompts: true,
promptsListChanged: true,
logging: true,
completions: true,
experimental: $experimental
);
$json = $capabilities->jsonSerialize();
$this->assertArrayHasKey('logging', $json);
$this->assertEquals(new \stdClass(), $json['logging']);
$this->assertArrayHasKey('completions', $json);
$this->assertEquals(new \stdClass(), $json['completions']);
$this->assertArrayHasKey('prompts', $json);
$this->assertTrue($json['prompts']->listChanged);
$this->assertArrayHasKey('resources', $json);
$this->assertTrue($json['resources']->subscribe);
$this->assertTrue($json['resources']->listChanged);
$this->assertArrayHasKey('tools', $json);
$this->assertTrue($json['tools']->listChanged);
$this->assertArrayHasKey('experimental', $json);
$this->assertEquals((object) $experimental, $json['experimental']);
}
public function testJsonSerializeWithFalseValues(): void
{
$capabilities = new ServerCapabilities(
tools: false,
resources: false,
prompts: false,
logging: false,
completions: false
);
$json = $capabilities->jsonSerialize();
$this->assertEquals([], $json);
}
public function testJsonSerializeWithMixedValues(): void
{
$capabilities = new ServerCapabilities(
tools: true,
toolsListChanged: false,
resources: false,
resourcesSubscribe: true,
resourcesListChanged: true,
prompts: true,
promptsListChanged: false,
logging: false,
completions: true
);
$json = $capabilities->jsonSerialize();
$expected = [
'completions' => new \stdClass(),
'prompts' => new \stdClass(),
'resources' => (object) [
'subscribe' => true,
'listChanged' => true,
],
'tools' => new \stdClass(),
];
$this->assertEquals($expected, $json);
}
public function testJsonSerializeWithOnlyListChangedFlags(): void
{
$capabilities = new ServerCapabilities(
tools: false,
toolsListChanged: true,
resources: false,
resourcesListChanged: true,
prompts: false,
promptsListChanged: true
);
$json = $capabilities->jsonSerialize();
$expected = [
'prompts' => (object) ['listChanged' => true],
'resources' => (object) ['listChanged' => true],
'tools' => (object) ['listChanged' => true],
];
$this->assertEquals($expected, $json);
}
public function testJsonSerializeWithNullExperimental(): void
{
$capabilities = new ServerCapabilities(
tools: true,
experimental: null
);
$json = $capabilities->jsonSerialize();
$this->assertArrayNotHasKey('experimental', $json);
$this->assertArrayHasKey('tools', $json);
}
public function testFromArrayHandlesEdgeCasesGracefully(): void
{
$data = [
'prompts' => [],
'resources' => [],
'tools' => [],
];
$capabilities = ServerCapabilities::fromArray($data);
$this->assertTrue($capabilities->prompts);
$this->assertNull($capabilities->promptsListChanged);
$this->assertTrue($capabilities->resources);
$this->assertNull($capabilities->resourcesSubscribe);
$this->assertNull($capabilities->resourcesListChanged);
$this->assertTrue($capabilities->tools);
$this->assertNull($capabilities->toolsListChanged);
}
}

View file

@ -0,0 +1,294 @@
<?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\Server\RequestHandler;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\RequestHandler\CallToolHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class CallToolHandlerTest extends TestCase
{
private CallToolHandler $handler;
private ToolCallerInterface|MockObject $toolExecutor;
private LoggerInterface|MockObject $logger;
protected function setUp(): void
{
$this->toolExecutor = $this->createMock(ToolCallerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->handler = new CallToolHandler(
$this->toolExecutor,
$this->logger,
);
}
public function testSupportsCallToolRequest(): void
{
$request = $this->createCallToolRequest('test_tool', ['param' => 'value']);
$this->assertTrue($this->handler->supports($request));
}
public function testHandleSuccessfulToolCall(): void
{
$request = $this->createCallToolRequest('greet_user', ['name' => 'John']);
$expectedResult = new CallToolResult([new TextContent('Hello, John!')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$this->logger
->expects($this->never())
->method('error');
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleToolCallWithEmptyArguments(): void
{
$request = $this->createCallToolRequest('simple_tool', []);
$expectedResult = new CallToolResult([new TextContent('Simple result')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleToolCallWithComplexArguments(): void
{
$arguments = [
'string_param' => 'value',
'int_param' => 42,
'bool_param' => true,
'array_param' => ['nested' => 'data'],
'null_param' => null,
];
$request = $this->createCallToolRequest('complex_tool', $arguments);
$expectedResult = new CallToolResult([new TextContent('Complex result')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleToolNotFoundExceptionReturnsError(): void
{
$request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']);
$exception = new ToolNotFoundException($request);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willThrowException($exception);
$this->logger
->expects($this->once())
->method('error')
->with(
'Error while executing tool "nonexistent_tool": "Tool not found for call: "nonexistent_tool".".',
[
'tool' => 'nonexistent_tool',
'arguments' => ['param' => 'value'],
],
);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INTERNAL_ERROR, $response->code);
$this->assertEquals('Error while executing tool', $response->message);
}
public function testHandleToolExecutionExceptionReturnsError(): void
{
$request = $this->createCallToolRequest('failing_tool', ['param' => 'value']);
$exception = new ToolCallException($request, new \RuntimeException('Tool execution failed'));
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willThrowException($exception);
$this->logger
->expects($this->once())
->method('error')
->with(
'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".',
[
'tool' => 'failing_tool',
'arguments' => ['param' => 'value'],
],
);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INTERNAL_ERROR, $response->code);
$this->assertEquals('Error while executing tool', $response->message);
}
public function testHandleWithNullResult(): void
{
$request = $this->createCallToolRequest('null_tool', []);
$expectedResult = new CallToolResult([]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleWithErrorResult(): void
{
$request = $this->createCallToolRequest('error_tool', []);
$expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertTrue($response->result->isError);
}
public function testConstructorWithDefaultLogger(): void
{
$handler = new CallToolHandler($this->toolExecutor);
$this->assertInstanceOf(CallToolHandler::class, $handler);
}
public function testHandleLogsErrorWithCorrectParameters(): void
{
$request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]);
$exception = new ToolCallException($request, new \RuntimeException('Custom error message'));
$this->toolExecutor
->expects($this->once())
->method('call')
->willThrowException($exception);
$this->logger
->expects($this->once())
->method('error')
->with(
'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".',
[
'tool' => 'test_tool',
'arguments' => ['key1' => 'value1', 'key2' => 42],
],
);
$this->handler->handle($request);
}
public function testHandleWithSpecialCharactersInToolName(): void
{
$request = $this->createCallToolRequest('tool-with_special.chars', []);
$expectedResult = new CallToolResult([new TextContent('Special tool result')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleWithSpecialCharactersInArguments(): void
{
$arguments = [
'special_chars' => 'äöü ñ 中文 🚀',
'unicode' => '\\u{1F600}',
'quotes' => 'text with "quotes" and \'single quotes\'',
];
$request = $this->createCallToolRequest('unicode_tool', $arguments);
$expectedResult = new CallToolResult([new TextContent('Unicode handled')]);
$this->toolExecutor
->expects($this->once())
->method('call')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
/**
* @param array<string, mixed> $arguments
*/
private function createCallToolRequest(string $name, array $arguments): CallToolRequest
{
return CallToolRequest::fromArray([
'jsonrpc' => '2.0',
'method' => CallToolRequest::getMethod(),
'id' => 'test-request-'.uniqid(),
'params' => [
'name' => $name,
'arguments' => $arguments,
],
]);
}
}

View file

@ -0,0 +1,342 @@
<?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\Server\RequestHandler;
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
use Mcp\Server\RequestHandler\GetPromptHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class GetPromptHandlerTest extends TestCase
{
private GetPromptHandler $handler;
private PromptGetterInterface|MockObject $promptGetter;
protected function setUp(): void
{
$this->promptGetter = $this->createMock(PromptGetterInterface::class);
$this->handler = new GetPromptHandler($this->promptGetter);
}
public function testSupportsGetPromptRequest(): void
{
$request = $this->createGetPromptRequest('test_prompt');
$this->assertTrue($this->handler->supports($request));
}
public function testHandleSuccessfulPromptGet(): void
{
$request = $this->createGetPromptRequest('greeting_prompt');
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')),
];
$expectedResult = new GetPromptResult(
description: 'A greeting prompt',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetWithArguments(): void
{
$arguments = [
'name' => 'John',
'context' => 'business meeting',
'formality' => 'formal',
];
$request = $this->createGetPromptRequest('personalized_prompt', $arguments);
$expectedMessages = [
new PromptMessage(
Role::User,
new TextContent('Good morning, John. How may I assist you in your business meeting?'),
),
];
$expectedResult = new GetPromptResult(
description: 'A personalized greeting prompt',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetWithNullArguments(): void
{
$request = $this->createGetPromptRequest('simple_prompt', null);
$expectedMessages = [
new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')),
];
$expectedResult = new GetPromptResult(
description: 'A simple prompt',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetWithEmptyArguments(): void
{
$request = $this->createGetPromptRequest('empty_args_prompt', []);
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Default message')),
];
$expectedResult = new GetPromptResult(
description: 'A prompt with empty arguments',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetWithMultipleMessages(): void
{
$request = $this->createGetPromptRequest('conversation_prompt');
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Hello')),
new PromptMessage(Role::Assistant, new TextContent('Hi there! How can I help you today?')),
new PromptMessage(Role::User, new TextContent('I need assistance with my project')),
];
$expectedResult = new GetPromptResult(
description: 'A conversation prompt',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertCount(3, $response->result->messages);
}
public function testHandlePromptNotFoundExceptionReturnsError(): void
{
$request = $this->createGetPromptRequest('nonexistent_prompt');
$exception = new PromptNotFoundException($request);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willThrowException($exception);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INTERNAL_ERROR, $response->code);
$this->assertEquals('Error while handling prompt', $response->message);
}
public function testHandlePromptGetExceptionReturnsError(): void
{
$request = $this->createGetPromptRequest('failing_prompt');
$exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt'));
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willThrowException($exception);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INTERNAL_ERROR, $response->code);
$this->assertEquals('Error while handling prompt', $response->message);
}
public function testHandlePromptGetWithComplexArguments(): void
{
$arguments = [
'user_data' => [
'name' => 'Alice',
'preferences' => ['formal', 'concise'],
'history' => [
'last_interaction' => '2025-01-15',
'topics' => ['technology', 'business'],
],
],
'context' => 'technical consultation',
'metadata' => [
'session_id' => 'sess_123456',
'timestamp' => 1705392000,
],
];
$request = $this->createGetPromptRequest('complex_prompt', $arguments);
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Complex prompt generated with all parameters')),
];
$expectedResult = new GetPromptResult(
description: 'A complex prompt with nested arguments',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetWithSpecialCharacters(): void
{
$arguments = [
'message' => 'Hello 世界! How are you? 😊',
'special' => 'äöü ñ ß',
'quotes' => 'Text with "double" and \'single\' quotes',
];
$request = $this->createGetPromptRequest('unicode_prompt', $arguments);
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Unicode message processed')),
];
$expectedResult = new GetPromptResult(
description: 'A prompt handling unicode characters',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandlePromptGetReturnsEmptyMessages(): void
{
$request = $this->createGetPromptRequest('empty_prompt');
$expectedResult = new GetPromptResult(
description: 'An empty prompt',
messages: [],
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertCount(0, $response->result->messages);
}
public function testHandlePromptGetWithLargeNumberOfArguments(): void
{
$arguments = [];
for ($i = 0; $i < 100; ++$i) {
$arguments["arg_{$i}"] = "value_{$i}";
}
$request = $this->createGetPromptRequest('many_args_prompt', $arguments);
$expectedMessages = [
new PromptMessage(Role::User, new TextContent('Processed 100 arguments')),
];
$expectedResult = new GetPromptResult(
description: 'A prompt with many arguments',
messages: $expectedMessages,
);
$this->promptGetter
->expects($this->once())
->method('get')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
/**
* @param array<string, mixed>|null $arguments
*/
private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest
{
return GetPromptRequest::fromArray([
'jsonrpc' => '2.0',
'method' => GetPromptRequest::getMethod(),
'id' => 'test-request-'.uniqid(),
'params' => [
'name' => $name,
'arguments' => $arguments,
],
]);
}
}

View file

@ -0,0 +1,147 @@
<?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\Server\RequestHandler;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\PingRequest;
use Mcp\Schema\Result\EmptyResult;
use Mcp\Server\RequestHandler\PingHandler;
use PHPUnit\Framework\TestCase;
class PingHandlerTest extends TestCase
{
private PingHandler $handler;
protected function setUp(): void
{
$this->handler = new PingHandler();
}
public function testSupportsPingRequest(): void
{
$request = $this->createPingRequest();
$this->assertTrue($this->handler->supports($request));
}
public function testHandlePingRequest(): void
{
$request = $this->createPingRequest();
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertInstanceOf(EmptyResult::class, $response->result);
}
public function testHandleMultiplePingRequests(): void
{
$request1 = $this->createPingRequest();
$request2 = $this->createPingRequest();
$response1 = $this->handler->handle($request1);
$response2 = $this->handler->handle($request2);
$this->assertInstanceOf(Response::class, $response1);
$this->assertInstanceOf(Response::class, $response2);
$this->assertInstanceOf(EmptyResult::class, $response1->result);
$this->assertInstanceOf(EmptyResult::class, $response2->result);
$this->assertEquals($request1->getId(), $response1->id);
$this->assertEquals($request2->getId(), $response2->id);
}
public function testHandlerHasNoSideEffects(): void
{
$request = $this->createPingRequest();
// Handle same request multiple times
$response1 = $this->handler->handle($request);
$response2 = $this->handler->handle($request);
// Both responses should be identical
$this->assertEquals($response1->id, $response2->id);
$this->assertEquals(
\get_class($response1->result),
\get_class($response2->result),
);
}
public function testEmptyResultIsCorrectType(): void
{
$request = $this->createPingRequest();
$response = $this->handler->handle($request);
$this->assertInstanceOf(EmptyResult::class, $response->result);
// Verify EmptyResult serializes to empty object
$serialized = json_encode($response->result);
$this->assertEquals('{}', $serialized);
}
public function testHandlerIsStateless(): void
{
$handler1 = new PingHandler();
$handler2 = new PingHandler();
$request = $this->createPingRequest();
$response1 = $handler1->handle($request);
$response2 = $handler2->handle($request);
// Both handlers should produce equivalent results
$this->assertEquals($response1->id, $response2->id);
$this->assertEquals(
\get_class($response1->result),
\get_class($response2->result),
);
}
public function testSupportsMethodIsConsistent(): void
{
$request = $this->createPingRequest();
// Multiple calls to supports should return same result
$this->assertTrue($this->handler->supports($request));
$this->assertTrue($this->handler->supports($request));
$this->assertTrue($this->handler->supports($request));
}
public function testHandlerCanBeReused(): void
{
$requests = [];
$responses = [];
// Create multiple ping requests
for ($i = 0; $i < 5; ++$i) {
$requests[$i] = $this->createPingRequest();
$responses[$i] = $this->handler->handle($requests[$i]);
}
// All responses should be valid
foreach ($responses as $i => $response) {
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($requests[$i]->getId(), $response->id);
$this->assertInstanceOf(EmptyResult::class, $response->result);
}
}
private function createPingRequest(): Request
{
return PingRequest::fromArray([
'jsonrpc' => '2.0',
'method' => PingRequest::getMethod(),
'id' => 'test-request-'.uniqid(),
]);
}
}

View file

@ -0,0 +1,351 @@
<?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\Server\RequestHandler;
use Mcp\Capability\Resource\ResourceReaderInterface;
use Mcp\Exception\ResourceNotFoundException;
use Mcp\Exception\ResourceReadException;
use Mcp\Schema\Content\BlobResourceContents;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Server\RequestHandler\ReadResourceHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ReadResourceHandlerTest extends TestCase
{
private ReadResourceHandler $handler;
private ResourceReaderInterface|MockObject $resourceReader;
protected function setUp(): void
{
$this->resourceReader = $this->createMock(ResourceReaderInterface::class);
$this->handler = new ReadResourceHandler($this->resourceReader);
}
public function testSupportsReadResourceRequest(): void
{
$request = $this->createReadResourceRequest('file://test.txt');
$this->assertTrue($this->handler->supports($request));
}
public function testHandleSuccessfulResourceRead(): void
{
$uri = 'file://documents/readme.txt';
$request = $this->createReadResourceRequest($uri);
$expectedContent = new TextResourceContents(
uri: $uri,
mimeType: 'text/plain',
text: 'This is the content of the readme file.',
);
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleResourceReadWithBlobContent(): void
{
$uri = 'file://images/logo.png';
$request = $this->createReadResourceRequest($uri);
$expectedContent = new BlobResourceContents(
uri: $uri,
mimeType: 'image/png',
blob: base64_encode('fake-image-data'),
);
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleResourceReadWithMultipleContents(): void
{
$uri = 'app://data/mixed-content';
$request = $this->createReadResourceRequest($uri);
$textContent = new TextResourceContents(
uri: $uri,
mimeType: 'text/plain',
text: 'Text part of the resource',
);
$blobContent = new BlobResourceContents(
uri: $uri,
mimeType: 'application/octet-stream',
blob: base64_encode('binary-data'),
);
$expectedResult = new ReadResourceResult([$textContent, $blobContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertCount(2, $response->result->contents);
}
public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void
{
$uri = 'file://nonexistent/file.txt';
$request = $this->createReadResourceRequest($uri);
$exception = new ResourceNotFoundException($request);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willThrowException($exception);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code);
$this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message);
}
public function testHandleResourceReadExceptionReturnsGenericError(): void
{
$uri = 'file://corrupted/file.txt';
$request = $this->createReadResourceRequest($uri);
$exception = new ResourceReadException(
$request,
new \RuntimeException('Failed to read resource: corrupted data'),
);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willThrowException($exception);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INTERNAL_ERROR, $response->code);
$this->assertEquals('Error while reading resource', $response->message);
}
public function testHandleResourceReadWithDifferentUriSchemes(): void
{
$uriSchemes = [
'file://local/path/file.txt',
'http://example.com/resource',
'https://secure.example.com/api/data',
'ftp://files.example.com/document.pdf',
'app://internal/resource/123',
'custom-scheme://special/resource',
];
foreach ($uriSchemes as $uri) {
$request = $this->createReadResourceRequest($uri);
$expectedContent = new TextResourceContents(
uri: $uri,
mimeType: 'text/plain',
text: "Content for {$uri}",
);
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
// Reset the mock for next iteration
$this->resourceReader = $this->createMock(ResourceReaderInterface::class);
$this->handler = new ReadResourceHandler($this->resourceReader);
}
}
public function testHandleResourceReadWithSpecialCharactersInUri(): void
{
$uri = 'file://path/with spaces/äöü-file-ñ.txt';
$request = $this->createReadResourceRequest($uri);
$expectedContent = new TextResourceContents(
uri: $uri,
mimeType: 'text/plain',
text: 'Content with unicode characters: äöü ñ 世界 🚀',
);
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
}
public function testHandleResourceReadWithEmptyContent(): void
{
$uri = 'file://empty/file.txt';
$request = $this->createReadResourceRequest($uri);
$expectedContent = new TextResourceContents(
uri: $uri,
mimeType: 'text/plain',
text: '',
);
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertInstanceOf(TextResourceContents::class, $response->result->contents[0]);
$this->assertEquals('', $response->result->contents[0]->text);
}
public function testHandleResourceReadWithDifferentMimeTypes(): void
{
$mimeTypes = [
'text/plain',
'text/html',
'application/json',
'application/xml',
'image/png',
'image/jpeg',
'application/pdf',
'video/mp4',
'audio/mpeg',
'application/octet-stream',
];
foreach ($mimeTypes as $i => $mimeType) {
$uri = "file://test/file{$i}";
$request = $this->createReadResourceRequest($uri);
if (str_starts_with($mimeType, 'text/') || str_starts_with($mimeType, 'application/json')) {
$expectedContent = new TextResourceContents(
uri: $uri,
mimeType: $mimeType,
text: "Content for {$mimeType}",
);
} else {
$expectedContent = new BlobResourceContents(
uri: $uri,
mimeType: $mimeType,
blob: base64_encode("binary-content-for-{$mimeType}"),
);
}
$expectedResult = new ReadResourceResult([$expectedContent]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertEquals($mimeType, $response->result->contents[0]->mimeType);
// Reset the mock for next iteration
$this->resourceReader = $this->createMock(ResourceReaderInterface::class);
$this->handler = new ReadResourceHandler($this->resourceReader);
}
}
public function testHandleResourceNotFoundWithCustomMessage(): void
{
$uri = 'file://custom/missing.txt';
$request = $this->createReadResourceRequest($uri);
$exception = new ResourceNotFoundException($request);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willThrowException($exception);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code);
$this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message);
}
public function testHandleResourceReadWithEmptyResult(): void
{
$uri = 'file://empty/resource';
$request = $this->createReadResourceRequest($uri);
$expectedResult = new ReadResourceResult([]);
$this->resourceReader
->expects($this->once())
->method('read')
->with($request)
->willReturn($expectedResult);
$response = $this->handler->handle($request);
$this->assertInstanceOf(Response::class, $response);
$this->assertSame($expectedResult, $response->result);
$this->assertCount(0, $response->result->contents);
}
private function createReadResourceRequest(string $uri): ReadResourceRequest
{
return ReadResourceRequest::fromArray([
'jsonrpc' => '2.0',
'method' => ReadResourceRequest::getMethod(),
'id' => 'test-request-'.uniqid(),
'params' => [
'uri' => $uri,
],
]);
}
}

View file

@ -32,6 +32,7 @@ class ServerTest extends TestCase
->disableOriginalConstructor()
->onlyMethods(['process'])
->getMock();
$handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']);
$transport = $this->getMockBuilder(InMemoryTransport::class)