diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb1ca1..92f1952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.3.0] - 2025-07-27 + +### Updated + +- MCP Server Bundle now supports tools and prompts without arguments. +- Updated tests fo fit the nex behavior + ## [1.2.0] - 2025-06-26 ### Added diff --git a/README.md b/README.md index 319f2d7..6bcaa4f 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Tools are the core components of the MCP Server Bundle. They allow you to define 1. Create a new class that will handle your tool logic 2. Use the `#[AsTool]` attribute to register your tool -3. Define the input schema for your tool using a class with validation constraints and OpenAPI attributes +3. Optionally, define the input schema for your tool using a class with validation constraints and OpenAPI attributes 4. Implement the `__invoke` method to handle the tool logic and return a `ToolResult` _As Tool classes are services within the Symfony application, any dependency can be injected in it, using the constructor, like any other service._ @@ -523,7 +523,7 @@ They are useful for providing context, instructions, or any structured message t 1. **Define a prompt class** - Use the `#[AsPrompt]` attribute to register your prompt. - - The class should implement the `__invoke` method, which receives an `ArgumentCollection` and returns a `PromptResult`. + - The class should implement the `__invoke` method, which optionally receives an `ArgumentCollection` and returns a `PromptResult`. - Arguments are defined using the `Argument` class (name, description, required, allowUnsafe), within the `#[AsPrompt]` declaration. **Example:** diff --git a/src/DependencyInjection/CompilerPass/PromptPass.php b/src/DependencyInjection/CompilerPass/PromptPass.php index 1836a30..ec6eceb 100644 --- a/src/DependencyInjection/CompilerPass/PromptPass.php +++ b/src/DependencyInjection/CompilerPass/PromptPass.php @@ -55,9 +55,9 @@ public function process(ContainerBuilder $container): void } $invokeMethodParameters = $refClass->getMethod('__invoke')->getParameters(); - if (\count($invokeMethodParameters) !== 1) { + if (\count($invokeMethodParameters) > 1) { throw new \LogicException(\sprintf( - 'Class "%s" must have exactly one parameter in the __invoke method to be used as a prompt.', + 'Class "%s" must have a maximum of one parameter in the __invoke method to be used as a prompt.', $class, )); } diff --git a/src/DependencyInjection/CompilerPass/ToolPass.php b/src/DependencyInjection/CompilerPass/ToolPass.php index 1241867..0068b58 100644 --- a/src/DependencyInjection/CompilerPass/ToolPass.php +++ b/src/DependencyInjection/CompilerPass/ToolPass.php @@ -65,15 +65,21 @@ public function process(ContainerBuilder $container): void } $invokeMethodParameters = $refClass->getMethod('__invoke')->getParameters(); - if (\count($invokeMethodParameters) !== 1) { + if (\count($invokeMethodParameters) > 1) { throw new \LogicException(\sprintf( - 'Class "%s" must have exactly one parameter in the __invoke method to be used as a tool.', + 'Class "%s" must have a maximum of 1 parameter in the __invoke method to be used as a tool.', $class, )); } - $invokeMethodParameter = $invokeMethodParameters[0]; - if ($invokeMethodParameter->getType() === null || $invokeMethodParameter->getType()->isBuiltin()) { // @phpstan-ignore method.notFound + $invokeMethodParameter = $invokeMethodParameters[0] ?? null; + if ( + $invokeMethodParameter !== null + && ( + $invokeMethodParameter->getType() === null + || $invokeMethodParameter->getType()->isBuiltin() // @phpstan-ignore method.notFound + ) + ) { throw new \LogicException(\sprintf( 'The parameter of the __invoke method in class "%s" must be a non-builtin type.', $class, @@ -89,8 +95,10 @@ public function process(ContainerBuilder $container): void )); } - $inputSchemaClassName = $invokeMethodParameter->getType()->getName(); // @phpstan-ignore method.notFound - $inputSchema = $schemaExtractor->extract($inputSchemaClassName); + $inputSchemaClassName = $invokeMethodParameter?->getType()?->getName(); // @phpstan-ignore method.notFound + $inputSchema = $invokeMethodParameter === null + ? [] + : $schemaExtractor->extract($inputSchemaClassName); $definition->addTag(name: 'mcp_server.tool', attributes: [ 'name' => $asTool->name, diff --git a/src/MethodHandler/ToolsCallMethodHandler.php b/src/MethodHandler/ToolsCallMethodHandler.php index 88aed6d..68f0cd0 100644 --- a/src/MethodHandler/ToolsCallMethodHandler.php +++ b/src/MethodHandler/ToolsCallMethodHandler.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; /** @@ -69,20 +70,25 @@ public function handle(JsonRpcRequest $request): array $jsonPayload = json_encode($sanitizedInput, \JSON_THROW_ON_ERROR); $inputSchemaClass = $toolDefinition->inputSchemaClass; - $inputModel = $this->serializer->deserialize( - data: $jsonPayload, - type: $inputSchemaClass, - format: 'json', - ); + $inputModel = $inputSchemaClass === null + ? null + : $this->serializer->deserialize( + data: $jsonPayload, + type: $inputSchemaClass, + format: 'json', + ); - if ($inputModel instanceof $inputSchemaClass === false) { + if ($inputSchemaClass !== null && $inputModel instanceof $inputSchemaClass === false) { throw new \InvalidArgumentException(\sprintf( 'Deserialized result is not an instance of "%s".', $inputSchemaClass, )); } - $violations = $this->validator->validate($inputModel); + $violations = $inputModel === null + ? new ConstraintViolationList() + : $this->validator->validate($inputModel); + if ($violations->count() > 0) { return $this->buildValidationErrorResponse(iterator_to_array($violations)); } @@ -97,7 +103,9 @@ public function handle(JsonRpcRequest $request): array $this->eventDispatcher->dispatch($event); } - $toolResult = $tool->__invoke($inputModel); + $toolResult = $inputModel === null + ? $tool->__invoke() + : $tool->__invoke($inputModel); if ($toolResult instanceof ToolResult === false) { throw new \LogicException(\sprintf( diff --git a/src/Service/ToolRegistry.php b/src/Service/ToolRegistry.php index 0a931c7..324592e 100644 --- a/src/Service/ToolRegistry.php +++ b/src/Service/ToolRegistry.php @@ -66,7 +66,7 @@ public function addToolDefinition( string $name, string $description, array $inputSchema, - string $inputSchemaClass, + ?string $inputSchemaClass, array $annotations, ): void { if (isset($this->toolDefinitions[$name]) === true) { diff --git a/src/Tool/ToolDefinition.php b/src/Tool/ToolDefinition.php index 2d057aa..2c4eb2f 100644 --- a/src/Tool/ToolDefinition.php +++ b/src/Tool/ToolDefinition.php @@ -30,7 +30,7 @@ public function __construct( public string $description, public array $inputSchema, #[Ignore] - public string $inputSchemaClass, + public ?string $inputSchemaClass, public array $annotations, ) { } diff --git a/tests/Controller/EntrypointControllerTest.php b/tests/Controller/EntrypointControllerTest.php index 0f5df89..4e3055e 100644 --- a/tests/Controller/EntrypointControllerTest.php +++ b/tests/Controller/EntrypointControllerTest.php @@ -105,18 +105,21 @@ public function testToolList(): void $this->assertArrayNotHasKey('isError', $resultContent); $this->assertArrayHasKey('tools', $resultContent); - $this->assertCount(3, $resultContent['tools']); + $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('multiply_numbers', $tools[1]['name']); - $this->assertSame('Calculates the product of two numbers', $tools[1]['description']); + $this->assertSame('date_time', $tools[1]['name']); + $this->assertSame('Retrieve the date and time of the server', $tools[1]['description']); - $this->assertSame('sum_numbers', $tools[2]['name']); - $this->assertSame('Calculates the sum of two numbers', $tools[2]['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([ @@ -149,6 +152,9 @@ public function testToolList(): void ], $tools[0]['annotations']); $this->assertArrayHasKey('inputSchema', $tools[1]); + $this->assertSame([], $tools[1]['inputSchema']); + + $this->assertArrayHasKey('inputSchema', $tools[2]); $this->assertSame([ 'type' => 'object', 'properties' => [ @@ -163,18 +169,18 @@ public function testToolList(): void 'nullable' => false, ], ], - ], $tools[1]['inputSchema']); + ], $tools[2]['inputSchema']); - $this->assertArrayHasKey('annotations', $tools[1]); + $this->assertArrayHasKey('annotations', $tools[2]); $this->assertSame([ 'title' => 'Multiply Numbers', 'readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => false, 'openWorldHint' => false, - ], $tools[1]['annotations']); + ], $tools[2]['annotations']); - $this->assertArrayHasKey('inputSchema', $tools[2]); + $this->assertArrayHasKey('inputSchema', $tools[3]); $this->assertSame([ 'type' => 'object', 'properties' => [ @@ -189,16 +195,16 @@ public function testToolList(): void 'nullable' => false, ], ], - ], $tools[2]['inputSchema']); + ], $tools[3]['inputSchema']); - $this->assertArrayHasKey('annotations', $tools[2]); + $this->assertArrayHasKey('annotations', $tools[3]); $this->assertSame([ 'title' => '', 'readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false, 'openWorldHint' => true, - ], $tools[2]['annotations']); + ], $tools[3]['annotations']); } /** @@ -231,6 +237,40 @@ public function testToolCallWithNonExistingTool(): void ], $responseContent); } + public function testToolCallWithNoParameters(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'date_time', + ], + ], + ); + + $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); + } + /** * @covers ::entrypointAction * @covers \Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler @@ -360,9 +400,10 @@ public function testPromptList(): void $resultContent = $responseContent['result']; $this->assertArrayHasKey('prompts', $resultContent); - $this->assertCount(2, $resultContent['prompts']); + $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]); @@ -381,6 +422,10 @@ public function testPromptList(): void $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 function testPromptGet(): void @@ -434,6 +479,41 @@ public function testPromptGet(): void $this->assertStringContainsString($scope, $content['text']); } + public function testPromptGetWithoutArguments(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/get', + 'params' => [ + 'name' => 'say_hello', + ], + ], + ); + + $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); + } + public function testPromptGetWithNonRequiredParameterLeftEmpty(): void { $response = $this->request( diff --git a/tests/TestApp/src/Prompt/SayHelloPrompt.php b/tests/TestApp/src/Prompt/SayHelloPrompt.php new file mode 100644 index 0000000..027361e --- /dev/null +++ b/tests/TestApp/src/Prompt/SayHelloPrompt.php @@ -0,0 +1,31 @@ +format('Y-m-d H:i:s')), + ]); + } +}