Skip to content

Commit 4cb618f

Browse files
committed
minor #8 Support batching (Nyholm)
This PR was squashed before being merged into the main branch. Discussion ---------- Support batching | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | no | Issues | #5 | License | MIT We need to add a test or two to this. Commits ------- 130dd7b Support batching
2 parents 9b534aa + 130dd7b commit 4cb618f

File tree

9 files changed

+108
-49
lines changed

9 files changed

+108
-49
lines changed

src/mcp-sdk/src/Exception/HandlerNotFoundException.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
/*
64
* This file is part of the Symfony package.
75
*

src/mcp-sdk/src/Exception/InvalidCursorException.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
/*
64
* This file is part of the Symfony package.
75
*
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\McpSdk\Exception;
13+
14+
class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface
15+
{
16+
}

src/mcp-sdk/src/Message/Factory.php

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,31 @@
1111

1212
namespace Symfony\AI\McpSdk\Message;
1313

14+
use Symfony\AI\McpSdk\Exception\InvalidInputMessageException;
15+
1416
final class Factory
1517
{
16-
public function create(string $json): Request|Notification
18+
/**
19+
* @return iterable<Notification|Request|InvalidInputMessageException>
20+
*
21+
* @throws \JsonException
22+
*/
23+
public function create(string $input): iterable
1724
{
18-
$data = json_decode($json, true, flags: \JSON_THROW_ON_ERROR);
25+
$data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);
1926

20-
if (!isset($data['method'])) {
21-
throw new \InvalidArgumentException('Invalid JSON-RPC request, missing method');
27+
if ('{' === $input[0]) {
28+
$data = [$data];
2229
}
2330

24-
if (str_starts_with((string) $data['method'], 'notifications/')) {
25-
return Notification::from($data);
31+
foreach ($data as $message) {
32+
if (!isset($message['method'])) {
33+
yield new InvalidInputMessageException('Invalid JSON-RPC request, missing "method".');
34+
} elseif (str_starts_with((string) $message['method'], 'notifications/')) {
35+
yield Notification::from($message);
36+
} else {
37+
yield Request::from($message);
38+
}
2639
}
27-
28-
return Request::from($data);
2940
}
3041
}

src/mcp-sdk/src/Server.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@ public function connect(TransportInterface $transport): void
3636
}
3737

3838
try {
39-
$response = $this->jsonRpcHandler->process($message);
39+
foreach ($this->jsonRpcHandler->process($message) as $response) {
40+
if (null === $response) {
41+
continue;
42+
}
43+
44+
$transport->send($response);
45+
}
4046
} catch (\JsonException $e) {
41-
$this->logger->error('Failed to process message', [
47+
$this->logger->error('Failed to encode response to JSON', [
4248
'message' => $message,
4349
'exception' => $e,
4450
]);
4551
continue;
4652
}
47-
48-
if (null === $response) {
49-
continue;
50-
}
51-
52-
$transport->send($response);
5353
}
5454

5555
usleep(1000);

src/mcp-sdk/src/Server/JsonRpcHandler.php

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Psr\Log\LoggerInterface;
1515
use Symfony\AI\McpSdk\Exception\ExceptionInterface;
1616
use Symfony\AI\McpSdk\Exception\HandlerNotFoundException;
17+
use Symfony\AI\McpSdk\Exception\InvalidInputMessageException;
1718
use Symfony\AI\McpSdk\Exception\NotFoundExceptionInterface;
1819
use Symfony\AI\McpSdk\Message\Error;
1920
use Symfony\AI\McpSdk\Message\Factory;
@@ -51,41 +52,49 @@ public function __construct(
5152
}
5253

5354
/**
55+
* @return iterable<string|null>
56+
*
5457
* @throws ExceptionInterface
5558
* @throws \JsonException
5659
*/
57-
public function process(string $message): ?string
60+
public function process(string $input): iterable
5861
{
59-
$this->logger->info('Received message to process', ['message' => $message]);
62+
$this->logger->info('Received message to process', ['message' => $input]);
6063

6164
try {
62-
$message = $this->messageFactory->create($message);
65+
$messages = $this->messageFactory->create($input);
6366
} catch (\JsonException $e) {
6467
$this->logger->warning('Failed to decode json message', ['exception' => $e]);
6568

66-
return $this->encodeResponse(Error::parseError($e->getMessage()));
67-
} catch (\InvalidArgumentException $e) {
68-
$this->logger->warning('Failed to create message', ['exception' => $e]);
69+
yield $this->encodeResponse(Error::parseError($e->getMessage()));
6970

70-
return $this->encodeResponse(Error::invalidRequest(0, $e->getMessage()));
71+
return;
7172
}
7273

73-
$this->logger->info('Decoded incoming message', ['message' => $message]);
74+
foreach ($messages as $message) {
75+
if ($message instanceof InvalidInputMessageException) {
76+
$this->logger->warning('Failed to create message', ['exception' => $message]);
77+
yield $this->encodeResponse(Error::invalidRequest(0, $message->getMessage()));
78+
continue;
79+
}
7480

75-
try {
76-
return $message instanceof Notification
77-
? $this->handleNotification($message)
78-
: $this->encodeResponse($this->handleRequest($message));
79-
} catch (\DomainException) {
80-
return null;
81-
} catch (NotFoundExceptionInterface $e) {
82-
$this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]);
81+
$this->logger->info('Decoded incoming message', ['message' => $message]);
8382

84-
return $this->encodeResponse(Error::methodNotFound($message->id ?? 0, $e->getMessage()));
85-
} catch (\InvalidArgumentException $e) {
86-
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
83+
try {
84+
yield $message instanceof Notification
85+
? $this->handleNotification($message)
86+
: $this->encodeResponse($this->handleRequest($message));
87+
} catch (\DomainException) {
88+
yield null;
89+
} catch (NotFoundExceptionInterface $e) {
90+
$this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]);
8791

88-
return $this->encodeResponse(Error::invalidParams($message->id ?? 0, $e->getMessage()));
92+
yield $this->encodeResponse(Error::methodNotFound($message->id ?? 0, $e->getMessage()));
93+
} catch (\InvalidArgumentException $e) {
94+
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
95+
96+
yield $this->encodeResponse(Error::invalidParams($message->id ?? 0, $e->getMessage()));
97+
}
8998
}
9099
}
91100

src/mcp-sdk/tests/Message/FactoryTest.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHPUnit\Framework\Attributes\CoversClass;
1515
use PHPUnit\Framework\Attributes\Small;
1616
use PHPUnit\Framework\TestCase;
17+
use Symfony\AI\McpSdk\Exception\InvalidInputMessageException;
1718
use Symfony\AI\McpSdk\Message\Factory;
1819
use Symfony\AI\McpSdk\Message\Notification;
1920
use Symfony\AI\McpSdk\Message\Request;
@@ -29,11 +30,23 @@ protected function setUp(): void
2930
$this->factory = new Factory();
3031
}
3132

33+
/**
34+
* @param iterable<mixed> $items
35+
*/
36+
private function first(iterable $items): mixed
37+
{
38+
foreach ($items as $item) {
39+
return $item;
40+
}
41+
42+
return null;
43+
}
44+
3245
public function testCreateRequest(): void
3346
{
3447
$json = '{"jsonrpc": "2.0", "method": "test_method", "params": {"foo": "bar"}, "id": 123}';
3548

36-
$result = $this->factory->create($json);
49+
$result = $this->first($this->factory->create($json));
3750

3851
self::assertInstanceOf(Request::class, $result);
3952
self::assertSame('test_method', $result->method);
@@ -45,7 +58,7 @@ public function testCreateNotification(): void
4558
{
4659
$json = '{"jsonrpc": "2.0", "method": "notifications/test_event", "params": {"foo": "bar"}}';
4760

48-
$result = $this->factory->create($json);
61+
$result = $this->first($this->factory->create($json));
4962

5063
self::assertInstanceOf(Notification::class, $result);
5164
self::assertSame('notifications/test_event', $result->method);
@@ -56,14 +69,26 @@ public function testInvalidJson(): void
5669
{
5770
$this->expectException(\JsonException::class);
5871

59-
$this->factory->create('invalid json');
72+
$this->first($this->factory->create('invalid json'));
6073
}
6174

6275
public function testMissingMethod(): void
6376
{
64-
$this->expectException(\InvalidArgumentException::class);
65-
$this->expectExceptionMessage('Invalid JSON-RPC request, missing method');
77+
$result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}'));
78+
self::assertInstanceOf(InvalidInputMessageException::class, $result);
79+
$this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage());
80+
}
6681

67-
$this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}');
82+
public function testBatchMissingMethod(): void
83+
{
84+
$results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/test_event", "params": {}, "id": 2}]');
85+
86+
$results = iterator_to_array($results);
87+
$result = array_shift($results);
88+
self::assertInstanceOf(InvalidInputMessageException::class, $result);
89+
$this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage());
90+
91+
$result = array_shift($results);
92+
self::assertInstanceOf(Notification::class, $result);
6893
}
6994
}

src/mcp-sdk/tests/Server/JsonRpcHandlerTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ public function testHandleMultipleNotifications(): void
5151
$handlerC->expects($this->once())->method('handle');
5252

5353
$jsonRpc = new JsonRpcHandler(new Factory(), [], [$handlerA, $handlerB, $handlerC], new NullLogger());
54-
$jsonRpc->process(
54+
$result = $jsonRpc->process(
5555
'{"jsonrpc": "2.0", "id": 1, "method": "notifications/foobar"}'
5656
);
57+
iterator_to_array($result);
5758
}
5859

5960
#[TestDox('Make sure a single request can NOT be handled by multiple handlers.')]
@@ -81,8 +82,9 @@ public function testHandleMultipleRequests(): void
8182
$handlerC->expects($this->never())->method('createResponse');
8283

8384
$jsonRpc = new JsonRpcHandler(new Factory(), [$handlerA, $handlerB, $handlerC], [], new NullLogger());
84-
$jsonRpc->process(
85+
$result = $jsonRpc->process(
8586
'{"jsonrpc": "2.0", "id": 1, "method": "request/foobar"}'
8687
);
88+
iterator_to_array($result);
8789
}
8890
}

src/mcp-sdk/tests/ServerTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function testJsonExceptions(): void
3636
->disableOriginalConstructor()
3737
->onlyMethods(['process'])
3838
->getMock();
39-
$handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), 'success');
39+
$handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']);
4040

4141
$transport = $this->getMockBuilder(InMemoryTransport::class)
4242
->setConstructorArgs([['foo', 'bar']])

0 commit comments

Comments
 (0)