Skip to content
This repository was archived by the owner on Nov 12, 2025. It is now read-only.

Commit a36ccbb

Browse files
committed
feat(mcp): allow tools & prompts without arguments
1 parent 6f7f526 commit a36ccbb

10 files changed

Lines changed: 190 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.3.0] - 2025-07-27
9+
10+
### Updated
11+
12+
- MCP Server Bundle now supports tools and prompts without arguments.
13+
- Updated tests fo fit the nex behavior
14+
815
## [1.2.0] - 2025-06-26
916

1017
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Tools are the core components of the MCP Server Bundle. They allow you to define
101101

102102
1. Create a new class that will handle your tool logic
103103
2. Use the `#[AsTool]` attribute to register your tool
104-
3. Define the input schema for your tool using a class with validation constraints and OpenAPI attributes
104+
3. Optionally, fefine the input schema for your tool using a class with validation constraints and OpenAPI attributes
105105
4. Implement the `__invoke` method to handle the tool logic and return a `ToolResult`
106106

107107
_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
523523

524524
1. **Define a prompt class**
525525
- Use the `#[AsPrompt]` attribute to register your prompt.
526-
- The class should implement the `__invoke` method, which receives an `ArgumentCollection` and returns a `PromptResult`.
526+
- The class should implement the `__invoke` method, which optionally receives an `ArgumentCollection` and returns a `PromptResult`.
527527
- Arguments are defined using the `Argument` class (name, description, required, allowUnsafe), within the `#[AsPrompt]` declaration.
528528

529529
**Example:**

src/DependencyInjection/CompilerPass/PromptPass.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ public function process(ContainerBuilder $container): void
5555
}
5656

5757
$invokeMethodParameters = $refClass->getMethod('__invoke')->getParameters();
58-
if (\count($invokeMethodParameters) !== 1) {
58+
if (\count($invokeMethodParameters) > 1) {
5959
throw new \LogicException(\sprintf(
60-
'Class "%s" must have exactly one parameter in the __invoke method to be used as a prompt.',
60+
'Class "%s" must have a maximum of one parameter in the __invoke method to be used as a prompt.',
6161
$class,
6262
));
6363
}

src/DependencyInjection/CompilerPass/ToolPass.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,21 @@ public function process(ContainerBuilder $container): void
6565
}
6666

6767
$invokeMethodParameters = $refClass->getMethod('__invoke')->getParameters();
68-
if (\count($invokeMethodParameters) !== 1) {
68+
if (\count($invokeMethodParameters) > 1) {
6969
throw new \LogicException(\sprintf(
70-
'Class "%s" must have exactly one parameter in the __invoke method to be used as a tool.',
70+
'Class "%s" must have a maximum of 1 parameter in the __invoke method to be used as a tool.',
7171
$class,
7272
));
7373
}
7474

75-
$invokeMethodParameter = $invokeMethodParameters[0];
76-
if ($invokeMethodParameter->getType() === null || $invokeMethodParameter->getType()->isBuiltin()) { // @phpstan-ignore method.notFound
75+
$invokeMethodParameter = $invokeMethodParameters[0] ?? null;
76+
if (
77+
$invokeMethodParameter !== null
78+
&& (
79+
$invokeMethodParameter->getType() === null
80+
|| $invokeMethodParameter->getType()->isBuiltin() // @phpstan-ignore method.notFound
81+
)
82+
) {
7783
throw new \LogicException(\sprintf(
7884
'The parameter of the __invoke method in class "%s" must be a non-builtin type.',
7985
$class,
@@ -89,8 +95,10 @@ public function process(ContainerBuilder $container): void
8995
));
9096
}
9197

92-
$inputSchemaClassName = $invokeMethodParameter->getType()->getName(); // @phpstan-ignore method.notFound
93-
$inputSchema = $schemaExtractor->extract($inputSchemaClassName);
98+
$inputSchemaClassName = $invokeMethodParameter?->getType()?->getName(); // @phpstan-ignore method.notFound
99+
$inputSchema = $invokeMethodParameter === null
100+
? []
101+
: $schemaExtractor->extract($inputSchemaClassName);
94102

95103
$definition->addTag(name: 'mcp_server.tool', attributes: [
96104
'name' => $asTool->name,

src/MethodHandler/ToolsCallMethodHandler.php

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpFoundation\Response;
1919
use Symfony\Component\Serializer\SerializerInterface;
2020
use Symfony\Component\Validator\ConstraintViolationInterface;
21+
use Symfony\Component\Validator\ConstraintViolationList;
2122
use Symfony\Component\Validator\Validator\ValidatorInterface;
2223

2324
/**
@@ -69,20 +70,25 @@ public function handle(JsonRpcRequest $request): array
6970
$jsonPayload = json_encode($sanitizedInput, \JSON_THROW_ON_ERROR);
7071
$inputSchemaClass = $toolDefinition->inputSchemaClass;
7172

72-
$inputModel = $this->serializer->deserialize(
73-
data: $jsonPayload,
74-
type: $inputSchemaClass,
75-
format: 'json',
76-
);
73+
$inputModel = $inputSchemaClass === null
74+
? null
75+
: $this->serializer->deserialize(
76+
data: $jsonPayload,
77+
type: $inputSchemaClass,
78+
format: 'json',
79+
);
7780

78-
if ($inputModel instanceof $inputSchemaClass === false) {
81+
if ($inputSchemaClass !== null && $inputModel instanceof $inputSchemaClass === false) {
7982
throw new \InvalidArgumentException(\sprintf(
8083
'Deserialized result is not an instance of "%s".',
8184
$inputSchemaClass,
8285
));
8386
}
8487

85-
$violations = $this->validator->validate($inputModel);
88+
$violations = $inputModel === null
89+
? new ConstraintViolationList()
90+
: $this->validator->validate($inputModel);
91+
8692
if ($violations->count() > 0) {
8793
return $this->buildValidationErrorResponse(iterator_to_array($violations));
8894
}
@@ -97,7 +103,9 @@ public function handle(JsonRpcRequest $request): array
97103
$this->eventDispatcher->dispatch($event);
98104
}
99105

100-
$toolResult = $tool->__invoke($inputModel);
106+
$toolResult = $inputModel === null
107+
? $tool->__invoke()
108+
: $tool->__invoke($inputModel);
101109

102110
if ($toolResult instanceof ToolResult === false) {
103111
throw new \LogicException(\sprintf(

src/Service/ToolRegistry.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public function addToolDefinition(
6666
string $name,
6767
string $description,
6868
array $inputSchema,
69-
string $inputSchemaClass,
69+
?string $inputSchemaClass,
7070
array $annotations,
7171
): void {
7272
if (isset($this->toolDefinitions[$name]) === true) {

src/Tool/ToolDefinition.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function __construct(
3030
public string $description,
3131
public array $inputSchema,
3232
#[Ignore]
33-
public string $inputSchemaClass,
33+
public ?string $inputSchemaClass,
3434
public array $annotations,
3535
) {
3636
}

tests/Controller/EntrypointControllerTest.php

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,21 @@ public function testToolList(): void
105105
$this->assertArrayNotHasKey('isError', $resultContent);
106106

107107
$this->assertArrayHasKey('tools', $resultContent);
108-
$this->assertCount(3, $resultContent['tools']);
108+
$this->assertCount(4, $resultContent['tools']);
109109

110110
$tools = $resultContent['tools'];
111111

112112
$this->assertSame('create_user', $tools[0]['name']);
113113
$this->assertSame('Creates a user based on the provided data', $tools[0]['description']);
114114

115-
$this->assertSame('multiply_numbers', $tools[1]['name']);
116-
$this->assertSame('Calculates the product of two numbers', $tools[1]['description']);
115+
$this->assertSame('date_time', $tools[1]['name']);
116+
$this->assertSame('Retrieve the date and time of the server', $tools[1]['description']);
117117

118-
$this->assertSame('sum_numbers', $tools[2]['name']);
119-
$this->assertSame('Calculates the sum of two numbers', $tools[2]['description']);
118+
$this->assertSame('multiply_numbers', $tools[2]['name']);
119+
$this->assertSame('Calculates the product of two numbers', $tools[2]['description']);
120+
121+
$this->assertSame('sum_numbers', $tools[3]['name']);
122+
$this->assertSame('Calculates the sum of two numbers', $tools[3]['description']);
120123

121124
$this->assertArrayHasKey('inputSchema', $tools[0]);
122125
$this->assertSame([
@@ -149,6 +152,9 @@ public function testToolList(): void
149152
], $tools[0]['annotations']);
150153

151154
$this->assertArrayHasKey('inputSchema', $tools[1]);
155+
$this->assertSame([], $tools[1]['inputSchema']);
156+
157+
$this->assertArrayHasKey('inputSchema', $tools[2]);
152158
$this->assertSame([
153159
'type' => 'object',
154160
'properties' => [
@@ -163,18 +169,18 @@ public function testToolList(): void
163169
'nullable' => false,
164170
],
165171
],
166-
], $tools[1]['inputSchema']);
172+
], $tools[2]['inputSchema']);
167173

168-
$this->assertArrayHasKey('annotations', $tools[1]);
174+
$this->assertArrayHasKey('annotations', $tools[2]);
169175
$this->assertSame([
170176
'title' => 'Multiply Numbers',
171177
'readOnlyHint' => true,
172178
'destructiveHint' => false,
173179
'idempotentHint' => false,
174180
'openWorldHint' => false,
175-
], $tools[1]['annotations']);
181+
], $tools[2]['annotations']);
176182

177-
$this->assertArrayHasKey('inputSchema', $tools[2]);
183+
$this->assertArrayHasKey('inputSchema', $tools[3]);
178184
$this->assertSame([
179185
'type' => 'object',
180186
'properties' => [
@@ -189,16 +195,16 @@ public function testToolList(): void
189195
'nullable' => false,
190196
],
191197
],
192-
], $tools[2]['inputSchema']);
198+
], $tools[3]['inputSchema']);
193199

194-
$this->assertArrayHasKey('annotations', $tools[2]);
200+
$this->assertArrayHasKey('annotations', $tools[3]);
195201
$this->assertSame([
196202
'title' => '',
197203
'readOnlyHint' => false,
198204
'destructiveHint' => true,
199205
'idempotentHint' => false,
200206
'openWorldHint' => true,
201-
], $tools[2]['annotations']);
207+
], $tools[3]['annotations']);
202208
}
203209

204210
/**
@@ -231,6 +237,40 @@ public function testToolCallWithNonExistingTool(): void
231237
], $responseContent);
232238
}
233239

240+
public function testToolCallWithNoParameters(): void
241+
{
242+
$response = $this->request(
243+
method: Request::METHOD_POST,
244+
url: '/mcp',
245+
body: [
246+
'jsonrpc' => '2.0',
247+
'id' => 1,
248+
'method' => 'tools/call',
249+
'params' => [
250+
'name' => 'date_time',
251+
],
252+
],
253+
);
254+
255+
$responseContent = json_decode((string) $response->getContent(), true);
256+
$this->assertNotFalse($responseContent);
257+
258+
$this->assertArrayHasKey('result', $responseContent);
259+
260+
$result = $responseContent['result'];
261+
262+
$this->assertArrayHasKey('content', $result);
263+
$content = $result['content'];
264+
265+
$this->assertCount(1, $content);
266+
267+
$content0 = $content[0];
268+
$this->assertSame('text', $content0['type']);
269+
$dateTime = $content0['text'];
270+
271+
$this->assertEqualsWithDelta(new \DateTime(), new \DateTime($dateTime), 1);
272+
}
273+
234274
/**
235275
* @covers ::entrypointAction
236276
* @covers \Ecourty\McpServerBundle\MethodHandler\ToolsCallMethodHandler
@@ -360,9 +400,10 @@ public function testPromptList(): void
360400
$resultContent = $responseContent['result'];
361401

362402
$this->assertArrayHasKey('prompts', $resultContent);
363-
$this->assertCount(2, $resultContent['prompts']);
403+
$this->assertCount(3, $resultContent['prompts']);
364404

365405
$prompts = $resultContent['prompts'];
406+
366407
$this->assertSame('generate-git-commit-message', $prompts[0]['name']);
367408
$this->assertSame('Generate a git commit message based on the provided changes.', $prompts[0]['description']);
368409
$this->assertArrayHasKey('arguments', $prompts[0]);
@@ -381,6 +422,10 @@ public function testPromptList(): void
381422
$this->assertSame('name', $prompts[1]['arguments'][0]['name']);
382423
$this->assertSame('The name of the person to greet.', $prompts[1]['arguments'][0]['description']);
383424
$this->assertFalse($prompts[1]['arguments'][0]['required']);
425+
426+
$this->assertSame('say_hello', $prompts[2]['name']);
427+
$this->assertSame('Says hello', $prompts[2]['description']);
428+
$this->assertArrayNotHasKey('arguments', $prompts[2]);
384429
}
385430

386431
public function testPromptGet(): void
@@ -434,6 +479,41 @@ public function testPromptGet(): void
434479
$this->assertStringContainsString($scope, $content['text']);
435480
}
436481

482+
public function testPromptGetWithoutArguments(): void
483+
{
484+
$response = $this->request(
485+
method: Request::METHOD_POST,
486+
url: '/mcp',
487+
body: [
488+
'jsonrpc' => '2.0',
489+
'id' => 1,
490+
'method' => 'prompts/get',
491+
'params' => [
492+
'name' => 'say_hello',
493+
],
494+
],
495+
);
496+
497+
$responseContent = json_decode((string) $response->getContent(), true);
498+
499+
$this->assertSame([
500+
'jsonrpc' => '2.0',
501+
'id' => 1,
502+
'result' => [
503+
'description' => 'Says hello',
504+
'messages' => [
505+
[
506+
'role' => 'system',
507+
'content' => [
508+
'type' => 'text',
509+
'text' => 'Hello!',
510+
],
511+
],
512+
],
513+
],
514+
], $responseContent);
515+
}
516+
437517
public function testPromptGetWithNonRequiredParameterLeftEmpty(): void
438518
{
439519
$response = $this->request(
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\TestApp\Prompt;
6+
7+
use Ecourty\McpServerBundle\Attribute\AsPrompt;
8+
use Ecourty\McpServerBundle\Enum\PromptRole;
9+
use Ecourty\McpServerBundle\IO\Prompt\Content\TextContent;
10+
use Ecourty\McpServerBundle\IO\Prompt\PromptMessage;
11+
use Ecourty\McpServerBundle\IO\Prompt\PromptResult;
12+
13+
#[AsPrompt(
14+
name: 'say_hello',
15+
description: 'Says hello',
16+
)]
17+
class SayHelloPrompt
18+
{
19+
public function __invoke(): PromptResult
20+
{
21+
return new PromptResult(
22+
description: 'Says hello',
23+
messages: [
24+
new PromptMessage(
25+
role: PromptRole::SYSTEM,
26+
content: new TextContent('Hello!'),
27+
),
28+
],
29+
);
30+
}
31+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\TestApp\Tool;
6+
7+
use Ecourty\McpServerBundle\Attribute\AsTool;
8+
use Ecourty\McpServerBundle\IO\TextToolResult;
9+
use Ecourty\McpServerBundle\IO\ToolResult;
10+
11+
#[AsTool(
12+
name: 'date_time',
13+
description: 'Retrieve the date and time of the server',
14+
)]
15+
class DateTimeTool
16+
{
17+
public function __invoke(): ToolResult
18+
{
19+
return new ToolResult([
20+
new TextToolResult(content: (new \DateTime('now'))->format('Y-m-d H:i:s')),
21+
]);
22+
}
23+
}

0 commit comments

Comments
 (0)