registry = $this->createMock(RegistryInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); $this->handler = new CallToolHandler( $this->registry, $this->referenceHandler, $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']); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); $this->registry ->expects($this->once()) ->method('getTool') ->with('greet_user') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['name' => 'John', '_session' => $this->session, '_request' => $request]) ->willReturn('Hello, John!'); $toolReference ->expects($this->once()) ->method('formatResult') ->with('Hello, John!') ->willReturn([new TextContent('Hello, John!')]); // Logger may be called for debugging, so we don't assert never() $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals($expectedResult, $response->result); } public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $expectedResult = new CallToolResult([new TextContent('Simple result')]); $this->registry ->expects($this->once()) ->method('getTool') ->with('simple_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn('Simple result'); $toolReference ->expects($this->once()) ->method('formatResult') ->with('Simple result') ->willReturn([new TextContent('Simple result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($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); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $expectedResult = new CallToolResult([new TextContent('Complex result')]); $this->registry ->expects($this->once()) ->method('getTool') ->with('complex_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn('Complex result'); $toolReference ->expects($this->once()) ->method('formatResult') ->with('Complex result') ->willReturn([new TextContent('Complex result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); } public function testHandleToolNotFoundExceptionReturnsError(): void { $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); $this->registry ->expects($this->once()) ->method('getTool') ->with('nonexistent_tool') ->willThrowException(new ToolNotFoundException('nonexistent_tool')); $this->logger ->expects($this->once()) ->method('error'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException('Tool execution failed'); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('failing_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $this->logger ->expects($this->once()) ->method('error'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $result = $response->result; $this->assertInstanceOf(CallToolResult::class, $result); $this->assertTrue($result->isError); $this->assertCount(1, $result->content); $this->assertInstanceOf(TextContent::class, $result->content[0]); $this->assertEquals('Tool execution failed', $result->content[0]->text); } public function testHandleWithNullResult(): void { $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('null_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn(null); $toolReference ->expects($this->once()) ->method('formatResult') ->with(null) ->willReturn([]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); } public function testConstructorWithDefaultLogger(): void { $handler = new CallToolHandler($this->registry, $this->referenceHandler); $this->assertInstanceOf(CallToolHandler::class, $handler); } public function testHandleLogsErrorWithCorrectParameters(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException('Custom error message'); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('test_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $this->logger ->expects($this->once()) ->method('error') ->with( 'Error while executing tool "test_tool": "Custom error message".', [ 'tool' => 'test_tool', 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session, '_request' => $request], ], ); $response = $this->handler->handle($request, $this->session); // ToolCallException should now return Response with CallToolResult having isError=true $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $result = $response->result; $this->assertInstanceOf(CallToolResult::class, $result); $this->assertTrue($result->isError); $this->assertCount(1, $result->content); $this->assertInstanceOf(TextContent::class, $result->content[0]); $this->assertEquals('Custom error message', $result->content[0]->text); } public function testHandleGenericExceptionReturnsError(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new \RuntimeException('Internal database connection failed'); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('failing_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['param' => 'value', '_session' => $this->session, '_request' => $request]) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); // Generic exceptions should return Error, not Response $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 testHandleWithSpecialCharactersInToolName(): void { $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('tool-with_special.chars') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['_session' => $this->session, '_request' => $request]) ->willReturn('Special tool result'); $toolReference ->expects($this->once()) ->method('formatResult') ->with('Special tool result') ->willReturn([new TextContent('Special tool result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($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')]); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $this->registry ->expects($this->once()) ->method('getTool') ->with('unicode_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, array_merge($arguments, ['_session' => $this->session, '_request' => $request])) ->willReturn('Unicode handled'); $toolReference ->expects($this->once()) ->method('formatResult') ->with('Unicode handled') ->willReturn([new TextContent('Unicode handled')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); } public function testHandleReturnsStructuredContentResult(): void { $request = $this->createCallToolRequest('structured_tool', ['query' => 'php']); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $structuredResult = new CallToolResult([new TextContent('Rendered results')], false, ['result' => 'Rendered results']); $this->registry ->expects($this->once()) ->method('getTool') ->with('structured_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) ->willReturn($structuredResult); $toolReference ->expects($this->never()) ->method('formatResult'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($structuredResult, $response->result); $this->assertEquals(['result' => 'Rendered results'], $response->result->jsonSerialize()['structuredContent'] ?? []); } public function testHandleReturnsCallToolResult(): void { $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); $toolReference = $this->createToolReference('greet_user', static function () { return 'Hello, John!'; }); $callToolResult = new CallToolResult([new TextContent('Error result')], true); $this->registry ->expects($this->once()) ->method('getTool') ->with('result_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->once()) ->method('handle') ->with($toolReference, ['query' => 'php', '_session' => $this->session, '_request' => $request]) ->willReturn($callToolResult); $toolReference ->expects($this->never()) ->method('formatResult'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($callToolResult, $response->result); $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); } public function testValidationError(): void { $schema = [ 'type' => 'object', 'properties' => [ 'favorite_number' => [ 'type' => 'number', 'description' => 'Your favorite number', ], ], 'required' => [ 'favorite_number', ], ]; $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); $toolReference = $this->getMockBuilder(ToolReference::class) ->setConstructorArgs([new Tool('simple_tool', null, $schema, null, null), static function () {}]) ->getMock(); $this->registry ->expects($this->once()) ->method('getTool') ->with('result_tool') ->willReturn($toolReference); $this->referenceHandler ->expects($this->never()) ->method('handle'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INVALID_PARAMS, $response->code); } /** * @param array $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, ], ]); } private function createToolReference( string $name, callable $handler, ?array $outputSchema = null, array $methodsToMock = ['formatResult'], ): ToolReference&MockObject { $schema = [ 'type' => 'object', 'properties' => [ 'example' => [ 'type' => 'string', 'description' => 'This is just a dummy', ], ], 'required' => [], ]; $tool = new Tool($name, null, $schema, null, null, null, null, $outputSchema); $builder = $this->getMockBuilder(ToolReference::class) ->setConstructorArgs([$tool, $handler]); if (!empty($methodsToMock)) { $builder->onlyMethods($methodsToMock); } return $builder->getMock(); } }