From 75fd737a4c683adfb05d5369f7248918576fe208 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 27 May 2026 10:53:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(mcp)=20:=20d=C3=A9coder=20les=20arguments?= =?UTF-8?q?=20tableaux/objets=20s=C3=A9rialis=C3=A9s=20en=20string=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complément du fix scalaire : certains proxies MCP sérialisent aussi les arguments tableaux/objets en string JSON (ex: tagIds arrive en "[3]" au lieu de [3]). Le schéma array les rejetait en 422, et castToArray du SDK ne décode pas les strings JSON. CoerceJsonEncodedArgumentsListener écoute le RequestEvent du SDK (dispatché avant tout handler) et, piloté par le schéma du tool, décode les arguments string dont le type cible est array/object. Les params string ne sont jamais touchés (sûr pour les titres/descriptions ressemblant à du JSON). Corrige le 422 'Expected array|null, but received string' sur tagIds / collaboratorIds lors des appels depuis Claude. --- .../CoerceJsonEncodedArgumentsListener.php | 99 +++++++++++++++++++ ...CoerceJsonEncodedArgumentsListenerTest.php | 91 +++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/Mcp/EventListener/CoerceJsonEncodedArgumentsListener.php create mode 100644 tests/Unit/Mcp/CoerceJsonEncodedArgumentsListenerTest.php 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; + } +}