628 lines
21 KiB
PHP
628 lines
21 KiB
PHP
<?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\Unit\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,
|
|
);
|
|
}
|
|
}
|