diff --git a/src/Mcp/EventListener/CoerceJsonEncodedArgumentsListener.php b/src/Mcp/EventListener/CoerceJsonEncodedArgumentsListener.php new file mode 100644 index 0000000..a756dd8 --- /dev/null +++ b/src/Mcp/EventListener/CoerceJsonEncodedArgumentsListener.php @@ -0,0 +1,99 @@ +getRequest(); + if (!$request instanceof CallToolRequest) { + return; + } + + $arguments = $request->arguments; + if ([] === $arguments) { + return; + } + + $properties = $this->toolProperties($request->name); + if (null === $properties) { + return; + } + + $changed = false; + foreach ($arguments as $name => $value) { + if (!is_string($value) || !is_array($properties[$name] ?? null)) { + continue; + } + + $types = (array) ($properties[$name]['type'] ?? []); + if ([] === array_intersect(['array', 'object'], $types)) { + continue; + } + + $decoded = json_decode($value, true); + if (is_array($decoded)) { + $arguments[$name] = $decoded; + $changed = true; + } + } + + if ($changed) { + $event->setRequest( + new CallToolRequest($request->name, $arguments) + ->withId($request->getId()) + ->withMeta($request->getMeta()), + ); + } + } + + /** + * @return null|array + */ + private function toolProperties(string $toolName): ?array + { + try { + $schema = $this->registry->getTool($toolName)->tool->inputSchema; + } catch (Throwable) { + return null; + } + + $properties = $schema['properties'] ?? null; + + return is_array($properties) ? $properties : null; + } +} diff --git a/tests/Unit/Mcp/CoerceJsonEncodedArgumentsListenerTest.php b/tests/Unit/Mcp/CoerceJsonEncodedArgumentsListenerTest.php new file mode 100644 index 0000000..4b42db8 --- /dev/null +++ b/tests/Unit/Mcp/CoerceJsonEncodedArgumentsListenerTest.php @@ -0,0 +1,91 @@ + 'object', + 'properties' => [ + 'id' => ['type' => ['integer', 'string']], + 'title' => ['type' => 'string'], + 'tagIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]], + 'collaboratorIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]], + ], + ]; + + public function testDecodesJsonStringArrayForArrayTypedParam(): void + { + $result = $this->handle(['tagIds' => '[3]', 'collaboratorIds' => '[5,6]']); + + self::assertSame([3], $result->arguments['tagIds']); + self::assertSame([5, 6], $result->arguments['collaboratorIds']); + } + + public function testLeavesRealArrayUntouched(): void + { + $result = $this->handle(['tagIds' => [3]]); + + self::assertSame([3], $result->arguments['tagIds']); + } + + public function testDoesNotTouchStringTypedParamEvenIfItLooksLikeJson(): void + { + $result = $this->handle(['title' => '[1,2]']); + + // title is schema-typed string -> must stay the literal string. + self::assertSame('[1,2]', $result->arguments['title']); + } + + public function testLeavesScalarTypedParamUntouched(): void + { + // id is integer/string typed -> not an array/object, handled by the schema + // relaxation + SDK cast, not by this listener. + $result = $this->handle(['id' => '463']); + + self::assertSame('463', $result->arguments['id']); + } + + public function testPreservesRequestId(): void + { + $result = $this->handle(['tagIds' => '[3]']); + + self::assertSame(1, $result->getId()); + } + + /** + * @param array $arguments + */ + private function handle(array $arguments): CallToolRequest + { + $tool = new Tool('update-task', self::SCHEMA, null, null); + $reference = new ToolReference($tool, static fn () => null); + + $registry = $this->createMock(RegistryInterface::class); + $registry->method('getTool')->willReturn($reference); + + $request = new CallToolRequest('update-task', $arguments)->withId(1); + $event = new RequestEvent($request, $this->createMock(SessionInterface::class)); + + (new CoerceJsonEncodedArgumentsListener($registry))($event); + + $result = $event->getRequest(); + self::assertInstanceOf(CallToolRequest::class, $result); + + return $result; + } +}