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

Commit 8b03e44

Browse files
committed
feat(mcp): support STDIO transport
1 parent 6f7f526 commit 8b03e44

9 files changed

Lines changed: 1117 additions & 525 deletions

File tree

src/Command/EntrypointCommand.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Command;
6+
7+
use Ecourty\McpServerBundle\Controller\EntrypointController;
8+
use Ecourty\McpServerBundle\EventListener\ExceptionListener;
9+
use Ecourty\McpServerBundle\HttpFoundation\JsonRpcRequest;
10+
use Symfony\Component\Console\Attribute\AsCommand;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Input\InputArgument;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
18+
use Symfony\Component\HttpKernel\HttpKernelInterface;
19+
use Symfony\Component\HttpKernel\KernelInterface;
20+
use Symfony\Component\Serializer\SerializerInterface;
21+
22+
#[AsCommand(
23+
name: 'mcp:stdio',
24+
description: 'MCP server STDIO entrypoint',
25+
)]
26+
class EntrypointCommand extends Command
27+
{
28+
public function __construct(
29+
private readonly SerializerInterface $serializer,
30+
private readonly EntrypointController $entrypointController,
31+
private readonly ExceptionListener $exceptionListener,
32+
private readonly KernelInterface $kernel,
33+
private readonly RequestStack $requestStack,
34+
) {
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this->addArgument('jsonrpc_request', InputArgument::REQUIRED, 'JSONRPC-formatted request');
41+
}
42+
43+
protected function execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
$jsonRpcRequestString = (string) $input->getArgument('jsonrpc_request');
46+
if (empty($jsonRpcRequestString) === true) {
47+
throw new \UnexpectedValueException('Missing request argument');
48+
}
49+
50+
$jsonRpcRequest = $this->serializer->deserialize($jsonRpcRequestString, JsonRpcRequest::class, 'json');
51+
$request = new Request(content: $jsonRpcRequestString);
52+
53+
try {
54+
$response = $this->entrypointController->__invoke($jsonRpcRequest, $request);
55+
} catch (\Throwable $exception) {
56+
$this->requestStack->push($request);
57+
$exceptionEvent = new ExceptionEvent(
58+
kernel: $this->kernel,
59+
request: $request,
60+
requestType: HttpKernelInterface::MAIN_REQUEST,
61+
e: $exception,
62+
);
63+
$this->exceptionListener->onKernelException($exceptionEvent);
64+
65+
$response = $exceptionEvent->getResponse();
66+
}
67+
68+
if ($response === null) {
69+
throw new \RuntimeException('No response received');
70+
}
71+
72+
$output->write((string) $response->getContent());
73+
74+
return self::SUCCESS;
75+
}
76+
}

src/EventListener/ExceptionListener.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
use Ecourty\McpServerBundle\Enum\McpErrorCode;
88
use Ecourty\McpServerBundle\Exception\MethodHandlerNotFoundException;
99
use Ecourty\McpServerBundle\Exception\RequestHandlingException;
10+
use Ecourty\McpServerBundle\Exception\ToolNotFoundException;
1011
use Ecourty\McpServerBundle\Service\ResponseFactory;
1112
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
13+
use Symfony\Component\HttpFoundation\JsonResponse;
1214
use Symfony\Component\HttpFoundation\RequestStack;
1315
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
1416
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -38,13 +40,28 @@ public function onKernelException(ExceptionEvent $event): void
3840

3941
$response = match (true) {
4042
$exception instanceof UnprocessableEntityHttpException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::PARSE_ERROR),
41-
$exception instanceof MethodHandlerNotFoundException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::TOOL_NOT_FOUND),
42-
$exception instanceof RequestHandlingException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::INTERNAL_ERROR),
43+
$exception instanceof MethodHandlerNotFoundException => $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::METHOD_NOT_FOUND),
44+
$exception instanceof RequestHandlingException => $this->handleRequestHandlingException($exception, $jsonRpcRequestId),
4345
default => null,
4446
};
4547

4648
if ($response !== null) {
4749
$event->setResponse($response);
4850
}
4951
}
52+
53+
private function handleRequestHandlingException(
54+
RequestHandlingException $exception,
55+
string|int|null $jsonRpcRequestId = null,
56+
): JsonResponse {
57+
$previous = $exception->getPrevious();
58+
if ($previous instanceof ToolNotFoundException === true) {
59+
return $this->responseFactory->error(
60+
id: $jsonRpcRequestId,
61+
errorCode: McpErrorCode::TOOL_NOT_FOUND,
62+
);
63+
}
64+
65+
return $this->responseFactory->error($jsonRpcRequestId, McpErrorCode::INTERNAL_ERROR);
66+
}
5067
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Exception;
6+
7+
class ToolNotFoundException extends \Exception
8+
{
9+
}

src/MethodHandler/ToolsCallMethodHandler.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Ecourty\McpServerBundle\Event\ToolCallExceptionEvent;
1111
use Ecourty\McpServerBundle\Event\ToolResultEvent;
1212
use Ecourty\McpServerBundle\Exception\ToolCallException;
13+
use Ecourty\McpServerBundle\Exception\ToolNotFoundException;
1314
use Ecourty\McpServerBundle\HttpFoundation\JsonRpcRequest;
1415
use Ecourty\McpServerBundle\IO\ToolResult;
1516
use Ecourty\McpServerBundle\Service\InputSanitizer;
@@ -52,7 +53,7 @@ public function handle(JsonRpcRequest $request): array
5253
$toolDefinition = $this->toolRegistry->getToolDefinition($toolName);
5354

5455
if ($tool === null) {
55-
throw new \InvalidArgumentException(\sprintf('Tool "%s" not found.', $request->params['name']));
56+
throw new ToolNotFoundException(\sprintf('Tool "%s" not found.', $request->params['name']));
5657
}
5758

5859
if ($toolDefinition === null) {
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Tests\Command;
6+
7+
use Ecourty\McpServerBundle\Tests\Support\CommandTestCase;
8+
use Ecourty\McpServerBundle\Tests\Support\Trait\McpAssertTrait;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
use Symfony\Component\Console\Tester\CommandTester;
11+
12+
/**
13+
* @coversDefaultClass \Ecourty\McpServerBundle\Command\EntrypointCommand
14+
*/
15+
class EntrypointCommandTest extends CommandTestCase
16+
{
17+
use McpAssertTrait;
18+
19+
private CommandTester $commandTester;
20+
21+
protected function setUp(): void
22+
{
23+
$this->commandTester = $this->getCommandTester('mcp:stdio');
24+
}
25+
26+
#[DataProvider('provideNonExistentMethodRequest')]
27+
public function testExecuteWithNonExistentMethod(array $request): void
28+
{
29+
$this->commandTester->execute([
30+
'jsonrpc_request' => json_encode($request),
31+
]);
32+
33+
$this->commandTester->assertCommandIsSuccessful();
34+
$output = $this->commandTester->getDisplay(true);
35+
36+
$this->assertNonExistentMethod($output);
37+
}
38+
39+
#[DataProvider('provideInitializeRequest')]
40+
public function testExecuteInitialize(array $request): void
41+
{
42+
$this->commandTester->execute([
43+
'jsonrpc_request' => json_encode($request),
44+
]);
45+
46+
$this->commandTester->assertCommandIsSuccessful();
47+
$output = $this->commandTester->getDisplay(true);
48+
49+
$this->assertInitializeResponse($output);
50+
}
51+
52+
#[DataProvider('provideToolsListRequest')]
53+
public function testExecuteToolsList(array $request): void
54+
{
55+
$this->commandTester->execute([
56+
'jsonrpc_request' => json_encode($request),
57+
]);
58+
59+
$this->commandTester->assertCommandIsSuccessful();
60+
$output = $this->commandTester->getDisplay(true);
61+
62+
$this->assertToolsList($output);
63+
}
64+
65+
#[DataProvider('provideNonExistingToolRequest')]
66+
public function testNonExistentTool(array $request): void
67+
{
68+
$this->commandTester->execute([
69+
'jsonrpc_request' => json_encode($request),
70+
]);
71+
72+
$this->commandTester->assertCommandIsSuccessful();
73+
$output = $this->commandTester->getDisplay(true);
74+
75+
$this->assertNonExistingToolResponse($output);
76+
}
77+
78+
#[DataProvider('provideToolCallWithInvalidParamsRequest')]
79+
public function testToolCallWithInvalidParams(array $request): void
80+
{
81+
$this->commandTester->execute([
82+
'jsonrpc_request' => json_encode($request),
83+
]);
84+
85+
$this->commandTester->assertCommandIsSuccessful();
86+
$output = $this->commandTester->getDisplay(true);
87+
88+
$this->assertToolCallWithInvalidParamsResponse($output);
89+
}
90+
91+
#[DataProvider('provideTestToolCalls')]
92+
public function testToolCalls(string $method, array $params, mixed $expectedResponse): void
93+
{
94+
$request = \sprintf(
95+
'{"jsonrpc": "2.0", "id": 1, "method": "%s", "params": %s}',
96+
$method,
97+
json_encode($params, \JSON_THROW_ON_ERROR),
98+
);
99+
100+
$this->commandTester->execute([
101+
'jsonrpc_request' => $request,
102+
]);
103+
104+
$this->commandTester->assertCommandIsSuccessful();
105+
$output = $this->commandTester->getDisplay(true);
106+
$responseContent = json_decode($output, true);
107+
108+
$this->assertSame($expectedResponse, $responseContent);
109+
}
110+
111+
#[DataProvider('providePromptsListRequest')]
112+
public function testPromptsList(array $request): void
113+
{
114+
$this->commandTester->execute([
115+
'jsonrpc_request' => json_encode($request),
116+
]);
117+
118+
$this->commandTester->assertCommandIsSuccessful();
119+
$output = $this->commandTester->getDisplay(true);
120+
121+
$this->assertPromptsList($output);
122+
}
123+
124+
#[DataProvider('providePromptGetRequest')]
125+
public function testPromptGet(array $request, string $scope, string $changes): void
126+
{
127+
$this->commandTester->execute([
128+
'jsonrpc_request' => json_encode($request),
129+
]);
130+
131+
$this->commandTester->assertCommandIsSuccessful();
132+
$output = $this->commandTester->getDisplay(true);
133+
134+
$this->assertPromptGet($output, $changes, $scope);
135+
}
136+
137+
#[DataProvider('providePromptGetWithMissingArgument')]
138+
public function testPromptGetWithNonRequiredParameterLeftEmpty(array $request): void
139+
{
140+
$this->commandTester->execute([
141+
'jsonrpc_request' => json_encode($request),
142+
]);
143+
144+
$this->commandTester->assertCommandIsSuccessful();
145+
$output = $this->commandTester->getDisplay(true);
146+
147+
$this->assertPromptGetWithNonRequiredArgumentLeftEmpty($output);
148+
}
149+
150+
#[DataProvider('providePromptWithUnsafeParametersRequest')]
151+
public function testPromptWithUnsafeContent(array $request, string $unsafeContent): void
152+
{
153+
$this->commandTester->execute([
154+
'jsonrpc_request' => json_encode($request),
155+
]);
156+
157+
$this->commandTester->assertCommandIsSuccessful();
158+
$output = $this->commandTester->getDisplay(true);
159+
160+
$this->assertPromptWithUnsafeParameters($output, $unsafeContent);
161+
}
162+
163+
#[DataProvider('provideDirectResourceListRequest')]
164+
public function testDirectResourceList(array $request): void
165+
{
166+
$this->commandTester->execute([
167+
'jsonrpc_request' => json_encode($request),
168+
]);
169+
170+
$this->commandTester->assertCommandIsSuccessful();
171+
$output = $this->commandTester->getDisplay(true);
172+
173+
$this->assertDirectResourcesList($output);
174+
}
175+
176+
#[DataProvider('provideTemplateResourceListRequest')]
177+
public function testTemplateResourceList(array $request): void
178+
{
179+
$this->commandTester->execute([
180+
'jsonrpc_request' => json_encode($request),
181+
]);
182+
183+
$this->commandTester->assertCommandIsSuccessful();
184+
$output = $this->commandTester->getDisplay(true);
185+
186+
$this->assertTemplateResourcesList($output);
187+
}
188+
189+
#[DataProvider('provideNotFoundResourceRequest')]
190+
public function testCallNotFoundResource(array $request): void
191+
{
192+
$this->commandTester->execute([
193+
'jsonrpc_request' => json_encode($request),
194+
]);
195+
196+
$this->commandTester->assertCommandIsSuccessful();
197+
$output = $this->commandTester->getDisplay(true);
198+
199+
$this->assertCallNotFoundResource($output);
200+
}
201+
202+
#[DataProvider('provideDirectResourceRequest')]
203+
public function testCallDirectResource(array $request): void
204+
{
205+
$this->commandTester->execute([
206+
'jsonrpc_request' => json_encode($request),
207+
]);
208+
209+
$this->commandTester->assertCommandIsSuccessful();
210+
$output = $this->commandTester->getDisplay(true);
211+
212+
$this->assertCallDirectResource($output);
213+
}
214+
215+
#[DataProvider('provideTestDataForTemplateResourceCall')]
216+
public function testCallTemplateResource(array $request, array $expectedResult): void
217+
{
218+
$this->commandTester->execute([
219+
'jsonrpc_request' => json_encode($request),
220+
]);
221+
222+
$this->commandTester->assertCommandIsSuccessful();
223+
$output = $this->commandTester->getDisplay(true);
224+
225+
$parsedOutput = json_decode($output, true);
226+
$result = $parsedOutput['result'] ?? null;
227+
228+
$this->assertNotNull($result);
229+
230+
$this->assertEquals($result, $expectedResult);
231+
}
232+
}

0 commit comments

Comments
 (0)