diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f1952..4264f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2025-07-28 + +### Added + +- Added support for STDIO transport through a Symfony Command : `mcp:stdio` +- Added a new CommandTestCase for handling tests of the Symfony command + +### Updated + +- Updated the test to be more reusable, by creating a trait that contains DataProviders and assertion logic + ## [1.3.0] - 2025-07-27 ### Updated diff --git a/README.md b/README.md index 6bcaa4f..4353552 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,14 @@ The current MCP protocol supported version is `2025-06-18`, which is the latest > This bundle will evolve along with the specification, so please ensure you are using the latest version of the bundle. > The CHANGELOG can be found [here](CHANGELOG.md). +The MCP Server Bundle currently supports HTTP & STDIO as transports. + ## Table of Contents - [Getting Started](#getting-started) - [Installation](#installation) - [Configuration](#configuration) + - [Transports](#transports) - [Tools](#tools) - [Creating Tools](#creating-tools) - [Tool Results](#tool-results) @@ -93,6 +96,30 @@ mcp_server: version: '1.0.0' # The version of your MCP server, used in the initialization response ``` +### Transports + +This bundle adds support for HTTP and STDIO transports. + +#### HTTP + +HTTP support is featured using a controller, as stated in the [configuration](#configuration). + +#### STDIO + +STDIO transport is featured through a Symfony command: +```bash +$ bin/console mcp:stdio + +Description: + MCP server STDIO entrypoint + +Usage: + mcp:stdio + +Arguments: + jsonrpc_request JSON-RPC request +``` + ## Tools Tools are the core components of the MCP Server Bundle. They allow you to define and manage custom logic that can be triggered by clients. diff --git a/src/Command/EntrypointCommand.php b/src/Command/EntrypointCommand.php new file mode 100644 index 0000000..c407340 --- /dev/null +++ b/src/Command/EntrypointCommand.php @@ -0,0 +1,76 @@ +addArgument('jsonrpc_request', InputArgument::REQUIRED, 'JSON-RPC request'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $jsonRpcRequestString = (string) $input->getArgument('jsonrpc_request'); + if (empty($jsonRpcRequestString) === true) { + throw new \UnexpectedValueException('Missing request argument'); + } + + $jsonRpcRequest = $this->serializer->deserialize($jsonRpcRequestString, JsonRpcRequest::class, 'json'); + $request = new Request(content: $jsonRpcRequestString); + + try { + $response = $this->entrypointController->__invoke($jsonRpcRequest, $request); + } catch (\Throwable $exception) { + $this->requestStack->push($request); + $exceptionEvent = new ExceptionEvent( + kernel: $this->kernel, + request: $request, + requestType: HttpKernelInterface::MAIN_REQUEST, + e: $exception, + ); + $this->exceptionListener->onKernelException($exceptionEvent); + + $response = $exceptionEvent->getResponse(); + } + + if ($response === null) { + throw new \RuntimeException('No response received'); + } + + $output->write((string) $response->getContent()); + + return self::SUCCESS; + } +} diff --git a/src/EventListener/ExceptionListener.php b/src/EventListener/ExceptionListener.php index c2344e2..ead4a8f 100644 --- a/src/EventListener/ExceptionListener.php +++ b/src/EventListener/ExceptionListener.php @@ -7,8 +7,10 @@ use Ecourty\McpServerBundle\Enum\McpErrorCode; use Ecourty\McpServerBundle\Exception\MethodHandlerNotFoundException; use Ecourty\McpServerBundle\Exception\RequestHandlingException; +use Ecourty\McpServerBundle\Exception\ToolNotFoundException; use Ecourty\McpServerBundle\Service\ResponseFactory; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -38,8 +40,8 @@ public function onKernelException(ExceptionEvent $event): void $response = match (true) { $exception instanceof UnprocessableEntityHttpException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::PARSE_ERROR), - $exception instanceof MethodHandlerNotFoundException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::TOOL_NOT_FOUND), - $exception instanceof RequestHandlingException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::INTERNAL_ERROR), + $exception instanceof MethodHandlerNotFoundException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::METHOD_NOT_FOUND), + $exception instanceof RequestHandlingException => $this->handleRequestHandlingException($exception, $jsonRpcRequestId), default => null, }; @@ -47,4 +49,19 @@ public function onKernelException(ExceptionEvent $event): void $event->setResponse($response); } } + + private function handleRequestHandlingException( + RequestHandlingException $exception, + string|int|null $jsonRpcRequestId = null, + ): JsonResponse { + $previous = $exception->getPrevious(); + if ($previous instanceof ToolNotFoundException === true) { + return $this->responseFactory->error( + id: $jsonRpcRequestId, + errorCode: McpErrorCode::TOOL_NOT_FOUND, + ); + } + + return $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::INTERNAL_ERROR); + } } diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php new file mode 100644 index 0000000..7c64c9c --- /dev/null +++ b/src/Exception/ToolNotFoundException.php @@ -0,0 +1,9 @@ +toolRegistry->getToolDefinition($toolName); if ($tool === null) { - throw new \InvalidArgumentException(\sprintf('Tool "%s" not found.', $request->params['name'])); + throw new ToolNotFoundException(\sprintf('Tool "%s" not found.', $request->params['name'])); } if ($toolDefinition === null) { diff --git a/tests/Command/EntrypointCommandTest.php b/tests/Command/EntrypointCommandTest.php new file mode 100644 index 0000000..c36c51f --- /dev/null +++ b/tests/Command/EntrypointCommandTest.php @@ -0,0 +1,258 @@ +commandTester = $this->getCommandTester('mcp:stdio'); + } + + #[DataProvider('provideNonExistentMethodRequest')] + public function testExecuteWithNonExistentMethod(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertNonExistentMethod($output); + } + + #[DataProvider('provideInitializeRequest')] + public function testExecuteInitialize(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertInitializeResponse($output); + } + + #[DataProvider('provideToolsListRequest')] + public function testExecuteToolsList(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertToolsList($output); + } + + #[DataProvider('provideNonExistingToolRequest')] + public function testNonExistentTool(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertNonExistingToolResponse($output); + } + + #[DataProvider('provideToolCallWithNoParametersRequest')] + public function testToolCallWithNoParameters(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertToolCallWithNoParametersResponse($output); + } + + #[DataProvider('provideToolCallWithInvalidParamsRequest')] + public function testToolCallWithInvalidParams(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertToolCallWithInvalidParamsResponse($output); + } + + #[DataProvider('provideTestToolCalls')] + public function testToolCalls(string $method, array $params, mixed $expectedResponse): void + { + $request = \sprintf( + '{"jsonrpc": "2.0", "id": 1, "method": "%s", "params": %s}', + $method, + json_encode($params, \JSON_THROW_ON_ERROR), + ); + + $this->commandTester->execute([ + 'jsonrpc_request' => $request, + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + $responseContent = json_decode($output, true); + + $this->assertSame($expectedResponse, $responseContent); + } + + #[DataProvider('providePromptsListRequest')] + public function testPromptsList(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertPromptsList($output); + } + + #[DataProvider('providePromptGetRequest')] + public function testPromptGet(array $request, string $scope, string $changes): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertPromptGet($output, $changes, $scope); + } + + #[DataProvider('providePromptGetWithMissingArgument')] + public function testPromptGetWithNonRequiredParameterLeftEmpty(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertPromptGetWithNonRequiredArgumentLeftEmpty($output); + } + + #[DataProvider('providePromptGetWithoutArgumentsRequest')] + public function testPromptGetWithoutArguments(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertPromptGetWithoutArguments($output); + } + + #[DataProvider('providePromptWithUnsafeParametersRequest')] + public function testPromptWithUnsafeContent(array $request, string $unsafeContent): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertPromptWithUnsafeParameters($output, $unsafeContent); + } + + #[DataProvider('provideDirectResourceListRequest')] + public function testDirectResourceList(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertDirectResourcesList($output); + } + + #[DataProvider('provideTemplateResourceListRequest')] + public function testTemplateResourceList(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertTemplateResourcesList($output); + } + + #[DataProvider('provideNotFoundResourceRequest')] + public function testCallNotFoundResource(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertCallNotFoundResource($output); + } + + #[DataProvider('provideDirectResourceRequest')] + public function testCallDirectResource(array $request): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $this->assertCallDirectResource($output); + } + + #[DataProvider('provideTestDataForTemplateResourceCall')] + public function testCallTemplateResource(array $request, array $expectedResult): void + { + $this->commandTester->execute([ + 'jsonrpc_request' => json_encode($request), + ]); + + $this->commandTester->assertCommandIsSuccessful(); + $output = $this->commandTester->getDisplay(true); + + $parsedOutput = json_decode($output, true); + $result = $parsedOutput['result'] ?? null; + + $this->assertNotNull($result); + + $this->assertEquals($result, $expectedResult); + } +} diff --git a/tests/Controller/EntrypointControllerTest.php b/tests/Controller/EntrypointControllerTest.php index 4e3055e..91a84dc 100644 --- a/tests/Controller/EntrypointControllerTest.php +++ b/tests/Controller/EntrypointControllerTest.php @@ -5,8 +5,7 @@ namespace Ecourty\McpServerBundle\Tests\Controller; use Ecourty\McpServerBundle\Enum\McpErrorCode; -use Ecourty\McpServerBundle\Enum\PromptRole; -use Ecourty\McpServerBundle\MethodHandler\InitializeMethodHandler; +use Ecourty\McpServerBundle\Tests\Support\Trait\McpAssertTrait; use Ecourty\McpServerBundle\Tests\WebTestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\HttpFoundation\Request; @@ -16,6 +15,8 @@ */ class EntrypointControllerTest extends WebTestCase { + use McpAssertTrait; + /** * @covers ::entrypointAction */ @@ -37,271 +38,92 @@ public function testIndex(): void ], $responseContent); } - public function testInitialize(): void + #[DataProvider('provideNonExistentMethodRequest')] + public function testWithNonExistentMethod(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'initialize', - 'params' => [], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertArrayHasKey('result', $responseContent); - $this->assertArrayNotHasKey('error', $responseContent); - - $resultContent = $responseContent['result']; - - $this->assertArrayHasKey('protocolVersion', $resultContent); - $this->assertSame(InitializeMethodHandler::PROTOCOL_VERSION, $resultContent['protocolVersion']); - - $this->assertArrayHasKey('serverInfo', $resultContent); - $serverInfo = $resultContent['serverInfo']; - - $this->assertArrayHasKey('name', $serverInfo); - $this->assertSame('My Test MCP Server', $serverInfo['name']); + $this->assertNonExistentMethod((string) $response->getContent()); + } - $this->assertArrayHasKey('title', $serverInfo); - $this->assertSame('My Test MCP Server Title', $serverInfo['title']); + #[DataProvider('provideInitializeRequest')] + public function testInitialize(array $request): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: $request, + ); - $this->assertArrayHasKey('version', $serverInfo); - $this->assertSame('1.0.1', $serverInfo['version']); + $this->assertInitializeResponse((string) $response->getContent()); } /** * @covers ::entrypointAction * @covers \Ecourty\McpServerBundle\MethodHandler\ToolsListMethodHandler */ - public function testToolList(): void + #[DataProvider('provideToolsListRequest')] + public function testToolList(array $request): void { - $requestId = uniqid('request_id_'); - $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => $requestId, - 'method' => 'tools/list', - 'params' => [], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame('2.0', $responseContent['jsonrpc']); - $this->assertSame($requestId, $responseContent['id']); - $this->assertArrayHasKey('result', $responseContent); - - $resultContent = $responseContent['result']; - - $this->assertArrayNotHasKey('isError', $resultContent); - - $this->assertArrayHasKey('tools', $resultContent); - $this->assertCount(4, $resultContent['tools']); - - $tools = $resultContent['tools']; - - $this->assertSame('create_user', $tools[0]['name']); - $this->assertSame('Creates a user based on the provided data', $tools[0]['description']); - - $this->assertSame('date_time', $tools[1]['name']); - $this->assertSame('Retrieve the date and time of the server', $tools[1]['description']); - - $this->assertSame('multiply_numbers', $tools[2]['name']); - $this->assertSame('Calculates the product of two numbers', $tools[2]['description']); - - $this->assertSame('sum_numbers', $tools[3]['name']); - $this->assertSame('Calculates the sum of two numbers', $tools[3]['description']); - - $this->assertArrayHasKey('inputSchema', $tools[0]); - $this->assertSame([ - 'type' => 'object', - 'properties' => [ - 'emailAddress' => [ - 'description' => 'The email address of the user', - 'type' => 'string', - 'maxLength' => 255, - 'minLength' => 5, - 'nullable' => false, - ], - 'username' => [ - 'description' => 'The username of the user', - 'type' => 'string', - 'maxLength' => 50, - 'minLength' => 3, - 'nullable' => false, - ], - ], - 'required' => ['emailAddress', 'username'], - ], $tools[0]['inputSchema']); - $this->assertArrayHasKey('annotations', $tools[0]); - $this->assertSame([ - 'title' => 'Create User', - 'readOnlyHint' => false, - 'destructiveHint' => false, - 'idempotentHint' => false, - 'openWorldHint' => false, - ], $tools[0]['annotations']); - - $this->assertArrayHasKey('inputSchema', $tools[1]); - $this->assertSame([], $tools[1]['inputSchema']); - - $this->assertArrayHasKey('inputSchema', $tools[2]); - $this->assertSame([ - 'type' => 'object', - 'properties' => [ - 'number1' => [ - 'description' => 'The first number to multiply', - 'type' => 'number', - 'nullable' => false, - ], - 'number2' => [ - 'description' => 'The second number to multiply', - 'type' => 'number', - 'nullable' => false, - ], - ], - ], $tools[2]['inputSchema']); - - $this->assertArrayHasKey('annotations', $tools[2]); - $this->assertSame([ - 'title' => 'Multiply Numbers', - 'readOnlyHint' => true, - 'destructiveHint' => false, - 'idempotentHint' => false, - 'openWorldHint' => false, - ], $tools[2]['annotations']); - - $this->assertArrayHasKey('inputSchema', $tools[3]); - $this->assertSame([ - 'type' => 'object', - 'properties' => [ - 'number1' => [ - 'description' => 'The first number to sum', - 'type' => 'number', - 'nullable' => false, - ], - 'number2' => [ - 'description' => 'The second number to sum', - 'type' => 'number', - 'nullable' => false, - ], - ], - ], $tools[3]['inputSchema']); - - $this->assertArrayHasKey('annotations', $tools[3]); - $this->assertSame([ - 'title' => '', - 'readOnlyHint' => false, - 'destructiveHint' => true, - 'idempotentHint' => false, - 'openWorldHint' => true, - ], $tools[3]['annotations']); + $this->assertToolsList((string) $response->getContent()); } /** * @covers ::entrypointAction * @covers \Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler */ - public function testToolCallWithNonExistingTool(): void + #[DataProvider('provideNonExistingToolRequest')] + public function testToolCallWithNonExistingTool(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/non_existing_tool', - 'params' => [], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - 'code' => McpErrorCode::TOOL_NOT_FOUND->value, - 'message' => McpErrorCode::TOOL_NOT_FOUND->getMessage(), - ], - ], $responseContent); + $this->assertNonExistingToolResponse((string) $response->getContent()); } - public function testToolCallWithNoParameters(): void + /** + * @covers ::__invoke + * @covers \Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler + */ + #[DataProvider('provideToolCallWithNoParametersRequest')] + public function testToolCallWithNoParameters(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'date_time', - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertArrayHasKey('result', $responseContent); - - $result = $responseContent['result']; - - $this->assertArrayHasKey('content', $result); - $content = $result['content']; - - $this->assertCount(1, $content); - - $content0 = $content[0]; - $this->assertSame('text', $content0['type']); - $dateTime = $content0['text']; - - $this->assertEqualsWithDelta(new \DateTime(), new \DateTime($dateTime), 1); + $this->assertToolCallWithNoParametersResponse((string) $response->getContent()); } /** * @covers ::entrypointAction * @covers \Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler */ - public function testToolCallWithInvalidParams(): void + #[DataProvider('provideToolCallWithInvalidParamsRequest')] + public function testToolCallWithInvalidParams(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'sum_numbers', - 'arguments' => ['a', 1], - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - 'code' => McpErrorCode::INTERNAL_ERROR->value, - 'message' => McpErrorCode::INTERNAL_ERROR->getMessage(), - ], - ], $responseContent); + $this->assertToolCallWithInvalidParamsResponse((string) $response->getContent()); } /** @@ -328,404 +150,121 @@ public function testToolCalls(string $method, array $params, mixed $expectedResp $this->assertSame($expectedResponse, $responseContent); } - public static function provideTestToolCalls(): \Generator - { - yield 'Sum two numbers' => [ - 'method' => 'tools/call', - 'params' => [ - 'name' => 'sum_numbers', - 'arguments' => [ - 'number1' => 5, - 'number2' => 10, - ], - ], - 'expectedResponse' => [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'content' => [ - [ - 'type' => 'text', - 'text' => '15', - ], - ], - ], - ], - ]; - - yield 'Multiply two numbers' => [ - 'method' => 'tools/call', - 'params' => [ - 'name' => 'multiply_numbers', - 'arguments' => [ - 'number1' => 3, - 'number2' => 4, - ], - ], - 'expectedResponse' => [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'content' => [ - [ - 'type' => 'text', - 'text' => '12', - ], - ], - ], - ], - ]; - } - - public function testPromptList(): void + #[DataProvider('providePromptsListRequest')] + public function testPromptList(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'prompts/list', - 'params' => [], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame('2.0', $responseContent['jsonrpc']); - $this->assertSame(1, $responseContent['id']); - $this->assertArrayHasKey('result', $responseContent); - - $resultContent = $responseContent['result']; - - $this->assertArrayHasKey('prompts', $resultContent); - $this->assertCount(3, $resultContent['prompts']); - - $prompts = $resultContent['prompts']; - - $this->assertSame('generate-git-commit-message', $prompts[0]['name']); - $this->assertSame('Generate a git commit message based on the provided changes.', $prompts[0]['description']); - $this->assertArrayHasKey('arguments', $prompts[0]); - $this->assertCount(2, $prompts[0]['arguments']); - - $this->assertSame('changes', $prompts[0]['arguments'][0]['name']); - $this->assertSame('The changed made in the codebase', $prompts[0]['arguments'][0]['description']); - $this->assertSame('scope', $prompts[0]['arguments'][1]['name']); - $this->assertSame('The scope of the changes, e.g., feature, bugfix, etc.', $prompts[0]['arguments'][1]['description']); - - $this->assertSame('greeting', $prompts[1]['name']); - $this->assertArrayNotHasKey('description', $prompts[1]); - $this->assertArrayHasKey('arguments', $prompts[1]); - - $this->assertCount(1, $prompts[1]['arguments']); - $this->assertSame('name', $prompts[1]['arguments'][0]['name']); - $this->assertSame('The name of the person to greet.', $prompts[1]['arguments'][0]['description']); - $this->assertFalse($prompts[1]['arguments'][0]['required']); - - $this->assertSame('say_hello', $prompts[2]['name']); - $this->assertSame('Says hello', $prompts[2]['description']); - $this->assertArrayNotHasKey('arguments', $prompts[2]); + $this->assertPromptsList((string) $response->getContent()); } - public function testPromptGet(): void + #[DataProvider('providePromptGetRequest')] + public function testPromptGet(array $request, string $changes, string $scope): void { - $changes = 'Fixed a bug in the user authentication flow'; - $scope = 'bugfix'; - $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'prompts/get', - 'params' => [ - 'name' => 'generate-git-commit-message', - 'arguments' => [ - 'changes' => $changes, - 'scope' => $scope, - ], - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame('2.0', $responseContent['jsonrpc']); - $this->assertSame(1, $responseContent['id']); - $this->assertArrayHasKey('result', $responseContent); - - $resultContent = $responseContent['result']; - - // Check if the result contains the expected prompt structure - $this->assertSame('A concise git commit message prompt', $resultContent['description']); - $this->assertArrayHasKey('messages', $resultContent); - $this->assertCount(1, $resultContent['messages']); - - // Check the first message in the result - $message = $resultContent['messages'][0]; - $this->assertSame(PromptRole::USER->value, $message['role']); - $this->assertArrayHasKey('content', $message); - $content = $message['content']; - - // Check the content type and text - $this->assertSame('text', $content['type']); - $this->assertArrayHasKey('text', $content); - - // Verify that the content text contains the changes and scope - $this->assertStringContainsString($changes, $content['text']); - $this->assertStringContainsString($scope, $content['text']); + $this->assertPromptGet((string) $response->getContent(), $changes, $scope); } - public function testPromptGetWithoutArguments(): void + #[DataProvider('providePromptGetWithMissingArgument')] + public function testPromptGetWithNonRequiredParameterLeftEmpty(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'prompts/get', - 'params' => [ - 'name' => 'say_hello', - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'description' => 'Says hello', - 'messages' => [ - [ - 'role' => 'system', - 'content' => [ - 'type' => 'text', - 'text' => 'Hello!', - ], - ], - ], - ], - ], $responseContent); + $this->assertPromptGetWithNonRequiredArgumentLeftEmpty((string) $response->getContent()); } - public function testPromptGetWithNonRequiredParameterLeftEmpty(): void + #[DataProvider('providePromptGetWithoutArgumentsRequest')] + public function testPromptGetWithoutArguments(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'prompts/get', - 'params' => [ - 'name' => 'greeting', - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame('2.0', $responseContent['jsonrpc']); - $this->assertSame(1, $responseContent['id']); - $this->assertArrayHasKey('result', $responseContent); - $this->assertArrayNotHasKey('error', $responseContent); + $this->assertPromptGetWithoutArguments((string) $response->getContent()); } - public function testPromptGetWithUnsafeParameterAllowed(): void + #[DataProvider('providePromptWithUnsafeParametersRequest')] + public function testPromptGetWithUnsafeParameterAllowed(array $request, string $unsafeContent): void { - $unsafeContent = ''; - $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'prompts/get', - 'params' => [ - 'name' => 'generate-git-commit-message', - 'arguments' => [ - 'changes' => $unsafeContent, - 'scope' => 'feature', - ], - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame('2.0', $responseContent['jsonrpc']); - $this->assertSame(1, $responseContent['id']); - $this->assertArrayHasKey('result', $responseContent); - - // Check if the unsafe content is present in the response - $resultContent = $responseContent['result']; - $this->assertStringContainsString($unsafeContent, $resultContent['messages'][0]['content']['text']); + $this->assertPromptWithUnsafeParameters((string) $response->getContent(), $unsafeContent); } - public function testDirectResourceList(): void + #[DataProvider('provideDirectResourceListRequest')] + public function testDirectResourceList(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'resources/list', - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'resources' => [ - [ - 'uri' => 'file://random', - 'name' => 'random_file', - 'title' => 'Get a random file', - 'description' => 'This resource returns the content of a random file.', - 'mimeType' => 'text/plain', - ], - [ - 'uri' => 'file://robots.txt', - 'name' => 'robots_txt', - 'title' => 'Get the Robots.txt file', - 'description' => 'This resource returns the content of the robots.txt file.', - 'mimeType' => 'text/plain', - ], - ], - ], - ], $responseContent); + $this->assertDirectResourcesList((string) $response->getContent()); } - public function testTemplateResourceList(): void + #[DataProvider('provideTemplateResourceListRequest')] + public function testTemplateResourceList(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'resources/templates/list', - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'resourceTemplates' => [ - [ - 'name' => 'order_data', - 'title' => 'Get Order Data', - 'description' => 'Gathers the data of an order by their ID.', - 'mimeType' => 'application/json', - 'uriTemplate' => 'database://order/{id}', - ], - [ - 'name' => 'user_data', - 'title' => 'Get User Data', - 'description' => 'Gathers the data of a user by their ID.', - 'mimeType' => 'application/json', - 'uriTemplate' => 'database://user/{id}', - ], - ], - ], - ], $responseContent); + $this->assertTemplateResourcesList((string) $response->getContent()); } - public function testCallNotFoundResource(): void + #[DataProvider('provideNotFoundResourceRequest')] + public function testCallNotFoundResource(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'resources/read', - 'params' => [ - 'uri' => 'database://non_existing_resource/123', - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - 'code' => McpErrorCode::INTERNAL_ERROR->value, - 'message' => McpErrorCode::INTERNAL_ERROR->getMessage(), - ], - ], $responseContent); + $this->assertCallNotFoundResource((string) $response->getContent()); } - public function testCallDirectResource(): void + #[DataProvider('provideDirectResourceRequest')] + public function testCallDirectResource(array $request): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'resources/read', - 'params' => [ - 'uri' => 'file://robots.txt', - ], - ], + body: $request, ); - $responseContent = json_decode((string) $response->getContent(), true); - $this->assertNotFalse($responseContent); - - $this->assertSame([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [ - 'contents' => [ - [ - 'uri' => 'file://robots.txt', - 'name' => 'robots.txt', - 'title' => 'The robots.txt file', - 'mimeType' => 'text/plain', - 'text' => 'Disallow: /', - ], - ], - ], - ], $responseContent); + $this->assertCallDirectResource((string) $response->getContent()); } #[DataProvider('provideTestDataForTemplateResourceCall')] - public function testCallTemplateResource(string $uri, array $expectedResult): void + public function testCallTemplateResource(array $request, array $expectedResult): void { $response = $this->request( method: Request::METHOD_POST, url: '/mcp', - body: [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'resources/read', - 'params' => [ - 'uri' => $uri, - ], - ], + body: $request, ); $responseContent = json_decode((string) $response->getContent(), true); @@ -733,52 +272,4 @@ public function testCallTemplateResource(string $uri, array $expectedResult): vo $this->assertSame($expectedResult, $responseContent['result']); } - - public static function provideTestDataForTemplateResourceCall(): \Generator - { - yield [ - 'uri' => 'database://order/1', - 'expectedResult' => [ - 'contents' => [ - [ - 'uri' => 'database://order/1', - 'name' => 'order_1', - 'title' => 'Order data', - 'mimeType' => 'application/json', - 'text' => '{"id":1,"reference":"C4CA4238A0B923820DCC509A6F75849B","status":"pending"}', - ], - ], - ], - ]; - - yield [ - 'uri' => 'database://user/2', - 'expectedResult' => [ - 'contents' => [ - [ - 'uri' => 'database://user/2', - 'name' => 'user_2', - 'title' => 'User data', - 'mimeType' => 'application/json', - 'text' => '{"id":2,"name":"User 2","email":"user2@example.com"}', - ], - ], - ], - ]; - - yield [ - 'uri' => 'database://user/999', - 'expectedResult' => [ - 'contents' => [ - [ - 'uri' => 'database://user/999', - 'name' => 'user_999', - 'title' => 'User data', - 'mimeType' => 'application/json', - 'text' => '{"id":999,"name":"User 999","email":"user999@example.com"}', - ], - ], - ], - ]; - } } diff --git a/tests/MethodHandler/ToolsCallMethodHandlerTest.php b/tests/MethodHandler/ToolsCallMethodHandlerTest.php index 122f6df..2d0132f 100644 --- a/tests/MethodHandler/ToolsCallMethodHandlerTest.php +++ b/tests/MethodHandler/ToolsCallMethodHandlerTest.php @@ -7,6 +7,7 @@ use Ecourty\McpServerBundle\Event\AbstractToolEvent; use Ecourty\McpServerBundle\Event\ToolCallEvent; use Ecourty\McpServerBundle\Event\ToolResultEvent; +use Ecourty\McpServerBundle\Exception\ToolNotFoundException; use Ecourty\McpServerBundle\HttpFoundation\JsonRpcRequest; use Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler; use Ecourty\McpServerBundle\Service\InputSanitizer; @@ -122,7 +123,7 @@ public static function provideEventFireTestData(): \Generator ], ), 'events' => [], - 'willThrow' => \InvalidArgumentException::class, + 'willThrow' => ToolNotFoundException::class, ]; } } diff --git a/tests/Support/CommandTestCase.php b/tests/Support/CommandTestCase.php new file mode 100644 index 0000000..7bb3a87 --- /dev/null +++ b/tests/Support/CommandTestCase.php @@ -0,0 +1,28 @@ +find($commandName); + + return new CommandTester($command); + } +} diff --git a/tests/Support/Trait/McpAssertTrait.php b/tests/Support/Trait/McpAssertTrait.php new file mode 100644 index 0000000..8f05da3 --- /dev/null +++ b/tests/Support/Trait/McpAssertTrait.php @@ -0,0 +1,766 @@ + [ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'sum_numbers', + 'arguments' => [ + 'number1' => 5, + 'number2' => 10, + ], + ], + 'expectedResponse' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => '15', + ], + ], + ], + ], + ]; + + yield 'Multiply two numbers' => [ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'multiply_numbers', + 'arguments' => [ + 'number1' => 3, + 'number2' => 4, + ], + ], + 'expectedResponse' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => [ + [ + 'type' => 'text', + 'text' => '12', + ], + ], + ], + ], + ]; + } + + public static function provideInitializeRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ], + ]; + } + + protected function assertInitializeResponse(string $responseContent): void + { + $this->assertJson($responseContent); + + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertArrayHasKey('result', $responseContent); + $this->assertArrayNotHasKey('error', $responseContent); + + $resultContent = $responseContent['result']; + + $this->assertArrayHasKey('protocolVersion', $resultContent); + $this->assertSame(InitializeMethodHandler::PROTOCOL_VERSION, $resultContent['protocolVersion']); + + $this->assertArrayHasKey('serverInfo', $resultContent); + $serverInfo = $resultContent['serverInfo']; + + $this->assertArrayHasKey('name', $serverInfo); + $this->assertSame('My Test MCP Server', $serverInfo['name']); + + $this->assertArrayHasKey('title', $serverInfo); + $this->assertSame('My Test MCP Server Title', $serverInfo['title']); + + $this->assertArrayHasKey('version', $serverInfo); + $this->assertSame('1.0.1', $serverInfo['version']); + } + + public static function provideToolsListRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ], + ]; + } + + protected function assertToolsList(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + + $this->assertSame('2.0', $responseContent['jsonrpc']); + $this->assertArrayHasKey('result', $responseContent); + + $resultContent = $responseContent['result']; + + $this->assertArrayNotHasKey('isError', $resultContent); + + $this->assertArrayHasKey('tools', $resultContent); + $this->assertCount(4, $resultContent['tools']); + + $tools = $resultContent['tools']; + + $this->assertSame('create_user', $tools[0]['name']); + $this->assertSame('Creates a user based on the provided data', $tools[0]['description']); + + $this->assertSame('date_time', $tools[1]['name']); + $this->assertSame('Retrieve the date and time of the server', $tools[1]['description']); + + $this->assertSame('multiply_numbers', $tools[2]['name']); + $this->assertSame('Calculates the product of two numbers', $tools[2]['description']); + + $this->assertSame('sum_numbers', $tools[3]['name']); + $this->assertSame('Calculates the sum of two numbers', $tools[3]['description']); + + $this->assertArrayHasKey('inputSchema', $tools[0]); + $this->assertSame([ + 'type' => 'object', + 'properties' => [ + 'emailAddress' => [ + 'description' => 'The email address of the user', + 'type' => 'string', + 'maxLength' => 255, + 'minLength' => 5, + 'nullable' => false, + ], + 'username' => [ + 'description' => 'The username of the user', + 'type' => 'string', + 'maxLength' => 50, + 'minLength' => 3, + 'nullable' => false, + ], + ], + 'required' => ['emailAddress', 'username'], + ], $tools[0]['inputSchema']); + $this->assertArrayHasKey('annotations', $tools[0]); + $this->assertSame([ + 'title' => 'Create User', + 'readOnlyHint' => false, + 'destructiveHint' => false, + 'idempotentHint' => false, + 'openWorldHint' => false, + ], $tools[0]['annotations']); + + $this->assertArrayHasKey('inputSchema', $tools[1]); + $this->assertSame([], $tools[1]['inputSchema']); + + $this->assertArrayHasKey('inputSchema', $tools[2]); + $this->assertSame([ + 'type' => 'object', + 'properties' => [ + 'number1' => [ + 'description' => 'The first number to multiply', + 'type' => 'number', + 'nullable' => false, + ], + 'number2' => [ + 'description' => 'The second number to multiply', + 'type' => 'number', + 'nullable' => false, + ], + ], + ], $tools[2]['inputSchema']); + + $this->assertArrayHasKey('annotations', $tools[2]); + $this->assertSame([ + 'title' => 'Multiply Numbers', + 'readOnlyHint' => true, + 'destructiveHint' => false, + 'idempotentHint' => false, + 'openWorldHint' => false, + ], $tools[2]['annotations']); + + $this->assertArrayHasKey('inputSchema', $tools[3]); + $this->assertSame([ + 'type' => 'object', + 'properties' => [ + 'number1' => [ + 'description' => 'The first number to sum', + 'type' => 'number', + 'nullable' => false, + ], + 'number2' => [ + 'description' => 'The second number to sum', + 'type' => 'number', + 'nullable' => false, + ], + ], + ], $tools[3]['inputSchema']); + + $this->assertArrayHasKey('annotations', $tools[3]); + $this->assertSame([ + 'title' => '', + 'readOnlyHint' => false, + 'destructiveHint' => true, + 'idempotentHint' => false, + 'openWorldHint' => true, + ], $tools[3]['annotations']); + } + + public static function provideNonExistentMethodRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'non_existing_method', + 'params' => [], + ], + ]; + } + + protected function assertNonExistentMethod(string $responseContent): void + { + $this->assertJson($responseContent); + + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => McpErrorCode::METHOD_NOT_FOUND->value, + 'message' => McpErrorCode::METHOD_NOT_FOUND->getMessage(), + ], + ], $responseContent); + } + + public static function provideNonExistingToolRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'non_existing_tool', + 'arguments' => [], + ], + ], + ]; + } + + protected function assertNonExistingToolResponse(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => McpErrorCode::TOOL_NOT_FOUND->value, + 'message' => McpErrorCode::TOOL_NOT_FOUND->getMessage(), + ], + ], $responseContent); + } + + public static function provideToolCallWithNoParametersRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'date_time', + ], + ], + ]; + } + + protected function assertToolCallWithNoParametersResponse(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertArrayHasKey('result', $responseContent); + + $result = $responseContent['result']; + + $this->assertArrayHasKey('content', $result); + $content = $result['content']; + + $this->assertCount(1, $content); + + $content0 = $content[0]; + $this->assertSame('text', $content0['type']); + $dateTime = $content0['text']; + + $this->assertEqualsWithDelta(new \DateTime(), new \DateTime($dateTime), 1); + } + + public static function provideToolCallWithInvalidParamsRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'sum_numbers', + 'arguments' => ['a', 1], + ], + ], + ]; + } + + protected function assertToolCallWithInvalidParamsResponse(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => McpErrorCode::INTERNAL_ERROR->value, + 'message' => McpErrorCode::INTERNAL_ERROR->getMessage(), + ], + ], $responseContent); + } + + public static function providePromptsListRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/list', + 'params' => [], + ], + ]; + } + + protected function assertPromptsList(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame('2.0', $responseContent['jsonrpc']); + $this->assertSame(1, $responseContent['id']); + $this->assertArrayHasKey('result', $responseContent); + + $resultContent = $responseContent['result']; + + $this->assertArrayHasKey('prompts', $resultContent); + $this->assertCount(3, $resultContent['prompts']); + + $prompts = $resultContent['prompts']; + $this->assertSame('generate-git-commit-message', $prompts[0]['name']); + $this->assertSame('Generate a git commit message based on the provided changes.', $prompts[0]['description']); + $this->assertArrayHasKey('arguments', $prompts[0]); + $this->assertCount(2, $prompts[0]['arguments']); + + $this->assertSame('changes', $prompts[0]['arguments'][0]['name']); + $this->assertSame('The changed made in the codebase', $prompts[0]['arguments'][0]['description']); + $this->assertSame('scope', $prompts[0]['arguments'][1]['name']); + $this->assertSame('The scope of the changes, e.g., feature, bugfix, etc.', $prompts[0]['arguments'][1]['description']); + + $this->assertSame('greeting', $prompts[1]['name']); + $this->assertArrayNotHasKey('description', $prompts[1]); + $this->assertArrayHasKey('arguments', $prompts[1]); + + $this->assertCount(1, $prompts[1]['arguments']); + $this->assertSame('name', $prompts[1]['arguments'][0]['name']); + $this->assertSame('The name of the person to greet.', $prompts[1]['arguments'][0]['description']); + $this->assertFalse($prompts[1]['arguments'][0]['required']); + + $this->assertSame('say_hello', $prompts[2]['name']); + $this->assertSame('Says hello', $prompts[2]['description']); + $this->assertArrayNotHasKey('arguments', $prompts[2]); + } + + public static function providePromptGetRequest(): iterable + { + $changes = 'Fixed a bug in the user authentication flow'; + $scope = 'bugfix'; + + yield [ + 'request' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'generate-git-commit-message', + 'arguments' => [ + 'changes' => $changes, + 'scope' => $scope, + ], + ], + ], + 'scope' => $scope, + 'changes' => $changes, + ]; + } + + protected function assertPromptGet(string $responseContent, string $changes, string $scope): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame('2.0', $responseContent['jsonrpc']); + $this->assertSame(1, $responseContent['id']); + $this->assertArrayHasKey('result', $responseContent); + + $resultContent = $responseContent['result']; + + // Check if the result contains the expected prompt structure + $this->assertSame('A concise git commit message prompt', $resultContent['description']); + $this->assertArrayHasKey('messages', $resultContent); + $this->assertCount(1, $resultContent['messages']); + + // Check the first message in the result + $message = $resultContent['messages'][0]; + $this->assertSame(PromptRole::USER->value, $message['role']); + $this->assertArrayHasKey('content', $message); + $content = $message['content']; + + // Check the content type and text + $this->assertSame('text', $content['type']); + $this->assertArrayHasKey('text', $content); + + // Verify that the content text contains the changes and scope + $this->assertStringContainsString($changes, $content['text']); + $this->assertStringContainsString($scope, $content['text']); + } + + public static function providePromptGetWithMissingArgument(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'greeting', + ], + ], + ]; + } + + protected function assertPromptGetWithNonRequiredArgumentLeftEmpty(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame('2.0', $responseContent['jsonrpc']); + $this->assertSame(1, $responseContent['id']); + $this->assertArrayHasKey('result', $responseContent); + $this->assertArrayNotHasKey('error', $responseContent); + } + + public static function providePromptGetWithoutArgumentsRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'say_hello', + ], + ], + ]; + } + + protected function assertPromptGetWithoutArguments(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'description' => 'Says hello', + 'messages' => [ + [ + 'role' => 'system', + 'content' => [ + 'type' => 'text', + 'text' => 'Hello!', + ], + ], + ], + ], + ], $responseContent); + } + + public static function providePromptWithUnsafeParametersRequest(): iterable + { + $unsafeContent = ''; + + yield [ + 'request' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'generate-git-commit-message', + 'arguments' => [ + 'changes' => $unsafeContent, + 'scope' => 'feature', + ], + ], + ], + 'unsafeContent' => $unsafeContent, + ]; + } + + protected function assertPromptWithUnsafeParameters(string $responseContent, string $unsafeContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame('2.0', $responseContent['jsonrpc']); + $this->assertSame(1, $responseContent['id']); + $this->assertArrayHasKey('result', $responseContent); + + // Check if the unsafe content is present in the response + $resultContent = $responseContent['result']; + $this->assertStringContainsString($unsafeContent, $resultContent['messages'][0]['content']['text']); + } + + public static function provideDirectResourceListRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/list', + ], + ]; + } + + protected function assertDirectResourcesList(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'resources' => [ + [ + 'uri' => 'file://random', + 'name' => 'random_file', + 'title' => 'Get a random file', + 'description' => 'This resource returns the content of a random file.', + 'mimeType' => 'text/plain', + ], + [ + 'uri' => 'file://robots.txt', + 'name' => 'robots_txt', + 'title' => 'Get the Robots.txt file', + 'description' => 'This resource returns the content of the robots.txt file.', + 'mimeType' => 'text/plain', + ], + ], + ], + ], $responseContent); + } + + public static function provideTemplateResourceListRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + ], + ]; + } + + protected function assertTemplateResourcesList(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'resourceTemplates' => [ + [ + 'name' => 'order_data', + 'title' => 'Get Order Data', + 'description' => 'Gathers the data of an order by their ID.', + 'mimeType' => 'application/json', + 'uriTemplate' => 'database://order/{id}', + ], + [ + 'name' => 'user_data', + 'title' => 'Get User Data', + 'description' => 'Gathers the data of a user by their ID.', + 'mimeType' => 'application/json', + 'uriTemplate' => 'database://user/{id}', + ], + ], + ], + ], $responseContent); + } + + public static function provideNotFoundResourceRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'database://non_existing_resource/123', + ], + ], + ]; + } + + protected function assertCallNotFoundResource(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => McpErrorCode::INTERNAL_ERROR->value, + 'message' => McpErrorCode::INTERNAL_ERROR->getMessage(), + ], + ], $responseContent); + } + + public static function provideDirectResourceRequest(): iterable + { + yield [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'file://robots.txt', + ], + ], + ]; + } + + protected function assertCallDirectResource(string $responseContent): void + { + $responseContent = json_decode($responseContent, true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'contents' => [ + [ + 'uri' => 'file://robots.txt', + 'name' => 'robots.txt', + 'title' => 'The robots.txt file', + 'mimeType' => 'text/plain', + 'text' => 'Disallow: /', + ], + ], + ], + ], $responseContent); + } + + public static function provideTestDataForTemplateResourceCall(): \Generator + { + yield [ + 'request' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'database://order/1', + ], + ], + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://order/1', + 'name' => 'order_1', + 'title' => 'Order data', + 'mimeType' => 'application/json', + 'text' => '{"id":1,"reference":"C4CA4238A0B923820DCC509A6F75849B","status":"pending"}', + ], + ], + ], + ]; + + yield [ + 'request' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'database://user/2', + ], + ], + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://user/2', + 'name' => 'user_2', + 'title' => 'User data', + 'mimeType' => 'application/json', + 'text' => '{"id":2,"name":"User 2","email":"user2@example.com"}', + ], + ], + ], + ]; + + yield [ + 'request' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'database://user/999', + ], + ], + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://user/999', + 'name' => 'user_999', + 'title' => 'User data', + 'mimeType' => 'application/json', + 'text' => '{"id":999,"name":"User 999","email":"user999@example.com"}', + ], + ], + ], + ]; + } +}