Skip to content
This repository was archived by the owner on Nov 12, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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:**
Expand Down
4 changes: 2 additions & 2 deletions src/DependencyInjection/CompilerPass/PromptPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
));
}
Expand Down
20 changes: 14 additions & 6 deletions src/DependencyInjection/CompilerPass/ToolPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
24 changes: 16 additions & 8 deletions src/MethodHandler/ToolsCallMethodHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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));
}
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/Service/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/Tool/ToolDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function __construct(
public string $description,
public array $inputSchema,
#[Ignore]
public string $inputSchemaClass,
public ?string $inputSchemaClass,
public array $annotations,
) {
}
Expand Down
106 changes: 93 additions & 13 deletions tests/Controller/EntrypointControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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' => [
Expand All @@ -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' => [
Expand All @@ -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']);
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions tests/TestApp/src/Prompt/SayHelloPrompt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Ecourty\McpServerBundle\TestApp\Prompt;

use Ecourty\McpServerBundle\Attribute\AsPrompt;
use Ecourty\McpServerBundle\Enum\PromptRole;
use Ecourty\McpServerBundle\IO\Prompt\Content\TextContent;
use Ecourty\McpServerBundle\IO\Prompt\PromptMessage;
use Ecourty\McpServerBundle\IO\Prompt\PromptResult;

#[AsPrompt(
name: 'say_hello',
description: 'Says hello',
)]
class SayHelloPrompt
{
public function __invoke(): PromptResult
{
return new PromptResult(
description: 'Says hello',
messages: [
new PromptMessage(
role: PromptRole::SYSTEM,
content: new TextContent('Hello!'),
),
],
);
}
}
23 changes: 23 additions & 0 deletions tests/TestApp/src/Tool/DateTimeTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Ecourty\McpServerBundle\TestApp\Tool;

use Ecourty\McpServerBundle\Attribute\AsTool;
use Ecourty\McpServerBundle\IO\TextToolResult;
use Ecourty\McpServerBundle\IO\ToolResult;

#[AsTool(
name: 'date_time',
description: 'Retrieve the date and time of the server',
)]
class DateTimeTool
{
public function __invoke(): ToolResult
{
return new ToolResult([
new TextToolResult(content: (new \DateTime('now'))->format('Y-m-d H:i:s')),
]);
}
}